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);
+ //