Use status context menus for post-like links

Closes #557
This commit is contained in:
Shadowfacts 2025-02-23 00:48:16 -05:00
parent 8d6c63d5e9
commit 8021868599
8 changed files with 138 additions and 80 deletions

View File

@ -299,6 +299,7 @@
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -728,6 +729,7 @@
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
D6C4532E2BCB873400E26A0E /* MockStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStatusView.swift; sourceTree = "<group>"; };
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonController+Resolve.swift"; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -1741,6 +1743,7 @@
children = (
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D630C3C92BC59FF500208903 /* MastodonController+Push.swift */,
D6C5F0632D6AEC050019F85B /* MastodonController+Resolve.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
@ -2161,6 +2164,7 @@
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
D6C5F0642D6AEC0A0019F85B /* MastodonController+Resolve.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,

View File

@ -0,0 +1,63 @@
//
// MastodonController+Resolve.swift
// Tusker
//
// Created by Shadowfacts on 2/23/25.
// Copyright © 2025 Shadowfacts. All rights reserved.
//
import Foundation
import WebURL
import Pachyderm
extension MastodonController {
@MainActor
func resolveRemoteStatus(url: URL) async throws -> StatusMO {
let effectiveURL: String
if isLikelyMastodonRemoteStatus(url: url) {
var request = URLRequest(url: url)
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
request.addValue("application/activity+json", forHTTPHeaderField: "accept")
if let (_, response) = try? await URLSession.appDefault.data(for: request, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
let (results, _) = try await run(request)
let statuses = results.statuses.compactMap(\.value)
// Don't try to exactly match effective URL because the URL form Mastodon
// uses for the ActivityPub redirect doesn't match what's returned by the API.
// Instead we just assume that, if only one status was returned, it worked.
guard statuses.count == 1 else {
throw UnableToResolveError()
}
let status = statuses[0]
return persistentContainer.addOrUpdateOnViewContext(status: status)
}
}
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
let path = url.path
let range = NSRange(location: 0, length: path.utf16.count)
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
}
private final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
private struct UnableToResolveError: LocalizedError {
var errorDescription: String? {
"Unable to resolve status from URL"
}
}

View File

@ -11,13 +11,6 @@ import Pachyderm
import WebURL
import WebURLFoundationExtras
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
let path = url.path
let range = NSRange(location: 0, length: path.utf16.count)
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
}
class ConversationViewController: UIViewController {
weak var mastodonController: MastodonController!
@ -215,38 +208,8 @@ class ConversationViewController: UIViewController {
indicator.startAnimating()
state = .loading(indicator)
let effectiveURL: String
final class RedirectBlocker: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
if isLikelyMastodonRemoteStatus(url: url) {
var request = URLRequest(url: url)
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
request.addValue("application/activity+json", forHTTPHeaderField: "accept")
if let (_, response) = try? await URLSession.appDefault.data(for: request, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do {
let (results, _) = try await mastodonController.run(request)
let statuses = results.statuses.compactMap(\.value)
// Don't try to exactly match effective URL because the URL form Mastodon
// uses for the ActivityPub redirect doesn't match what's returned by the API.
// Instead we just assume that, if only one status was returned, it worked.
guard statuses.count == 1 else {
throw UnableToResolveError()
}
let status = statuses[0]
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
let status = try await mastodonController.resolveRemoteStatus(url: url)
mode = .localID(status.id)
return status.id
} catch {
@ -311,7 +274,6 @@ class ConversationViewController: UIViewController {
guard case .displaying(_) = state else {
return nil
}
let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
@ -368,9 +330,7 @@ class ConversationViewController: UIViewController {
subtitle.adjustsFontForContentSizeCategory = true
subtitle.numberOfLines = 0
subtitle.textAlignment = .center
if let error = error as? UnableToResolveError {
subtitle.text = error.localizedDescription
} else if let error = error as? Client.Error {
if let error = error as? Client.Error {
subtitle.text = error.localizedDescription
} else {
subtitle.text = error.localizedDescription
@ -433,11 +393,6 @@ extension ConversationViewController {
}
extension ConversationViewController {
struct UnableToResolveError: Error {
var localizedDescription: String {
"Unable to resolve status from URL"
}
}
}
extension ConversationViewController {

View File

@ -123,6 +123,23 @@ extension MenuActionProvider {
]
}
func actionsForResolvingStatusURL(_ url: URL, source: PopoverSource) -> [UIMenuElement] {
guard let mastodonController else {
return actionsForURL(url, source: source)
}
return [
UIDeferredMenuElement({ completionHandler in
Task {
if let status = try? await mastodonController.resolveRemoteStatus(url: url) {
completionHandler(self.actionsForStatus(status, source: source))
} else {
completionHandler(self.actionsForURL(url, source: source))
}
}
})
]
}
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController,
@ -409,6 +426,29 @@ extension MenuActionProvider {
})
]
}
func contextMenuConfigurationForURL(_ url: URL, source: PopoverSource) -> UIContextMenuConfiguration {
if let mastodonController,
isLikelyResolvableAsStatus(url) {
return UIContextMenuConfiguration {
ConversationViewController(resolving: url, mastodonController: mastodonController)
} actionProvider: { _ in
let actions = self.actionsForResolvingStatusURL(url, source: source)
return UIMenu(children: actions)
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
let actions = self.actionsForURL(url, source: source)
return UIMenu(children: actions)
}
}
}
private func createAction(identifier: String, title: String, systemImageName: String?, handler: @escaping (UIAction) -> Void) -> UIAction {
let image: UIImage?

View File

@ -216,7 +216,7 @@ private let statusPathRegex = try! NSRegularExpression(
options: .caseInsensitive
)
private func isLikelyResolvableAsStatus(_ url: URL) -> Bool {
func isLikelyResolvableAsStatus(_ url: URL) -> Bool {
let path = url.path
let range = NSRange(location: 0, length: path.utf16.count)
return statusPathRegex.numberOfMatches(in: path, range: range) == 1

View File

@ -273,20 +273,33 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
// Store the previewed link range for use in the previewForHighlighting method
currentPreviewedLinkRange = range
let preview: UIContextMenuContentPreviewProvider = {
self.getViewController(forLink: link, inRange: range)
}
let actions: UIContextMenuActionProvider = { (_) in
let text = (self.text as NSString).substring(with: range)
let actions: [UIMenuElement]
if let mention = self.getMention(for: link, text: text) {
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
} else if let tag = self.getHashtag(for: link, text: text) {
actions = self.actionsForHashtag(tag, source: .view(self))
} else {
actions = self.actionsForURL(link, source: .view(self))
let preview: UIContextMenuContentPreviewProvider
let actions: UIContextMenuActionProvider
if let mastodonController,
isLikelyResolvableAsStatus(link) {
preview = {
ConversationViewController(resolving: link, mastodonController: mastodonController)
}
actions = { _ in
let actions = self.actionsForResolvingStatusURL(link, source: .view(self))
return UIMenu(children: actions)
}
} else {
preview = {
self.getViewController(forLink: link, inRange: range)
}
actions = { (_) in
let text = (self.text as NSString).substring(with: range)
let actions: [UIMenuElement]
if let mention = self.getMention(for: link, text: text) {
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
} else if let tag = self.getHashtag(for: link, text: text) {
actions = self.actionsForHashtag(tag, source: .view(self))
} else {
actions = self.actionsForURL(link, source: .view(self))
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions)

View File

@ -183,15 +183,7 @@ extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionPro
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
return self.contextMenuConfigurationForURL(url, source: .view(self))
}
}

View File

@ -277,16 +277,7 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let card = card else { return nil }
return UIContextMenuConfiguration(identifier: nil) {
let vc = SFSafariViewController(url: URL(card.url)!)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { (_) in
let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? []
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
return self.actionProvider?.contextMenuConfigurationForURL(URL(card.url)!, source: .view(self))
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {