Compare commits

..

7 Commits

24 changed files with 299 additions and 59 deletions

View File

@ -29,11 +29,11 @@ public protocol DuckableViewControllerDelegate: AnyObject {
extension UIViewController {
@available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController) -> Bool {
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
var cur: UIViewController? = self
while let vc = cur {
if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: true, completion: nil)
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
return true
} else {
cur = vc.parent

View File

@ -17,6 +17,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
private var bottomConstraint: NSLayoutConstraint!
private(set) var state = State.idle
public var duckedViewController: DuckableViewController? {
if case .ducked(let vc, placeholder: _) = state {
return vc
} else {
return nil
}
}
public init(child: UIViewController) {
self.child = child
@ -50,7 +58,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
])
}
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
@ -69,9 +77,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
return
}
if isDucked {
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
configureChildForDuckedPlaceholder()
} else {
state = .presentingDucked(viewController, isFirstPresentation: true)
doPresentDuckable(viewController, animated: animated, completion: completion)
}
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self
@ -79,9 +92,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self
present(nav, animated: animated) {
self.bottomConstraint.isActive = false
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
self.bottomConstraint.isActive = true
self.configureChildForDuckedPlaceholder()
completion?()
}
}
@ -127,10 +138,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
}
private func configureChildForDuckedPlaceholder() {
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true
dismiss(animated: true)
}
@objc func unduckViewController() {
@ -191,7 +210,10 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
@available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
let snapshot = child.view.snapshotView(afterScreenUpdates: false)!
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
return
}
snapshot.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(snapshot)
NSLayoutConstraint.activate([

View File

@ -48,6 +48,7 @@
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; };
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; };
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
@ -412,6 +413,7 @@
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; };
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; };
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -1166,6 +1168,7 @@
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1935,6 +1938,7 @@
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,

View File

@ -122,6 +122,10 @@ class DiskCache<T> {
}
}
func getSizeInBytes() -> Int64? {
return fileManager.recursiveSize(url: URL(fileURLWithPath: path, isDirectory: true))
}
}
extension DiskCache {

View File

@ -110,6 +110,10 @@ class ImageCache {
try cache.removeAll()
}
func getDiskSizeInBytes() -> Int64? {
return cache.disk?.getSizeInBytes()
}
typealias Request = URLSessionDataTask
}

View File

@ -11,7 +11,7 @@ import UIKit
class ImageDataCache {
private let memory: MemoryCache<Entry>
private let disk: DiskCache<Data>?
let disk: DiskCache<Data>?
private let storeOriginalDataInMemory: Bool
private let desiredPixelSize: CGSize?

View File

@ -0,0 +1,35 @@
//
// FileManager+Size.swift
// Tusker
//
// Created by Shadowfacts on 11/28/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
extension FileManager {
func recursiveSize(url: URL) -> Int64? {
if (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
return size(url: url)
} else {
guard let enumerator = enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]) else {
return nil
}
var total: Int64 = 0
for case let url as URL in enumerator {
total += size(url: url) ?? 0
}
return total
}
}
}
private func size(url: URL) -> Int64? {
guard let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]),
resourceValues.isRegularFile ?? false,
let size = resourceValues.fileSize ?? resourceValues.totalFileAllocatedSize else {
return nil
}
return Int64(size)
}

View File

@ -97,7 +97,7 @@ private func createFavoriteAction(status: StatusMO, container: StatusSwipeAction
}
let title = status.favourited ? "Unfavorite" : "Favorite"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
Task {
Task { @MainActor in
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
completion(true)
}
@ -113,7 +113,7 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
}
let title = status.reblogged ? "Unreblog" : "Reblog"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
Task {
Task { @MainActor in
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
completion(true)
}

View File

@ -22,7 +22,7 @@ struct ComposeCurrentAccount: View {
ComposeAvatarImageView(url: account?.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
.accessibilityHidden(true)
if let id = account?.id,
let account = mastodonController.persistentContainer.account(for: id) {

View File

@ -43,11 +43,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
super.init(rootView: wrapper)
self.uiState.delegate = self
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
updateNavigationTitle(draft: uiState.draft)
self.uiState.$draft
.flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
@ -55,12 +55,27 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
DraftsManager.save()
}
.store(in: &cancellables)
self.uiState.$draft
.sink { [unowned self] draft in
self.updateNavigationTitle(draft: draft)
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateNavigationTitle(draft: Draft) {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
navigationItem.title = "Reply to @\(status.account.acct)"
} else {
navigationItem.title = "New Post"
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
@ -92,6 +107,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
}
}
override func accessibilityPerformEscape() -> Bool {
dismissCompose(mode: .cancel)
return true
}
// MARK: Duckable
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {

View File

@ -80,6 +80,7 @@ struct ComposeReplyView: View {
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
}
}

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame
}
})
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController)
}
@ -203,23 +202,19 @@ struct ComposeView: View {
private var header: some View {
HStack(alignment: .top) {
ComposeCurrentAccount()
.accessibilitySortPriority(1)
Spacer()
Text(verbatim: charactersRemaining.description)
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit())
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
// this should come first, so VO users can back to it from the main compose text view
.accessibilitySortPriority(0)
}.frame(height: 50)
}
private var navTitle: Text {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
return Text("Reply to @\(status.account.acct)")
} else {
return Text("New Post")
}
}
private var cancelButton: some View {
Button(action: self.cancel) {
Text("Cancel")

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
(child as? TuskerRootViewController)?.stateRestorationActivity()
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
if let compose = duckedViewController as? ComposeHostingController,
compose.draft.hasContent {
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
}
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
if let draft = UserActivityManager.getDraft(from: activity),
let account = UserActivityManager.getAccount(from: activity) {
let mastodonController = MastodonController.getForAccount(account)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
_ = presentDuckable(compose, animated: false, isDucked: true)
}
(child as? TuskerRootViewController)?.restoreActivity(activity)
}

View File

@ -391,8 +391,7 @@ extension MainSplitViewController: TuskerRootViewController {
return tabBarViewController.stateRestorationActivity()
} else {
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController
return timeline.stateRestorationActivity()
return timelinePages.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
return nil

View File

@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = []
@available(iOS, obsoleted: 16.0)
private var draftToPresentOnAppear: Draft?
var selectedTab: Tab {
return Tab(rawValue: selectedIndex)!
}
@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
if let draftToPresentOnAppear {
self.draftToPresentOnAppear = nil
compose(editing: draftToPresentOnAppear, animated: true)
}
}
override func viewDidLayoutSubviews() {
@ -235,23 +243,39 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC")
return nil
var activity: NSUserActivity?
if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController {
activity = timelinePages.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find timeline/page VC")
}
return timelineVC.stateRestorationActivity()
if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
}
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
func restoreEditedDraft() {
// on iOS 16+, this is handled by the duckable container
if #unavailable(iOS 16.0),
let draft = UserActivityManager.getDraft(from: activity) {
draftToPresentOnAppear = draft
}
}
if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
return
}
timelineVC.restoreActivity(activity)
timelinePages.restoreActivity(activity)
restoreEditedDraft()
} else if activity.activityType == UserActivityType.newPost.rawValue {
restoreEditedDraft()
return
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
}

View File

@ -11,6 +11,8 @@ import CoreData
struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0
var body: some View {
List {
@ -64,13 +66,42 @@ struct AdvancedPrefsView : View {
}
var cachingSection: some View {
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) {
Section {
Button(action: clearCache) {
Text("Clear Mastodon Cache")
}.foregroundColor(.red)
Button(action: clearImageCaches) {
Text("Clear Image Caches")
}.foregroundColor(.red)
} header: {
Text("Caching")
} footer: {
var s: AttributedString = "Clearing caches will restart the app."
if imageCacheSize != 0 {
s += AttributedString("\nImage cache size: \(ByteCountFormatter().string(fromByteCount: imageCacheSize))")
}
if mastodonCacheSize != 0 {
s += AttributedString("\nMastodon cache size: \(ByteCountFormatter().string(fromByteCount: mastodonCacheSize))")
}
return Text(s)
}.task {
imageCacheSize = [
ImageCache.avatars,
.headers,
.attachments,
.emojis,
].map {
$0.getDiskSizeInBytes() ?? 0
}.reduce(0, +)
mastodonCacheSize = LocalData.shared.accounts.map {
let descriptions = MastodonController.getForAccount($0).persistentContainer.persistentStoreDescriptions
return descriptions.map {
guard let url = $0.url else {
return 0
}
return FileManager.default.recursiveSize(url: url) ?? 0
}.reduce(0, +)
}.reduce(0, +)
}
}

View File

@ -252,8 +252,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
private func doRestore() -> Bool {
guard let activity = activityToRestore,
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
guard let activity = activityToRestore else {
return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}

View File

@ -46,21 +46,27 @@ class TimelinesPageViewController: SegmentedPageViewController {
fatalError("init(coder:) has not been implemented")
}
func stateRestorationActivity() -> NSUserActivity? {
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return
}
let index: Int
switch timeline {
case .home:
selectPage(at: 0, animated: false)
index = 0
case .public(local: false):
selectPage(at: 1, animated: false)
index = 1
case .public(local: true):
selectPage(at: 2, animated: false)
index = 2
default:
return
}
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
selectPage(at: index, animated: false)
let timelineVC = pageControllers[index] as! TimelineViewController
timelineVC.restoreActivity(activity)
}

View File

@ -13,6 +13,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
let titles: [String]
let pageControllers: [UIViewController]
private var initialIndex = 0
private(set) var currentIndex = 0
var segmentedControl: UISegmentedControl!
@ -43,7 +44,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
view.backgroundColor = .systemBackground
selectPage(at: 0, animated: false)
selectPage(at: initialIndex, animated: false)
addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand)
@ -57,6 +58,10 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
}
func selectPage(at index: Int, animated: Bool) {
guard isViewLoaded else {
initialIndex = index
return
}
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
navigationItem.title = pageControllers[index].title

View File

@ -91,10 +91,37 @@ class UserActivityManager {
return activity
}
static func addDuckedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"duckedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"editedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func getDraft(from activity: NSUserActivity) -> Draft? {
guard activity.activityType == UserActivityType.newPost.rawValue,
let str = activity.userInfo?["draftID"] as? String,
let uuid = UUID(uuidString: str) else {
let idStr: String?
if activity.activityType == UserActivityType.newPost.rawValue {
idStr = activity.userInfo?["draftID"] as? String
} else {
idStr = activity.userInfo?["duckedDraftID"] as? String ?? activity.userInfo?["editedDraftID"] as? String
}
guard let idStr,
let uuid = UUID(uuidString: idStr) else {
return nil
}
return DraftsManager.shared.getBy(id: uuid)

View File

@ -88,7 +88,7 @@ extension TuskerNavigationDelegate {
show(conversation(mainStatusID: statusID, state: state), sender: self)
}
func compose(editing draft: Draft) {
func compose(editing draft: Draft, animated: Bool = true) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
@ -97,20 +97,20 @@ extension TuskerNavigationDelegate {
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *),
presentDuckable(compose) {
presentDuckable(compose, animated: animated) {
return
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
present(nav, animated: animated)
}
}
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil, animated: Bool = true) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
compose(editing: draft)
compose(editing: draft, animated: animated)
}
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {

View File

@ -14,6 +14,7 @@ import WebURL
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private let dataDetectorsScheme = "x-apple-data-detectors"
class ContentTextView: LinkTextView, BaseEmojiLabel {
@ -198,7 +199,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location) {
if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme {
let text = (self.text as NSString).substring(with: range)
handleLinkTapped(url: link, text: text)
}
@ -287,9 +289,15 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
extension ContentTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
// generally disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
// the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
return URL.scheme == "x-apple-data-detectors"
if URL.scheme == dataDetectorsScheme {
return true
} else {
// otherwise, regular taps are handled by the gesture recognizer, but the accessibility interaction to select links with the rotor goes through here
// and this seems to be the only way of overriding what it does
handleLinkTapped(url: URL, text: (text as NSString).substring(with: characterRange))
return false
}
}
}

View File

@ -395,6 +395,27 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
return true
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
guard let text = contentTextView.attributedText else {
return nil
}
var actions: [UIAccessibilityCustomAction] = []
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, range, stop in
guard let value = value as? URL else {
return
}
let text = text.attributedSubstring(from: range).string
actions.append(UIAccessibilityCustomAction(name: text) { [unowned self] _ in
self.contentTextView.handleLinkTapped(url: value, text: text)
return true
})
}
return actions
}
set {}
}
// MARK: Configure UI
func updateUI(statusID: String, state: StatusState) {

View File

@ -308,6 +308,27 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
return true
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
guard let text = contentTextView.attributedText else {
return nil
}
var actions: [UIAccessibilityCustomAction] = []
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, range, stop in
guard let value = value as? URL else {
return
}
let text = text.attributedSubstring(from: range).string
actions.append(UIAccessibilityCustomAction(name: text) { [unowned self] _ in
self.contentTextView.handleLinkTapped(url: value, text: text)
return true
})
}
return actions
}
set {}
}
}
extension TimelineStatusTableViewCell: SelectableTableViewCell {