diff --git a/App/Misc/HTMLRenderingHelpers.swift b/App/Misc/HTMLRenderingHelpers.swift index 503990a8d..50f66aa98 100644 --- a/App/Misc/HTMLRenderingHelpers.swift +++ b/App/Misc/HTMLRenderingHelpers.swift @@ -5,7 +5,20 @@ import HTMLReader extension HTMLDocument { - + + /// Finds links that appear to be to Bluesky posts and adds a `data-bluesky-post` attribute to those links. + func addAttributeToBlueskyLinks() { + for a in nodes(matchingSelector: "a[href *= 'bsky.app']") { + guard let href = a["href"], + let url = URL(string: href), + url.host?.caseInsensitiveCompare("bsky.app") == .orderedSame, + url.pathComponents.contains(where: { $0.caseInsensitiveCompare("post") == .orderedSame }), + a.textContent.hasPrefix("https:") // approximate raw-link check + else { continue } + a["data-bluesky-post"] = "" + } + } + /// Finds links that appear to be to tweets and adds a `data-tweet-id` attribute to those links. func addAttributeToTweetLinks() { for a in nodes(matchingSelector: "a[href *= 'twitter.com'], a[href *= 'x.com']") { diff --git a/App/Posts/OEmbedFetcher.swift b/App/Posts/OEmbedFetcher.swift new file mode 100644 index 000000000..43a13a540 --- /dev/null +++ b/App/Posts/OEmbedFetcher.swift @@ -0,0 +1,26 @@ +// OEmbedFetcher.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Foundation + +/// Fetches OEmbed HTML fragments on behalf of a web view. +final class OEmbedFetcher { + private let session: URLSession = URLSession(configuration: .ephemeral) + + func fetch(url: URL, id: String) async -> String { + do { + let (responseData, urlResponse) = try await session.data(from: url) + if let status = (urlResponse as? HTTPURLResponse)?.statusCode, status >= 400 { + struct Failure: Error {} + throw Failure() + } + let json = try JSONSerialization.jsonObject(with: responseData) + let callback = try JSONSerialization.data(withJSONObject: ["body": json]) + return String(data: callback, encoding: .utf8)! + } catch { + let callback = try! JSONSerialization.data(withJSONObject: ["error": "\(error)"]) + return String(data: callback, encoding: .utf8)! + } + } +} diff --git a/App/Resources/RenderView.js b/App/Resources/RenderView.js index c20f75618..24f7ffac2 100644 --- a/App/Resources/RenderView.js +++ b/App/Resources/RenderView.js @@ -11,6 +11,76 @@ if (!window.Awful) { } +/** + Retrieves an OEmbed HTML fragment. + + @param url The OEmbed URL. + @returns The OEmbed response, probably JSON of some kind. + @throws When the OEmbed response is unavailable. + */ +Awful.fetchOEmbed = async function(url) { + return new Promise((resolve, reject) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const id = [...new Array(8)].map(_ => chars.charAt(Math.floor(Math.random() * chars.length))).join(''); + waitingOEmbedResponses[id] = function(response) { + delete waitingOEmbedResponses[id]; + if (response.error) { + reject(response.error); + } else { + resolve(response.body); + } + }; + window.webkit.messageHandlers.fetchOEmbedFragment.postMessage({ id, url }); + }); +}; + +/** + Callback for fetchOEmbed. + + @param id The value for the `id` key in the message body. + @param response An object with either a `body` key with the JSON response, or an `error` key explaining a failure. + */ +Awful.didFetchOEmbed = function(id, response) { + waitingOEmbedResponses[id]?.(response); +}; +var waitingOEmbedResponses = {}; + + +/** + Turns apparent links to Bluesky posts into actual embedded Bluesky posts. + */ +Awful.embedBlueskyPosts = function() { + for (const a of document.querySelectorAll('a[data-bluesky-post]')) { + (async function() { + const search = new URLSearchParams(); + search.set('url', a.href); + const url = `https://embed.bsky.app/oembed?${search}`; + try { + const oembed = await Awful.fetchOEmbed(url); + if (!oembed.html) { + return; + } + const div = document.createElement('div'); + div.classList.add('bluesky-post'); + div.innerHTML = oembed.html; + a.parentNode.replaceChild(div, a); + //