Skip to content

Commit 82c9ac1

Browse files
authored
Embed Bluesky posts (#1202)
1 parent 020d05b commit 82c9ac1

File tree

11 files changed

+223
-5
lines changed

11 files changed

+223
-5
lines changed

App/Misc/HTMLRenderingHelpers.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,20 @@
55
import HTMLReader
66

77
extension HTMLDocument {
8-
8+
9+
/// Finds links that appear to be to Bluesky posts and adds a `data-bluesky-post` attribute to those links.
10+
func addAttributeToBlueskyLinks() {
11+
for a in nodes(matchingSelector: "a[href *= 'bsky.app']") {
12+
guard let href = a["href"],
13+
let url = URL(string: href),
14+
url.host?.caseInsensitiveCompare("bsky.app") == .orderedSame,
15+
url.pathComponents.contains(where: { $0.caseInsensitiveCompare("post") == .orderedSame }),
16+
a.textContent.hasPrefix("https:") // approximate raw-link check
17+
else { continue }
18+
a["data-bluesky-post"] = ""
19+
}
20+
}
21+
922
/// Finds links that appear to be to tweets and adds a `data-tweet-id` attribute to those links.
1023
func addAttributeToTweetLinks() {
1124
for a in nodes(matchingSelector: "a[href *= 'twitter.com'], a[href *= 'x.com']") {

App/Posts/OEmbedFetcher.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// OEmbedFetcher.swift
2+
//
3+
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app
4+
5+
import Foundation
6+
7+
/// Fetches OEmbed HTML fragments on behalf of a web view.
8+
final class OEmbedFetcher {
9+
private let session: URLSession = URLSession(configuration: .ephemeral)
10+
11+
func fetch(url: URL, id: String) async -> String {
12+
do {
13+
let (responseData, urlResponse) = try await session.data(from: url)
14+
if let status = (urlResponse as? HTTPURLResponse)?.statusCode, status >= 400 {
15+
struct Failure: Error {}
16+
throw Failure()
17+
}
18+
let json = try JSONSerialization.jsonObject(with: responseData)
19+
let callback = try JSONSerialization.data(withJSONObject: ["body": json])
20+
return String(data: callback, encoding: .utf8)!
21+
} catch {
22+
let callback = try! JSONSerialization.data(withJSONObject: ["error": "\(error)"])
23+
return String(data: callback, encoding: .utf8)!
24+
}
25+
}
26+
}

App/Resources/RenderView.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,76 @@ if (!window.Awful) {
1111
}
1212

1313

14+
/**
15+
Retrieves an OEmbed HTML fragment.
16+
17+
@param url The OEmbed URL.
18+
@returns The OEmbed response, probably JSON of some kind.
19+
@throws When the OEmbed response is unavailable.
20+
*/
21+
Awful.fetchOEmbed = async function(url) {
22+
return new Promise((resolve, reject) => {
23+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
24+
const id = [...new Array(8)].map(_ => chars.charAt(Math.floor(Math.random() * chars.length))).join('');
25+
waitingOEmbedResponses[id] = function(response) {
26+
delete waitingOEmbedResponses[id];
27+
if (response.error) {
28+
reject(response.error);
29+
} else {
30+
resolve(response.body);
31+
}
32+
};
33+
window.webkit.messageHandlers.fetchOEmbedFragment.postMessage({ id, url });
34+
});
35+
};
36+
37+
/**
38+
Callback for fetchOEmbed.
39+
40+
@param id The value for the `id` key in the message body.
41+
@param response An object with either a `body` key with the JSON response, or an `error` key explaining a failure.
42+
*/
43+
Awful.didFetchOEmbed = function(id, response) {
44+
waitingOEmbedResponses[id]?.(response);
45+
};
46+
var waitingOEmbedResponses = {};
47+
48+
49+
/**
50+
Turns apparent links to Bluesky posts into actual embedded Bluesky posts.
51+
*/
52+
Awful.embedBlueskyPosts = function() {
53+
for (const a of document.querySelectorAll('a[data-bluesky-post]')) {
54+
(async function() {
55+
const search = new URLSearchParams();
56+
search.set('url', a.href);
57+
const url = `https://embed.bsky.app/oembed?${search}`;
58+
try {
59+
const oembed = await Awful.fetchOEmbed(url);
60+
if (!oembed.html) {
61+
return;
62+
}
63+
const div = document.createElement('div');
64+
div.classList.add('bluesky-post');
65+
div.innerHTML = oembed.html;
66+
a.parentNode.replaceChild(div, a);
67+
// <script> inserted via innerHTML won't execute, but we want whatever Bluesky script to run so it fetches the post content, so clone all <script>s.
68+
for (const scriptNode of div.querySelectorAll('script')) {
69+
const newScript = document.createElement('script');
70+
newScript.text = scriptNode.innerHTML;
71+
const attributes = scriptNode.attributes;
72+
for (let i = 0, len = attributes.length; i < len; i++) {
73+
newScript.setAttribute(attributes[i].name, attributes[i].value);
74+
}
75+
scriptNode.parentNode.replaceChild(newScript, scriptNode);
76+
}
77+
} catch (error) {
78+
console.error(`Could not fetch OEmbed from ${url}: ${error}`);
79+
}
80+
})();
81+
}
82+
};
83+
1484
/**
1585
Turns apparent links to tweets into actual embedded tweets.
1686
*/

App/View Controllers/Messages/MessageViewController.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ final class MessageViewController: ViewController {
2020
private var composeVC: MessageComposeViewController?
2121
private var didLoadOnce = false
2222
private var didRender = false
23+
@FoilDefaultStorage(Settings.embedBlueskyPosts) private var embedBlueskyPosts
2324
@FoilDefaultStorage(Settings.embedTweets) private var embedTweets
2425
@FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics
2526
@FoilDefaultStorage(Settings.fontScale) private var fontScale
2627
private var fractionalContentOffsetOnLoad: CGFloat = 0
2728
@FoilDefaultStorage(Settings.handoffEnabled) private var handoffEnabled
2829
private var loadingView: LoadingView?
30+
private lazy var oEmbedFetcher: OEmbedFetcher = .init()
2931
private let privateMessage: PrivateMessage
3032
@FoilDefaultStorage(Settings.showAvatars) private var showAvatars
3133
@FoilDefaultStorage(Settings.loadImages) private var showImages
@@ -124,6 +126,13 @@ final class MessageViewController: ViewController {
124126
}
125127
}
126128

129+
private func fetchOEmbed(url: URL, id: String) {
130+
Task {
131+
let callbackData = await oEmbedFetcher.fetch(url: url, id: id)
132+
renderView.didFetchOEmbed(id: id, response: callbackData)
133+
}
134+
}
135+
127136
private func showUserActions(from rect: CGRect) {
128137
guard let user = privateMessage.from else { return }
129138

@@ -192,11 +201,19 @@ final class MessageViewController: ViewController {
192201

193202
renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self)
194203
renderView.registerMessage(RenderView.BuiltInMessage.DidFinishLoadingTweets.self)
204+
renderView.registerMessage(RenderView.BuiltInMessage.FetchOEmbedFragment.self)
195205

196206
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressWebView))
197207
longPress.delegate = self
198208
renderView.addGestureRecognizer(longPress)
199-
209+
210+
$embedBlueskyPosts
211+
.dropFirst()
212+
.filter { $0 }
213+
.receive(on: RunLoop.main)
214+
.sink { [weak self] _ in self?.renderView.embedBlueskyPosts() }
215+
.store(in: &cancellables)
216+
200217
$embedTweets
201218
.dropFirst()
202219
.filter { $0 }
@@ -329,7 +346,10 @@ extension MessageViewController: RenderViewDelegate {
329346

330347
loadingView?.removeFromSuperview()
331348
loadingView = nil
332-
349+
350+
if embedBlueskyPosts {
351+
renderView.embedBlueskyPosts()
352+
}
333353
if embedTweets {
334354
renderView.embedTweets()
335355
}
@@ -345,6 +365,9 @@ extension MessageViewController: RenderViewDelegate {
345365
case let didTapHeader as RenderView.BuiltInMessage.DidTapAuthorHeader:
346366
showUserActions(from: didTapHeader.frame)
347367

368+
case let message as RenderView.BuiltInMessage.FetchOEmbedFragment:
369+
fetchOEmbed(url: message.url, id: message.id)
370+
348371
default:
349372
let description = "\(message)"
350373
logger.warning("ignoring unexpected message \(description)")
@@ -398,6 +421,7 @@ private struct RenderModel: StencilContextConvertible {
398421
var htmlContents: String? {
399422
guard let originalHTML = message.innerHTML else { return nil }
400423
let document = HTMLDocument(string: originalHTML)
424+
document.addAttributeToBlueskyLinks()
401425
document.addAttributeToTweetLinks()
402426
if let username = FoilDefaultStorageOptional(Settings.username).wrappedValue {
403427
document.identifyQuotesCitingUser(named: username, shouldHighlight: true)

App/View Controllers/Posts/PostRenderModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ private func massageHTML(_ html: String, isIgnored: Bool, forumID: String) -> St
101101
let document = HTMLDocument(string: html)
102102
document.removeSpoilerStylingAndEvents()
103103
document.removeEmptyEditedByParagraphs()
104+
document.addAttributeToBlueskyLinks()
104105
document.addAttributeToTweetLinks()
105106
document.useHTML5VimeoPlayer()
106107
if let username = UserDefaults.standard.value(for: Settings.username) {

App/View Controllers/Posts/PostsPageViewController.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ final class PostsPageViewController: ViewController {
2626
private var cancellables: Set<AnyCancellable> = []
2727
@FoilDefaultStorage(Settings.canSendPrivateMessages) private var canSendPrivateMessages
2828
@FoilDefaultStorage(Settings.darkMode) private var darkMode
29+
@FoilDefaultStorage(Settings.embedBlueskyPosts) private var embedBlueskyPosts
2930
@FoilDefaultStorage(Settings.embedTweets) private var embedTweets
3031
@FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics
3132
private var flagRequest: Task<Void, Error>?
@@ -42,6 +43,7 @@ final class PostsPageViewController: ViewController {
4243
private var messageViewController: MessageComposeViewController?
4344
private var networkOperation: Task<(posts: [Post], firstUnreadPost: Int?, advertisementHTML: String), Error>?
4445
private var observers: [NSKeyValueObservation] = []
46+
private lazy var oEmbedFetcher: OEmbedFetcher = .init()
4547
private(set) var page: ThreadPage?
4648
@FoilDefaultStorage(Settings.pullForNext) private var pullForNext
4749
private var replyWorkspace: ReplyWorkspace?
@@ -100,6 +102,7 @@ final class PostsPageViewController: ViewController {
100102
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidFinishLoadingTweets.self)
101103
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapPostActionButton.self)
102104
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self)
105+
postsView.renderView.registerMessage(RenderView.BuiltInMessage.FetchOEmbedFragment.self)
103106
postsView.topBar.goToParentForum = { [unowned self] in
104107
guard let forum = self.thread.forum else { return }
105108
AppDelegate.instance.open(route: .forum(id: forum.forumID))
@@ -1352,6 +1355,13 @@ final class PostsPageViewController: ViewController {
13521355

13531356
hiddenMenuButton.show(menu: postActionMenu, from: frame)
13541357
}
1358+
1359+
private func fetchOEmbed(url: URL, id: String) {
1360+
Task {
1361+
let callbackData = await oEmbedFetcher.fetch(url: url, id: id)
1362+
postsView.renderView.didFetchOEmbed(id: id, response: callbackData)
1363+
}
1364+
}
13551365

13561366
private func presentDraftMenu(
13571367
from source: DraftMenuSource,
@@ -1552,6 +1562,13 @@ final class PostsPageViewController: ViewController {
15521562
.sink { [weak self] in self?.postsView.renderView.setExternalStylesheet($0.stylesheet) }
15531563
.store(in: &cancellables)
15541564

1565+
$embedBlueskyPosts
1566+
.dropFirst()
1567+
.filter { $0 }
1568+
.receive(on: RunLoop.main)
1569+
.sink { [weak self] _ in self?.postsView.renderView.embedBlueskyPosts() }
1570+
.store(in: &cancellables)
1571+
15551572
$embedTweets
15561573
.dropFirst()
15571574
.receive(on: RunLoop.main)
@@ -1717,6 +1734,9 @@ extension PostsPageViewController: ComposeTextViewControllerDelegate {
17171734

17181735
extension PostsPageViewController: RenderViewDelegate {
17191736
func didFinishRenderingHTML(in view: RenderView) {
1737+
if embedBlueskyPosts {
1738+
view.embedBlueskyPosts()
1739+
}
17201740
if embedTweets {
17211741
view.embedTweets()
17221742
}
@@ -1766,6 +1786,9 @@ extension PostsPageViewController: RenderViewDelegate {
17661786
offset.y = fraction
17671787
postsView.renderView.scrollToFractionalOffset(offset)
17681788
}
1789+
1790+
case let message as RenderView.BuiltInMessage.FetchOEmbedFragment:
1791+
fetchOEmbed(url: message.url, id: message.id)
17691792

17701793
case is FYADFlagRequest:
17711794
fetchNewFlag()

App/Views/RenderView.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,28 @@ extension RenderView: WKScriptMessageHandler {
257257
self.postIndex = postIndex
258258
}
259259
}
260+
261+
struct FetchOEmbedFragment: RenderViewMessage {
262+
static let messageName = "fetchOEmbedFragment"
263+
264+
/// An opaque `id` to use when calling back with the response.
265+
let id: String
266+
267+
/// The OEmbed URL to fetch.
268+
let url: URL
269+
270+
init?(rawMessage: WKScriptMessage, in renderView: RenderView) {
271+
assert(rawMessage.name == Self.messageName)
272+
guard let body = rawMessage.body as? [String: Any],
273+
let id = body["id"] as? String,
274+
let rawURL = body["url"] as? String,
275+
let url = URL(string: rawURL)
276+
else { return nil }
277+
278+
self.id = id
279+
self.url = url
280+
}
281+
}
260282
}
261283
}
262284

@@ -276,7 +298,22 @@ extension CGRect {
276298
// MARK: - Bossing around and retrieving information from the render view
277299

278300
extension RenderView {
279-
301+
302+
/// Turns any links that look like Bluesky posts into an actual Bluesky post embed.
303+
func embedBlueskyPosts() {
304+
Task {
305+
do {
306+
try await webView.eval("""
307+
if (window.Awful) {
308+
Awful.embedBlueskyPosts();
309+
}
310+
""")
311+
} catch {
312+
self.mentionError(error, explanation: "could not evaluate embedBlueskyPosts")
313+
}
314+
}
315+
}
316+
280317
/// Turns any links that look like tweets into an actual tweet embed.
281318
func embedTweets() {
282319
let renderGhostTweets = FoilDefaultStorage(Settings.frogAndGhostEnabled).wrappedValue
@@ -455,6 +492,18 @@ extension RenderView {
455492
rect.origin = convertToRenderView(webDocumentPoint: rect.origin)
456493
return rect
457494
}
495+
496+
func didFetchOEmbed(id: String, response: String) {
497+
Task {
498+
do {
499+
try await webView.eval("""
500+
window.Awful?.didFetchOEmbed(\(escapeForEval(id)), \(response));
501+
""")
502+
} catch {
503+
logger.error("error calling back after fetching oembed: \(error)")
504+
}
505+
}
506+
}
458507

459508
/**
460509
How far the web document is offset from the scroll view's bounds.

Awful.xcodeproj/project.pbxproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
1C917CF81C4F21B800BBF672 /* HairlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC22AB419F972C200D5BABD /* HairlineView.swift */; };
150150
1C9AEBC6210C3B2300C9A567 /* CloseBBcodeTagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9AEBC5210C3B2300C9A567 /* CloseBBcodeTagTests.swift */; };
151151
1C9AEBCE210C3BAF00C9A567 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9AEBCD210C3BAF00C9A567 /* main.swift */; };
152+
1CA3D6FC2D98A7E400D70964 /* OEmbedFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */; };
152153
1CA45D941F2C0AD1005BEEC5 /* RenderView.js in Resources */ = {isa = PBXBuildFile; fileRef = 1CA45D931F2C0AD1005BEEC5 /* RenderView.js */; };
153154
1CA56FEB1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1CA56FEA1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist */; };
154155
1CA887B01F40AE1A0059FEEC /* User+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA887AF1F40AE1A0059FEEC /* User+Presentation.swift */; };
@@ -442,7 +443,8 @@
442443
1C9AEBC5210C3B2300C9A567 /* CloseBBcodeTagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseBBcodeTagTests.swift; sourceTree = "<group>"; };
443444
1C9AEBC7210C3B2300C9A567 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
444445
1C9AEBCD210C3BAF00C9A567 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
445-
1CA45D931F2C0AD1005BEEC5 /* RenderView.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = RenderView.js; sourceTree = "<group>"; };
446+
1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OEmbedFetcher.swift; sourceTree = "<group>"; };
447+
1CA45D931F2C0AD1005BEEC5 /* RenderView.js */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.javascript; path = RenderView.js; sourceTree = "<group>"; tabWidth = 2; };
446448
1CA56FEA1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = PotentiallyObjectionableTexts.plist; sourceTree = "<group>"; };
447449
1CA887AF1F40AE1A0059FEEC /* User+Presentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "User+Presentation.swift"; sourceTree = "<group>"; };
448450
1CAD42B52CD3050400579B8E /* lottie-player.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = "lottie-player.js"; sourceTree = "<group>"; };
@@ -1002,6 +1004,7 @@
10021004
1CC780241612D9DD002AF958 /* Posts */ = {
10031005
isa = PBXGroup;
10041006
children = (
1007+
1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */,
10051008
1C16FBD41CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift */,
10061009
1C8A8CF91A3C14DF00E4F6A4 /* ReplyWorkspace.swift */,
10071010
);
@@ -1550,6 +1553,7 @@
15501553
1C220E3D2B815AFC00DA92B0 /* Bundle+.swift in Sources */,
15511554
1CD0C54F1BE674D700C3AC80 /* PostsPageRefreshSpinnerView.swift in Sources */,
15521555
1CEB5BFF19AB9C1700C82C30 /* InAppActionViewController.swift in Sources */,
1556+
1CA3D6FC2D98A7E400D70964 /* OEmbedFetcher.swift in Sources */,
15531557
1C16FBAA1CB5D38700C88BD1 /* CompositionInputAccessoryView.swift in Sources */,
15541558
1C9AEBCE210C3BAF00C9A567 /* main.swift in Sources */,
15551559
1C16FBE71CBC671A00C88BD1 /* PostRenderModel.swift in Sources */,

0 commit comments

Comments
 (0)