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 { extension UIViewController {
@available(iOS 16.0, *) @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 var cur: UIViewController? = self
while let vc = cur { while let vc = cur {
if let container = vc as? DuckableContainerViewController { if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: true, completion: nil) container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
return true return true
} else { } else {
cur = vc.parent cur = vc.parent

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import UIKit
class ImageDataCache { class ImageDataCache {
private let memory: MemoryCache<Entry> private let memory: MemoryCache<Entry>
private let disk: DiskCache<Data>? let disk: DiskCache<Data>?
private let storeOriginalDataInMemory: Bool private let storeOriginalDataInMemory: Bool
private let desiredPixelSize: CGSize? 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 title = status.favourited ? "Unfavorite" : "Favorite"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in 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() await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
completion(true) completion(true)
} }
@ -113,7 +113,7 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
} }
let title = status.reblogged ? "Unreblog" : "Reblog" let title = status.reblogged ? "Unreblog" : "Reblog"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in 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() await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
completion(true) completion(true)
} }

View File

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

View File

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

View File

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

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame globalFrameOutsideList = frame
} }
}) })
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) { .sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController) DraftsView(currentDraft: draft, mastodonController: mastodonController)
} }
@ -203,23 +202,19 @@ struct ComposeView: View {
private var header: some View { private var header: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
ComposeCurrentAccount() ComposeCurrentAccount()
.accessibilitySortPriority(1)
Spacer() Spacer()
Text(verbatim: charactersRemaining.description) Text(verbatim: charactersRemaining.description)
.foregroundColor(charactersRemaining < 0 ? .red : .secondary) .foregroundColor(charactersRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit()) .font(Font.body.monospacedDigit())
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining")) .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) }.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 { private var cancelButton: some View {
Button(action: self.cancel) { Button(action: self.cancel) {
Text("Cancel") Text("Cancel")

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController { extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { 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) { 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) (child as? TuskerRootViewController)?.restoreActivity(activity)
} }

View File

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

View File

@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController! private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = [] private var fastSwitcherConstraints: [NSLayoutConstraint] = []
@available(iOS, obsoleted: 16.0)
private var draftToPresentOnAppear: Draft?
var selectedTab: Tab { var selectedTab: Tab {
return Tab(rawValue: selectedIndex)! return Tab(rawValue: selectedIndex)!
} }
@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
if let draftToPresentOnAppear {
self.draftToPresentOnAppear = nil
compose(editing: draftToPresentOnAppear, animated: true)
}
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
@ -235,23 +243,39 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, var activity: NSUserActivity?
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else { if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC") activity = timelinePages.stateRestorationActivity()
return nil } 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 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 { if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController else {
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC") stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
return return
} }
timelineVC.restoreActivity(activity) timelinePages.restoreActivity(activity)
restoreEditedDraft()
} else if activity.activityType == UserActivityType.newPost.rawValue {
restoreEditedDraft()
return
} else { } else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)") 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 { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0
var body: some View { var body: some View {
List { List {
@ -64,13 +66,42 @@ struct AdvancedPrefsView : View {
} }
var cachingSection: some View { var cachingSection: some View {
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) { Section {
Button(action: clearCache) { Button(action: clearCache) {
Text("Clear Mastodon Cache") Text("Clear Mastodon Cache")
}.foregroundColor(.red) }.foregroundColor(.red)
Button(action: clearImageCaches) { Button(action: clearImageCaches) {
Text("Clear Image Caches") Text("Clear Image Caches")
}.foregroundColor(.red) }.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 { private func doRestore() -> Bool {
guard let activity = activityToRestore, guard let activity = activityToRestore else {
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else { return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs") stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false return false
} }

View File

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

View File

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

View File

@ -91,10 +91,37 @@ class UserActivityManager {
return activity 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? { static func getDraft(from activity: NSUserActivity) -> Draft? {
guard activity.activityType == UserActivityType.newPost.rawValue, let idStr: String?
let str = activity.userInfo?["draftID"] as? String, if activity.activityType == UserActivityType.newPost.rawValue {
let uuid = UUID(uuidString: str) else { 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 nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsManager.shared.getBy(id: uuid)

View File

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

View File

@ -14,6 +14,7 @@ import WebURL
import WebURLFoundationExtras import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private let dataDetectorsScheme = "x-apple-data-detectors"
class ContentTextView: LinkTextView, BaseEmojiLabel { class ContentTextView: LinkTextView, BaseEmojiLabel {
@ -198,7 +199,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
let location = recognizer.location(in: self) 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) let text = (self.text as NSString).substring(with: range)
handleLinkTapped(url: link, text: text) handleLinkTapped(url: link, text: text)
} }
@ -287,9 +289,15 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
extension ContentTextView: UITextViewDelegate { extension ContentTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { 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 // 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 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 // MARK: Configure UI
func updateUI(statusID: String, state: StatusState) { func updateUI(statusID: String, state: StatusState) {

View File

@ -308,6 +308,27 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
return true 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 { extension TimelineStatusTableViewCell: SelectableTableViewCell {