Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion App/Misc/HTMLRenderingHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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']") {
Expand Down
26 changes: 26 additions & 0 deletions App/Posts/OEmbedFetcher.swift
Original file line number Diff line number Diff line change
@@ -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)!
}
}
}
70 changes: 70 additions & 0 deletions App/Resources/RenderView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
// <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.
for (const scriptNode of div.querySelectorAll('script')) {
const newScript = document.createElement('script');
newScript.text = scriptNode.innerHTML;
const attributes = scriptNode.attributes;
for (let i = 0, len = attributes.length; i < len; i++) {
newScript.setAttribute(attributes[i].name, attributes[i].value);
}
scriptNode.parentNode.replaceChild(newScript, scriptNode);
}
} catch (error) {
console.error(`Could not fetch OEmbed from ${url}: ${error}`);
}
})();
}
};

/**
Turns apparent links to tweets into actual embedded tweets.
*/
Expand Down
28 changes: 26 additions & 2 deletions App/View Controllers/Messages/MessageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ final class MessageViewController: ViewController {
private var composeVC: MessageComposeViewController?
private var didLoadOnce = false
private var didRender = false
@FoilDefaultStorage(Settings.embedBlueskyPosts) private var embedBlueskyPosts
@FoilDefaultStorage(Settings.embedTweets) private var embedTweets
@FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics
@FoilDefaultStorage(Settings.fontScale) private var fontScale
private var fractionalContentOffsetOnLoad: CGFloat = 0
@FoilDefaultStorage(Settings.handoffEnabled) private var handoffEnabled
private var loadingView: LoadingView?
private lazy var oEmbedFetcher: OEmbedFetcher = .init()
private let privateMessage: PrivateMessage
@FoilDefaultStorage(Settings.showAvatars) private var showAvatars
@FoilDefaultStorage(Settings.loadImages) private var showImages
Expand Down Expand Up @@ -124,6 +126,13 @@ final class MessageViewController: ViewController {
}
}

private func fetchOEmbed(url: URL, id: String) {
Task {
let callbackData = await oEmbedFetcher.fetch(url: url, id: id)
renderView.didFetchOEmbed(id: id, response: callbackData)
}
}

private func showUserActions(from rect: CGRect) {
guard let user = privateMessage.from else { return }

Expand Down Expand Up @@ -192,11 +201,19 @@ final class MessageViewController: ViewController {

renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self)
renderView.registerMessage(RenderView.BuiltInMessage.DidFinishLoadingTweets.self)
renderView.registerMessage(RenderView.BuiltInMessage.FetchOEmbedFragment.self)

let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPressWebView))
longPress.delegate = self
renderView.addGestureRecognizer(longPress)


$embedBlueskyPosts
.dropFirst()
.filter { $0 }
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.renderView.embedBlueskyPosts() }
.store(in: &cancellables)

$embedTweets
.dropFirst()
.filter { $0 }
Expand Down Expand Up @@ -329,7 +346,10 @@ extension MessageViewController: RenderViewDelegate {

loadingView?.removeFromSuperview()
loadingView = nil


if embedBlueskyPosts {
renderView.embedBlueskyPosts()
}
if embedTweets {
renderView.embedTweets()
}
Expand All @@ -345,6 +365,9 @@ extension MessageViewController: RenderViewDelegate {
case let didTapHeader as RenderView.BuiltInMessage.DidTapAuthorHeader:
showUserActions(from: didTapHeader.frame)

case let message as RenderView.BuiltInMessage.FetchOEmbedFragment:
fetchOEmbed(url: message.url, id: message.id)

default:
let description = "\(message)"
logger.warning("ignoring unexpected message \(description)")
Expand Down Expand Up @@ -398,6 +421,7 @@ private struct RenderModel: StencilContextConvertible {
var htmlContents: String? {
guard let originalHTML = message.innerHTML else { return nil }
let document = HTMLDocument(string: originalHTML)
document.addAttributeToBlueskyLinks()
document.addAttributeToTweetLinks()
if let username = FoilDefaultStorageOptional(Settings.username).wrappedValue {
document.identifyQuotesCitingUser(named: username, shouldHighlight: true)
Expand Down
1 change: 1 addition & 0 deletions App/View Controllers/Posts/PostRenderModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ private func massageHTML(_ html: String, isIgnored: Bool, forumID: String) -> St
let document = HTMLDocument(string: html)
document.removeSpoilerStylingAndEvents()
document.removeEmptyEditedByParagraphs()
document.addAttributeToBlueskyLinks()
document.addAttributeToTweetLinks()
document.useHTML5VimeoPlayer()
if let username = UserDefaults.standard.value(for: Settings.username) {
Expand Down
23 changes: 23 additions & 0 deletions App/View Controllers/Posts/PostsPageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class PostsPageViewController: ViewController {
private var cancellables: Set<AnyCancellable> = []
@FoilDefaultStorage(Settings.canSendPrivateMessages) private var canSendPrivateMessages
@FoilDefaultStorage(Settings.darkMode) private var darkMode
@FoilDefaultStorage(Settings.embedBlueskyPosts) private var embedBlueskyPosts
@FoilDefaultStorage(Settings.embedTweets) private var embedTweets
@FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics
private var flagRequest: Task<Void, Error>?
Expand All @@ -42,6 +43,7 @@ final class PostsPageViewController: ViewController {
private var messageViewController: MessageComposeViewController?
private var networkOperation: Task<(posts: [Post], firstUnreadPost: Int?, advertisementHTML: String), Error>?
private var observers: [NSKeyValueObservation] = []
private lazy var oEmbedFetcher: OEmbedFetcher = .init()
private(set) var page: ThreadPage?
@FoilDefaultStorage(Settings.pullForNext) private var pullForNext
private var replyWorkspace: ReplyWorkspace?
Expand Down Expand Up @@ -100,6 +102,7 @@ final class PostsPageViewController: ViewController {
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidFinishLoadingTweets.self)
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapPostActionButton.self)
postsView.renderView.registerMessage(RenderView.BuiltInMessage.DidTapAuthorHeader.self)
postsView.renderView.registerMessage(RenderView.BuiltInMessage.FetchOEmbedFragment.self)
postsView.topBar.goToParentForum = { [unowned self] in
guard let forum = self.thread.forum else { return }
AppDelegate.instance.open(route: .forum(id: forum.forumID))
Expand Down Expand Up @@ -1352,6 +1355,13 @@ final class PostsPageViewController: ViewController {

hiddenMenuButton.show(menu: postActionMenu, from: frame)
}

private func fetchOEmbed(url: URL, id: String) {
Task {
let callbackData = await oEmbedFetcher.fetch(url: url, id: id)
postsView.renderView.didFetchOEmbed(id: id, response: callbackData)
}
}

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

$embedBlueskyPosts
.dropFirst()
.filter { $0 }
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.postsView.renderView.embedBlueskyPosts() }
.store(in: &cancellables)

$embedTweets
.dropFirst()
.receive(on: RunLoop.main)
Expand Down Expand Up @@ -1717,6 +1734,9 @@ extension PostsPageViewController: ComposeTextViewControllerDelegate {

extension PostsPageViewController: RenderViewDelegate {
func didFinishRenderingHTML(in view: RenderView) {
if embedBlueskyPosts {
view.embedBlueskyPosts()
}
if embedTweets {
view.embedTweets()
}
Expand Down Expand Up @@ -1766,6 +1786,9 @@ extension PostsPageViewController: RenderViewDelegate {
offset.y = fraction
postsView.renderView.scrollToFractionalOffset(offset)
}

case let message as RenderView.BuiltInMessage.FetchOEmbedFragment:
fetchOEmbed(url: message.url, id: message.id)

case is FYADFlagRequest:
fetchNewFlag()
Expand Down
51 changes: 50 additions & 1 deletion App/Views/RenderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ extension RenderView: WKScriptMessageHandler {
self.postIndex = postIndex
}
}

struct FetchOEmbedFragment: RenderViewMessage {
static let messageName = "fetchOEmbedFragment"

/// An opaque `id` to use when calling back with the response.
let id: String

/// The OEmbed URL to fetch.
let url: URL

init?(rawMessage: WKScriptMessage, in renderView: RenderView) {
assert(rawMessage.name == Self.messageName)
guard let body = rawMessage.body as? [String: Any],
let id = body["id"] as? String,
let rawURL = body["url"] as? String,
let url = URL(string: rawURL)
else { return nil }

self.id = id
self.url = url
}
}
}
}

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

extension RenderView {


/// Turns any links that look like Bluesky posts into an actual Bluesky post embed.
func embedBlueskyPosts() {
Task {
do {
try await webView.eval("""
if (window.Awful) {
Awful.embedBlueskyPosts();
}
""")
} catch {
self.mentionError(error, explanation: "could not evaluate embedBlueskyPosts")
}
}
}

/// Turns any links that look like tweets into an actual tweet embed.
func embedTweets() {
let renderGhostTweets = FoilDefaultStorage(Settings.frogAndGhostEnabled).wrappedValue
Expand Down Expand Up @@ -455,6 +492,18 @@ extension RenderView {
rect.origin = convertToRenderView(webDocumentPoint: rect.origin)
return rect
}

func didFetchOEmbed(id: String, response: String) {
Task {
do {
try await webView.eval("""
window.Awful?.didFetchOEmbed(\(escapeForEval(id)), \(response));
""")
} catch {
logger.error("error calling back after fetching oembed: \(error)")
}
}
}

/**
How far the web document is offset from the scroll view's bounds.
Expand Down
6 changes: 5 additions & 1 deletion Awful.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
1C917CF81C4F21B800BBF672 /* HairlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC22AB419F972C200D5BABD /* HairlineView.swift */; };
1C9AEBC6210C3B2300C9A567 /* CloseBBcodeTagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9AEBC5210C3B2300C9A567 /* CloseBBcodeTagTests.swift */; };
1C9AEBCE210C3BAF00C9A567 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C9AEBCD210C3BAF00C9A567 /* main.swift */; };
1CA3D6FC2D98A7E400D70964 /* OEmbedFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */; };
1CA45D941F2C0AD1005BEEC5 /* RenderView.js in Resources */ = {isa = PBXBuildFile; fileRef = 1CA45D931F2C0AD1005BEEC5 /* RenderView.js */; };
1CA56FEB1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1CA56FEA1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist */; };
1CA887B01F40AE1A0059FEEC /* User+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA887AF1F40AE1A0059FEEC /* User+Presentation.swift */; };
Expand Down Expand Up @@ -442,7 +443,8 @@
1C9AEBC5210C3B2300C9A567 /* CloseBBcodeTagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseBBcodeTagTests.swift; sourceTree = "<group>"; };
1C9AEBC7210C3B2300C9A567 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1C9AEBCD210C3BAF00C9A567 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
1CA45D931F2C0AD1005BEEC5 /* RenderView.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = RenderView.js; sourceTree = "<group>"; };
1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OEmbedFetcher.swift; sourceTree = "<group>"; };
1CA45D931F2C0AD1005BEEC5 /* RenderView.js */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.javascript; path = RenderView.js; sourceTree = "<group>"; tabWidth = 2; };
1CA56FEA1A009BDF009A91AE /* PotentiallyObjectionableTexts.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = PotentiallyObjectionableTexts.plist; sourceTree = "<group>"; };
1CA887AF1F40AE1A0059FEEC /* User+Presentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "User+Presentation.swift"; sourceTree = "<group>"; };
1CAD42B52CD3050400579B8E /* lottie-player.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = "lottie-player.js"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1002,6 +1004,7 @@
1CC780241612D9DD002AF958 /* Posts */ = {
isa = PBXGroup;
children = (
1CA3D6FB2D98A7E100D70964 /* OEmbedFetcher.swift */,
1C16FBD41CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift */,
1C8A8CF91A3C14DF00E4F6A4 /* ReplyWorkspace.swift */,
);
Expand Down Expand Up @@ -1550,6 +1553,7 @@
1C220E3D2B815AFC00DA92B0 /* Bundle+.swift in Sources */,
1CD0C54F1BE674D700C3AC80 /* PostsPageRefreshSpinnerView.swift in Sources */,
1CEB5BFF19AB9C1700C82C30 /* InAppActionViewController.swift in Sources */,
1CA3D6FC2D98A7E400D70964 /* OEmbedFetcher.swift in Sources */,
1C16FBAA1CB5D38700C88BD1 /* CompositionInputAccessoryView.swift in Sources */,
1C9AEBCE210C3BAF00C9A567 /* main.swift in Sources */,
1C16FBE71CBC671A00C88BD1 /* PostRenderModel.swift in Sources */,
Expand Down
Loading
Loading