parent
8d6c63d5e9
commit
8021868599
@ -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 */,
|
||||
|
63
Tusker/API/MastodonController+Resolve.swift
Normal file
63
Tusker/API/MastodonController+Resolve.swift
Normal 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"
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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? {
|
||||
|
Loading…
x
Reference in New Issue
Block a user