Compare commits
7 Commits
bcc70e9f8c
...
765b5e1a7c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 765b5e1a7c | |
Shadowfacts | a3e64703ab | |
Shadowfacts | d74be9d81d | |
Shadowfacts | 6ca5bb0c74 | |
Shadowfacts | 76550d8fb8 | |
Shadowfacts | daf3741c9a | |
Shadowfacts | b2977540e0 |
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct List: Decodable, Equatable, Hashable, Sendable {
|
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
|
||||||
|
@ -16,6 +16,11 @@ public struct List: Decodable, Equatable, Hashable, Sendable {
|
||||||
return .list(id: id)
|
return .list(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(id: String, title: String) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||||
return lhs.id == rhs.id && lhs.title == rhs.title
|
return lhs.id == rhs.id && lhs.title == rhs.title
|
||||||
}
|
}
|
||||||
|
@ -25,28 +30,28 @@ public struct List: Decodable, Equatable, Hashable, Sendable {
|
||||||
hasher.combine(title)
|
hasher.combine(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ list: List, title: String) -> Request<List> {
|
public static func update(_ listID: String, title: String) -> Request<List> {
|
||||||
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
|
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ list: List) -> Request<Empty> {
|
public static func delete(_ listID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func add(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
return Request<Empty>(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
|
public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
|
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
|
||||||
"account_ids" => accountIDs
|
"account_ids" => accountIDs
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
//
|
||||||
|
// ListProtocol.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol ListProtocol {
|
||||||
|
var id: String { get }
|
||||||
|
var title: String { get }
|
||||||
|
}
|
|
@ -223,6 +223,8 @@
|
||||||
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; };
|
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; };
|
||||||
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; };
|
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; };
|
||||||
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
|
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
|
||||||
|
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
|
||||||
|
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
|
||||||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
|
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
|
||||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
|
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
|
||||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
|
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
|
||||||
|
@ -641,6 +643,8 @@
|
||||||
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; };
|
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; };
|
||||||
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; };
|
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; };
|
||||||
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
|
||||||
|
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
|
||||||
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
|
||||||
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
|
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
|
||||||
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
|
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -984,6 +988,7 @@
|
||||||
children = (
|
children = (
|
||||||
D62D2425217ABF63005076CC /* UserActivityType.swift */,
|
D62D2425217ABF63005076CC /* UserActivityType.swift */,
|
||||||
D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
|
D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
|
||||||
|
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */,
|
||||||
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
|
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
|
||||||
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
|
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
|
||||||
);
|
);
|
||||||
|
@ -1208,6 +1213,7 @@
|
||||||
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
|
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
|
||||||
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
|
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
|
||||||
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
|
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
|
||||||
|
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
|
||||||
);
|
);
|
||||||
path = "Profile Header";
|
path = "Profile Header";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2075,6 +2081,7 @@
|
||||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||||
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
|
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
|
||||||
|
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
|
||||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
||||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
|
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
|
||||||
|
@ -2229,6 +2236,7 @@
|
||||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||||
|
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
|
|
|
@ -48,7 +48,7 @@ class DeleteListService {
|
||||||
|
|
||||||
private func deleteList() async {
|
private func deleteList() async {
|
||||||
do {
|
do {
|
||||||
let request = List.delete(list)
|
let request = List.delete(list.id)
|
||||||
_ = try await mastodonController.run(request)
|
_ = try await mastodonController.run(request)
|
||||||
mastodonController.deletedList(list)
|
mastodonController.deletedList(list)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -166,6 +166,8 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
loadAccountPreferences()
|
loadAccountPreferences()
|
||||||
|
|
||||||
|
lists = loadCachedLists()
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
|
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [unowned self] _ in
|
.sink { [unowned self] _ in
|
||||||
|
@ -363,6 +365,23 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadCachedLists() -> [List] {
|
||||||
|
let req = ListMO.fetchRequest()
|
||||||
|
guard let lists = try? persistentContainer.viewContext.fetch(req) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return lists.map {
|
||||||
|
List(id: $0.id, title: $0.title)
|
||||||
|
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedList(id: String) -> List? {
|
||||||
|
let req = ListMO.fetchRequest(id: id)
|
||||||
|
return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
|
||||||
|
List(id: $0.id, title: $0.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func addedList(_ list: List) {
|
func addedList(_ list: List) {
|
||||||
var new = self.lists
|
var new = self.lists
|
||||||
|
|
|
@ -11,13 +11,13 @@ import Pachyderm
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class RenameListService {
|
class RenameListService {
|
||||||
private let list: List
|
private let list: ListProtocol
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let present: (UIViewController) -> Void
|
private let present: (UIViewController) -> Void
|
||||||
|
|
||||||
private var renameAction: UIAlertAction?
|
private var renameAction: UIAlertAction?
|
||||||
|
|
||||||
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
||||||
self.list = list
|
self.list = list
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.present = present
|
self.present = present
|
||||||
|
@ -47,7 +47,7 @@ class RenameListService {
|
||||||
|
|
||||||
private func updateList(with title: String) async {
|
private func updateList(with title: String) async {
|
||||||
do {
|
do {
|
||||||
let req = List.update(list, title: title)
|
let req = List.update(list.id, title: title)
|
||||||
let (list, _) = try await mastodonController.run(req)
|
let (list, _) = try await mastodonController.run(req)
|
||||||
mastodonController.renamedList(list)
|
mastodonController.renamedList(list)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import CoreData
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
@objc(ListMO)
|
@objc(ListMO)
|
||||||
public final class ListMO: NSManagedObject {
|
public final class ListMO: NSManagedObject, ListProtocol {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
|
||||||
return NSFetchRequest(entityName: "List")
|
return NSFetchRequest(entityName: "List")
|
||||||
|
|
|
@ -11,7 +11,7 @@ import UIKit
|
||||||
struct MenuController {
|
struct MenuController {
|
||||||
|
|
||||||
static let composeCommand: UIKeyCommand = {
|
static let composeCommand: UIKeyCommand = {
|
||||||
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
|
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {
|
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {
|
||||||
|
|
|
@ -74,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
||||||
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
|
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
|
||||||
switch UserActivityType(rawValue: activity.activityType) {
|
switch UserActivityType(rawValue: activity.activityType) {
|
||||||
case .showTimeline:
|
case .showTimeline:
|
||||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else { return nil }
|
guard let (timeline, _) = UserActivityManager.getTimeline(from: activity) else { return nil }
|
||||||
return timelineViewController(for: timeline, mastodonController: mastodonController)
|
return timelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
|
|
||||||
case .showConversation:
|
case .showConversation:
|
||||||
|
|
|
@ -64,7 +64,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
|
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
|
||||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
|
let context: any UserActivityHandlingContext
|
||||||
|
if let account = UserActivityManager.getAccount(from: userActivity),
|
||||||
|
account.id != scene.session.mastodonController!.accountInfo!.id {
|
||||||
|
stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||||
|
}
|
||||||
|
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
@ -169,10 +177,16 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
activateAccount(account, animated: false)
|
activateAccount(account, animated: false)
|
||||||
|
|
||||||
if let activity = launchActivity {
|
if let activity = launchActivity {
|
||||||
|
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||||
|
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||||
|
context.finalize(activity: activity)
|
||||||
|
}
|
||||||
if activity.isStateRestorationActivity {
|
if activity.isStateRestorationActivity {
|
||||||
rootViewController?.restoreActivity(activity)
|
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
||||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!))
|
||||||
|
} else {
|
||||||
|
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -227,6 +227,10 @@ class ConversationViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainStatusLoaded(_ mainStatus: StatusMO) {
|
private func mainStatusLoaded(_ mainStatus: StatusMO) {
|
||||||
|
if let accountID = mastodonController.accountInfo?.id {
|
||||||
|
userActivity = UserActivityManager.showConversationActivity(mainStatusID: mainStatus.id, accountID: accountID)
|
||||||
|
}
|
||||||
|
|
||||||
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self)
|
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self)
|
||||||
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
|
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
|
||||||
vc.showStatusesAutomatically = showStatusesAutomatically
|
vc.showStatusesAutomatically = showStatusesAutomatically
|
||||||
|
@ -430,10 +434,6 @@ extension ConversationViewController: StateRestorableViewController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
fatalError("ConversationViewController must be reconstructed, not restored")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationViewController: ToastableViewController {
|
extension ConversationViewController: ToastableViewController {
|
||||||
|
|
|
@ -539,24 +539,6 @@ extension ExploreViewController: StateRestorableViewController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
guard let type = UserActivityType(rawValue: activity.activityType) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if type == .bookmarks {
|
|
||||||
show(BookmarksViewController(mastodonController: mastodonController), sender: nil)
|
|
||||||
} else if type == .search {
|
|
||||||
loadViewIfNeeded()
|
|
||||||
searchController.isActive = true
|
|
||||||
if let query = UserActivityManager.getSearchQuery(from: activity),
|
|
||||||
!query.isEmpty {
|
|
||||||
searchController.searchBar.text = query
|
|
||||||
} else {
|
|
||||||
searchController.searchBar.becomeFirstResponder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ExploreViewController: InstanceTimelineViewControllerDelegate {
|
extension ExploreViewController: InstanceTimelineViewControllerDelegate {
|
||||||
|
|
|
@ -105,7 +105,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
func loadAccounts() async {
|
func loadAccounts() async {
|
||||||
do {
|
do {
|
||||||
let request = List.getAccounts(list)
|
let request = List.getAccounts(list.id)
|
||||||
let (accounts, pagination) = try await mastodonController.run(request)
|
let (accounts, pagination) = try await mastodonController.run(request)
|
||||||
self.nextRange = pagination?.older
|
self.nextRange = pagination?.older
|
||||||
|
|
||||||
|
@ -135,7 +135,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
private func addAccount(id: String) async {
|
private func addAccount(id: String) async {
|
||||||
changedAccounts = true
|
changedAccounts = true
|
||||||
do {
|
do {
|
||||||
let req = List.add(list, accounts: [id])
|
let req = List.add(list.id, accounts: [id])
|
||||||
_ = try await mastodonController.run(req)
|
_ = try await mastodonController.run(req)
|
||||||
self.searchController.isActive = false
|
self.searchController.isActive = false
|
||||||
await self.loadAccounts()
|
await self.loadAccounts()
|
||||||
|
@ -151,7 +151,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
private func removeAccount(id: String) async {
|
private func removeAccount(id: String) async {
|
||||||
changedAccounts = true
|
changedAccounts = true
|
||||||
do {
|
do {
|
||||||
let request = List.remove(list, accounts: [id])
|
let request = List.remove(list.id, accounts: [id])
|
||||||
_ = try await mastodonController.run(request)
|
_ = try await mastodonController.run(request)
|
||||||
await self.loadAccounts()
|
await self.loadAccounts()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -32,7 +32,4 @@ extension BookmarksViewController: StateRestorableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
return UserActivityManager.bookmarksActivity(accountID: mastodonController.accountInfo!.id)
|
return UserActivityManager.bookmarksActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,19 +92,14 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
return root.stateRestorationActivity()
|
return root.stateRestorationActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.restoreActivity(activity)
|
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentCompose() {
|
func select(route: TuskerRoute, animated: Bool) {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.presentCompose()
|
root.select(route: route, animated: animated)
|
||||||
}
|
|
||||||
|
|
||||||
func select(tab: MainTabBarViewController.Tab) {
|
|
||||||
loadViewIfNeeded()
|
|
||||||
root.select(tab: tab)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
@ -112,6 +107,16 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
return root.getTabController(tab: tab)
|
return root.getTabController(tab: tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||||
|
loadViewIfNeeded()
|
||||||
|
return root.getNavigationDelegate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNavigationController() -> NavigationControllerProtocol {
|
||||||
|
loadViewIfNeeded()
|
||||||
|
return root.getNavigationController()
|
||||||
|
}
|
||||||
|
|
||||||
func performSearch(query: String) {
|
func performSearch(query: String) {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.performSearch(query: query)
|
root.performSearch(query: query)
|
||||||
|
|
|
@ -20,22 +20,20 @@ extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) {
|
||||||
if let draft = UserActivityManager.getDraft(from: activity),
|
(child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentCompose() {
|
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||||
(child as? TuskerRootViewController)?.presentCompose()
|
(child as? TuskerRootViewController)?.getNavigationDelegate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(tab: MainTabBarViewController.Tab) {
|
func getNavigationController() -> NavigationControllerProtocol {
|
||||||
(child as? TuskerRootViewController)?.select(tab: tab)
|
(child as! TuskerRootViewController).getNavigationController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(route: TuskerRoute, animated: Bool) {
|
||||||
|
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
|
|
@ -106,7 +106,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||||
|
|
||||||
mastodonController.$lists
|
mastodonController.$lists
|
||||||
.sink { [unowned self] in self.reloadLists($0) }
|
.sink { [unowned self] in self.reloadLists($0, animated: true) }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
mastodonController.$followedHashtags
|
mastodonController.$followedHashtags
|
||||||
.merge(with:
|
.merge(with:
|
||||||
|
@ -179,7 +179,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
], toSection: .compose)
|
], toSection: .compose)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
reloadLists(mastodonController.lists)
|
reloadLists(mastodonController.lists, animated: false)
|
||||||
updateHashtagsSection(followed: mastodonController.followedHashtags)
|
updateHashtagsSection(followed: mastodonController.followedHashtags)
|
||||||
reloadSavedInstances()
|
reloadSavedInstances()
|
||||||
}
|
}
|
||||||
|
@ -192,7 +192,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists(_ lists: [List]) {
|
private func reloadLists(_ lists: [List], animated: Bool) {
|
||||||
if let selectedItem,
|
if let selectedItem,
|
||||||
case .list(let list) = selectedItem,
|
case .list(let list) = selectedItem,
|
||||||
!lists.contains(where: { $0.id == list.id }) {
|
!lists.contains(where: { $0.id == list.id }) {
|
||||||
|
@ -204,7 +204,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
exploreSnapshot.expand([.listsHeader])
|
exploreSnapshot.expand([.listsHeader])
|
||||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||||
self.dataSource.apply(exploreSnapshot, to: .lists)
|
self.dataSource.apply(exploreSnapshot, to: .lists, animatingDifferences: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
@ -128,6 +128,10 @@ class MainSplitViewController: UISplitViewController {
|
||||||
fastAccountSwitcher?.hide()
|
fastAccountSwitcher?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func handleComposeKeyCommand() {
|
||||||
|
compose(editing: nil)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: UISplitViewControllerDelegate {
|
extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
|
@ -353,7 +357,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
|
|
||||||
extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
||||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
|
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
|
||||||
presentCompose()
|
compose(editing: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
||||||
|
@ -411,85 +415,41 @@ extension MainSplitViewController: StateRestorableViewController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
guard traitCollection.horizontalSizeClass != .compact else {
|
|
||||||
tabBarViewController.restoreActivity(activity)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let type = UserActivityType(rawValue: activity.activityType) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let item: MainSidebarViewController.Item
|
|
||||||
var needsRestore = true
|
|
||||||
switch type {
|
|
||||||
case .showTimeline:
|
|
||||||
item = .tab(.timelines)
|
|
||||||
case .checkNotifications:
|
|
||||||
item = .tab(.notifications)
|
|
||||||
case .search:
|
|
||||||
item = .explore
|
|
||||||
case .bookmarks:
|
|
||||||
item = .bookmarks
|
|
||||||
case .myProfile:
|
|
||||||
item = .tab(.myProfile)
|
|
||||||
needsRestore = false
|
|
||||||
case .newPost:
|
|
||||||
return
|
|
||||||
case .showConversation, .showProfile:
|
|
||||||
item = .tab(.timelines)
|
|
||||||
default:
|
|
||||||
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sidebar.select(item: item, animated: false)
|
|
||||||
select(item: item)
|
|
||||||
|
|
||||||
if type == .showConversation {
|
|
||||||
if let statusID = UserActivityManager.getConversationStatus(from: activity) {
|
|
||||||
let conv = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController)
|
|
||||||
secondaryNavController.show(conv, sender: nil)
|
|
||||||
}
|
|
||||||
} else if type == .showProfile {
|
|
||||||
if let accountID = UserActivityManager.getProfile(from: activity) {
|
|
||||||
let profile = ProfileViewController(accountID: accountID, mastodonController: mastodonController)
|
|
||||||
secondaryNavController.show(profile, sender: nil)
|
|
||||||
}
|
|
||||||
} else if needsRestore {
|
|
||||||
if let vc = secondaryNavController.viewControllers.first as? StateRestorableViewController {
|
|
||||||
vc.restoreActivity(activity)
|
|
||||||
} else {
|
|
||||||
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity, couldn't find StateRestorableViewController")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: TuskerRootViewController {
|
extension MainSplitViewController: TuskerRootViewController {
|
||||||
@objc func presentCompose() {
|
func select(route: TuskerRoute, animated: Bool) {
|
||||||
self.compose()
|
guard traitCollection.horizontalSizeClass != .compact else {
|
||||||
|
tabBarViewController?.select(route: route, animated: animated)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
guard presentedViewController == nil else {
|
||||||
func select(tab: MainTabBarViewController.Tab) {
|
dismiss(animated: animated) {
|
||||||
if traitCollection.horizontalSizeClass == .compact {
|
self.select(route: route, animated: animated)
|
||||||
tabBarViewController?.select(tab: tab)
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let item: MainSidebarViewController.Item
|
||||||
|
switch route {
|
||||||
|
case .timelines:
|
||||||
|
item = .tab(.timelines)
|
||||||
|
case .notifications:
|
||||||
|
item = .tab(.notifications)
|
||||||
|
case .myProfile:
|
||||||
|
item = .tab(.myProfile)
|
||||||
|
case .explore:
|
||||||
|
item = .explore
|
||||||
|
case .bookmarks:
|
||||||
|
item = .bookmarks
|
||||||
|
case .list(id: let id):
|
||||||
|
if let list = mastodonController.getCachedList(id: id) {
|
||||||
|
item = .list(list)
|
||||||
} else {
|
} else {
|
||||||
if tab == .compose {
|
return
|
||||||
presentCompose()
|
|
||||||
} else {
|
|
||||||
if presentedViewController != nil {
|
|
||||||
dismiss(animated: true) {
|
|
||||||
self.select(item: .tab(tab))
|
|
||||||
self.sidebar.select(item: .tab(tab), animated: false)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
select(item: .tab(tab))
|
|
||||||
sidebar.select(item: .tab(tab), animated: false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sidebar.select(item: item, animated: false)
|
||||||
|
select(item: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
@ -506,6 +466,22 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||||
|
if traitCollection.horizontalSizeClass == .compact {
|
||||||
|
return tabBarViewController.getNavigationDelegate()
|
||||||
|
} else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNavigationController() -> NavigationControllerProtocol {
|
||||||
|
if traitCollection.horizontalSizeClass == .compact {
|
||||||
|
return tabBarViewController.getNavigationController()
|
||||||
|
} else {
|
||||||
|
return secondaryNavController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func performSearch(query: String) {
|
func performSearch(query: String) {
|
||||||
guard traitCollection.horizontalSizeClass != .compact else {
|
guard traitCollection.horizontalSizeClass != .compact else {
|
||||||
// ensure the tab bar VC is loaded
|
// ensure the tab bar VC is loaded
|
||||||
|
|
|
@ -110,6 +110,31 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
repositionFastSwitcherIndicator()
|
repositionFastSwitcherIndicator()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func select(tab: Tab) {
|
||||||
|
if tab == .compose {
|
||||||
|
compose(editing: nil)
|
||||||
|
} else {
|
||||||
|
// when switching tabs, dismiss the currently presented VC
|
||||||
|
// otherwise the selected tab changes behind the presented VC
|
||||||
|
if presentedViewController != nil {
|
||||||
|
dismiss(animated: true) {
|
||||||
|
self.selectedIndex = tab.rawValue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
|
||||||
|
selectedIndex = tab.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func show(_ vc: UIViewController, sender: Any?) {
|
||||||
|
if let nav = selectedViewController as? UINavigationController {
|
||||||
|
nav.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
present(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func repositionFastSwitcherIndicator() {
|
private func repositionFastSwitcherIndicator() {
|
||||||
guard let myProfileButton = findMyProfileTabBarButton() else {
|
guard let myProfileButton = findMyProfileTabBarButton() else {
|
||||||
return
|
return
|
||||||
|
@ -145,6 +170,10 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
fastAccountSwitcher.hide()
|
fastAccountSwitcher.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func handleComposeKeyCommand() {
|
||||||
|
compose(editing: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func embedInNavigationController(_ vc: UIViewController) -> UINavigationController {
|
func embedInNavigationController(_ vc: UIViewController) -> UINavigationController {
|
||||||
if let vc = vc as? UINavigationController {
|
if let vc = vc as? UINavigationController {
|
||||||
return vc
|
return vc
|
||||||
|
@ -157,7 +186,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
|
|
||||||
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
||||||
if viewController == composePlaceholder {
|
if viewController == composePlaceholder {
|
||||||
presentCompose()
|
compose(editing: nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if viewController == viewControllers![selectedIndex],
|
if viewController == viewControllers![selectedIndex],
|
||||||
|
@ -242,96 +271,50 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
|
||||||
|
|
||||||
extension MainTabBarViewController: StateRestorableViewController {
|
extension MainTabBarViewController: StateRestorableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
let nav = viewController(for: selectedTab) as! UINavigationController
|
|
||||||
var activity: NSUserActivity?
|
var activity: NSUserActivity?
|
||||||
if let vc = nav.topViewController as? StateRestorableViewController {
|
|
||||||
activity = vc.stateRestorationActivity()
|
|
||||||
} else {
|
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController")
|
|
||||||
}
|
|
||||||
if let presentedNav = presentedViewController as? UINavigationController,
|
if let presentedNav = presentedViewController as? UINavigationController,
|
||||||
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
|
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
|
||||||
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
|
activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID)
|
||||||
|
} else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController {
|
||||||
|
activity = vc.stateRestorationActivity()
|
||||||
|
}
|
||||||
|
if activity == nil {
|
||||||
|
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController")
|
||||||
}
|
}
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
guard let type = UserActivityType(rawValue: activity.activityType) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tab: Tab
|
|
||||||
switch type {
|
|
||||||
case .showTimeline:
|
|
||||||
tab = .timelines
|
|
||||||
case .checkNotifications:
|
|
||||||
tab = .notifications
|
|
||||||
case .search, .bookmarks:
|
|
||||||
tab = .explore
|
|
||||||
case .myProfile:
|
|
||||||
tab = .myProfile
|
|
||||||
case .newPost:
|
|
||||||
restoreEditedDraft()
|
|
||||||
return
|
|
||||||
case .showConversation, .showProfile:
|
|
||||||
tab = .timelines
|
|
||||||
default:
|
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select(tab: tab)
|
|
||||||
let nav = viewController(for: tab) as! UINavigationController
|
|
||||||
|
|
||||||
if type == .showConversation {
|
|
||||||
if let statusID = UserActivityManager.getConversationStatus(from: activity) {
|
|
||||||
let conv = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController)
|
|
||||||
nav.pushViewController(conv, animated: false)
|
|
||||||
}
|
|
||||||
} else if type == .showProfile {
|
|
||||||
if let accountID = UserActivityManager.getProfile(from: activity) {
|
|
||||||
let profile = ProfileViewController(accountID: accountID, mastodonController: mastodonController)
|
|
||||||
nav.pushViewController(profile, animated: false)
|
|
||||||
}
|
|
||||||
} else if type == .bookmarks {
|
|
||||||
nav.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: false)
|
|
||||||
} else if let vc = nav.viewControllers.first as? StateRestorableViewController {
|
|
||||||
vc.restoreActivity(activity)
|
|
||||||
} else {
|
|
||||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity, couldn't find StateRestorableViewController")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainTabBarViewController: TuskerRootViewController {
|
extension MainTabBarViewController: TuskerRootViewController {
|
||||||
@objc func presentCompose() {
|
func select(route: TuskerRoute, animated: Bool) {
|
||||||
compose()
|
switch route {
|
||||||
|
case .timelines:
|
||||||
|
select(tab: .timelines)
|
||||||
|
case .notifications:
|
||||||
|
select(tab: .notifications)
|
||||||
|
case .myProfile:
|
||||||
|
select(tab: .myProfile)
|
||||||
|
case .explore:
|
||||||
|
select(tab: .explore)
|
||||||
|
case .bookmarks:
|
||||||
|
select(tab: .explore)
|
||||||
|
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
|
||||||
|
case .list(id: let id):
|
||||||
|
select(tab: .explore)
|
||||||
|
if let list = mastodonController.getCachedList(id: id) {
|
||||||
|
let nav = getNavigationController()
|
||||||
|
_ = nav.popToRootViewController(animated: animated)
|
||||||
|
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(tab: Tab) {
|
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||||
if tab == .compose {
|
return self
|
||||||
presentCompose()
|
|
||||||
} else {
|
|
||||||
// when switching tabs, dismiss the currently presented VC
|
|
||||||
// otherwise the selected tab changes behind the presented VC
|
|
||||||
if presentedViewController != nil {
|
|
||||||
dismiss(animated: true) {
|
|
||||||
self.selectedIndex = tab.rawValue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
|
|
||||||
selectedIndex = tab.rawValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getNavigationController() -> NavigationControllerProtocol {
|
||||||
|
return (selectedViewController as! UINavigationController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func performSearch(query: String) {
|
func performSearch(query: String) {
|
||||||
|
|
|
@ -8,10 +8,91 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
||||||
func presentCompose()
|
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
|
||||||
func select(tab: MainTabBarViewController.Tab)
|
func select(route: TuskerRoute, animated: Bool)
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||||
|
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
||||||
|
func getNavigationController() -> NavigationControllerProtocol
|
||||||
func performSearch(query: String)
|
func performSearch(query: String)
|
||||||
func presentPreferences(completion: (() -> Void)?)
|
func presentPreferences(completion: (() -> Void)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//extension TuskerRootViewController {
|
||||||
|
// func select(route: NewRoute, animated: Bool) {
|
||||||
|
// doApply(components: route.components, animated: animated)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func doApply(components: ArraySlice<RouteComponent>, animated: Bool) {
|
||||||
|
// guard let first = components.first else {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// doApply(component: first, animated: animated) {
|
||||||
|
// self.doApply(components: components.dropFirst(), animated: animated)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func doApply(component: RouteComponent, animated: Bool, completion: @escaping () -> Void) {
|
||||||
|
// switch component {
|
||||||
|
// case .topLevelItem(let rootRoute):
|
||||||
|
// select(route: rootRoute)
|
||||||
|
// completion()
|
||||||
|
// case .popToRoot:
|
||||||
|
// _ = getNavigationController().popToRootViewController(animated: animated)
|
||||||
|
// completion()
|
||||||
|
// case .push(<#T##(MastodonController) -> UIViewController#>)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
enum TuskerRoute {
|
||||||
|
case timelines
|
||||||
|
case notifications
|
||||||
|
case myProfile
|
||||||
|
case explore
|
||||||
|
case bookmarks
|
||||||
|
case list(id: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct NewRoute: ExpressibleByArrayLiteral {
|
||||||
|
// let components: [RouteComponent]
|
||||||
|
//
|
||||||
|
// init(arrayLiteral elements: RouteComponent...) {
|
||||||
|
// self.components = elements
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// static var timelines: Self { [.topLevelItem(.timelines)] }
|
||||||
|
// static var explore: Self { [.topLevelItem(.explore)] }
|
||||||
|
// static var myProfile: Self { [.topLevelItem(.myProfile)] }
|
||||||
|
// static var bookmarks: Self { [.topLevelItem(.explore), .push({ BookmarksViewController(mastodonController: $0) })] }
|
||||||
|
// static func profile(accountID: String) -> Self { [.topLevelItem(.timelines), .push({ ProfileViewController(accountID: accountID, mastodonController: $0) })] }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//enum RouteComponent {
|
||||||
|
// case topLevelItem(RootRoute)
|
||||||
|
// case popToRoot
|
||||||
|
// case push((MastodonController) -> UIViewController)
|
||||||
|
// case present(UIViewController)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//enum RootRoute {
|
||||||
|
// case timelines
|
||||||
|
// case explore
|
||||||
|
// case myProfile
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
protocol NavigationControllerProtocol {
|
||||||
|
var topViewController: UIViewController? { get }
|
||||||
|
func popToRootViewController(animated: Bool) -> [UIViewController]?
|
||||||
|
func pushViewController(_ vc: UIViewController, animated: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UINavigationController: NavigationControllerProtocol {
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SplitNavigationController: NavigationControllerProtocol {
|
||||||
|
var topViewController: UIViewController? {
|
||||||
|
viewControllers.last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -79,6 +79,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func userActivity(accountID: String) -> NSUserActivity {
|
func userActivity(accountID: String) -> NSUserActivity {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
|
@ -95,10 +96,4 @@ extension NotificationsPageViewController: StateRestorableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
if let mode = UserActivityManager.getNotificationsMode(from: activity) {
|
|
||||||
selectMode(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,9 @@ class MyProfileViewController: ProfileViewController {
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
|
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateUserActivity() {
|
||||||
userActivity = UserActivityManager.myProfileActivity(accountID: mastodonController.accountInfo!.id)
|
userActivity = UserActivityManager.myProfileActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -109,6 +109,13 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateUserActivity() {
|
||||||
|
if let accountID,
|
||||||
|
let currentAccountID = mastodonController.accountInfo?.id {
|
||||||
|
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadAccount() async {
|
private func loadAccount() async {
|
||||||
guard let accountID else {
|
guard let accountID else {
|
||||||
return
|
return
|
||||||
|
@ -136,10 +143,7 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAccountUI(account: AccountMO) {
|
private func updateAccountUI(account: AccountMO) {
|
||||||
if let currentAccountID = mastodonController.accountInfo?.id {
|
updateUserActivity()
|
||||||
userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationItem.title = account.displayNameWithoutCustomEmoji
|
navigationItem.title = account.displayNameWithoutCustomEmoji
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -295,10 +299,6 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
fatalError("ProfileViewController must be reconstructed, not restored")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController {
|
extension ProfileViewController {
|
||||||
|
|
|
@ -23,7 +23,7 @@ struct ReportView: View {
|
||||||
self.account = mastodonController.persistentContainer.account(for: report.accountID)!
|
self.account = mastodonController.persistentContainer.account(for: report.accountID)!
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self._report = StateObject(wrappedValue: report)
|
self._report = StateObject(wrappedValue: report)
|
||||||
if mastodonController.instance.rules == nil {
|
if mastodonController.instance?.rules == nil {
|
||||||
report.reason = .spam
|
report.reason = .spam
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,7 @@ struct ReportView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if mastodonController.instance.rules != nil {
|
if mastodonController.instance?.rules != nil {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ReportSelectRulesView(mastodonController: mastodonController, report: report)
|
ReportSelectRulesView(mastodonController: mastodonController, report: report)
|
||||||
} label: {
|
} label: {
|
||||||
|
|
|
@ -25,7 +25,7 @@ extension TimelineViewControllerDelegate {
|
||||||
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {}
|
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
|
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController, StateRestorableViewController {
|
||||||
weak var delegate: TimelineViewControllerDelegate?
|
weak var delegate: TimelineViewControllerDelegate?
|
||||||
|
|
||||||
let timeline: Timeline
|
let timeline: Timeline
|
||||||
|
@ -43,8 +43,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var contentOffsetObservation: NSKeyValueObservation?
|
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
|
||||||
private var activityToRestore: NSUserActivity?
|
|
||||||
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
|
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
|
||||||
private var disappearedAt: Date?
|
private var disappearedAt: Date?
|
||||||
|
|
||||||
|
@ -69,6 +68,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
self.navigationItem.title = timeline.title
|
self.navigationItem.title = timeline.title
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
||||||
|
|
||||||
|
if let accountID = mastodonController.accountInfo?.id {
|
||||||
|
self.userActivity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: accountID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -136,13 +139,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
|
|
||||||
if let indexPath = self?.dataSource.indexPath(for: .gap),
|
|
||||||
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
|
|
||||||
cell.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterer.filtersChanged = { [unowned self] actionsChanged in
|
filterer.filtersChanged = { [unowned self] actionsChanged in
|
||||||
self.reapplyFilters(actionsChanged: actionsChanged)
|
self.reapplyFilters(actionsChanged: actionsChanged)
|
||||||
}
|
}
|
||||||
|
@ -168,6 +164,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||||
|
|
||||||
|
if userActivity != nil {
|
||||||
|
userActivityNeedsUpdate
|
||||||
|
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
if let info = self.timelinePositionInfo(),
|
||||||
|
let userActivity = self.userActivity {
|
||||||
|
UserActivityManager.addTimelinePositionInfo(to: userActivity, statusIDs: info.statusIDs, centerStatusID: info.centerStatusID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -291,15 +299,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return centerVisible
|
return centerVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveState() {
|
private func timelinePositionInfo() -> (statusIDs: [String], centerStatusID: String)? {
|
||||||
guard isViewLoaded,
|
guard isViewLoaded else {
|
||||||
persistsState,
|
return nil
|
||||||
let accountInfo = mastodonController.accountInfo else {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else {
|
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
|
|
||||||
|
@ -339,6 +345,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
|
return (ids, centerVisibleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveState() {
|
||||||
|
guard persistsState,
|
||||||
|
let accountInfo = mastodonController.accountInfo,
|
||||||
|
let (ids, centerVisibleID) = timelinePositionInfo() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch Preferences.shared.timelineSyncMode {
|
switch Preferences.shared.timelineSyncMode {
|
||||||
case .icloud:
|
case .icloud:
|
||||||
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
||||||
|
@ -406,6 +421,22 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreStateFromHandoff(statusIDs: [String], centerStatusID: String) async {
|
||||||
|
let crumb = Breadcrumb(level: .debug, category: "TimelineViewController")
|
||||||
|
crumb.message = "Restoring state from handoff activity"
|
||||||
|
SentrySDK.addBreadcrumb(crumb)
|
||||||
|
await controller.restoreInitial { @MainActor in
|
||||||
|
let position = TimelinePosition(context: mastodonController.persistentContainer.viewContext)
|
||||||
|
position.statusIDs = statusIDs
|
||||||
|
position.centerStatusID = centerStatusID
|
||||||
|
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||||
|
if hasStatusesToRestore {
|
||||||
|
applyItemsToRestore(position: position)
|
||||||
|
}
|
||||||
|
mastodonController.persistentContainer.viewContext.delete(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
|
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
|
||||||
let originalPositionStatusIDs = position.statusIDs
|
let originalPositionStatusIDs = position.statusIDs
|
||||||
|
@ -1290,6 +1321,23 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
removeTimelineDescriptionCell()
|
removeTimelineDescriptionCell()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||||
|
if !decelerate {
|
||||||
|
userActivityNeedsUpdate.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
userActivityNeedsUpdate.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
if let indexPath = dataSource.indexPath(for: .gap),
|
||||||
|
let cell = collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
|
||||||
|
cell.update()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineViewController: UICollectionViewDragDelegate {
|
extension TimelineViewController: UICollectionViewDragDelegate {
|
||||||
|
|
|
@ -211,16 +211,4 @@ extension TimelinesPageViewController: StateRestorableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
guard let timeline = UserActivityManager.getTimeline(from: activity),
|
|
||||||
let pinned = PinnedTimeline(timeline: timeline) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let page = Page(mastodonController: mastodonController, timeline: pinned)
|
|
||||||
// the pinned timelines may have changed after an iCloud sync, in which case don't restore anything
|
|
||||||
if pages.contains(page) {
|
|
||||||
selectPage(page, animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,7 +73,7 @@ extension MenuActionProvider {
|
||||||
actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in
|
actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in
|
||||||
var listActions = mastodonController.lists.map { list in
|
var listActions = mastodonController.lists.map { list in
|
||||||
UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in
|
UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in
|
||||||
let req = List.add(list, accounts: [accountID])
|
let req = List.add(list.id, accounts: [accountID])
|
||||||
mastodonController.run(req) { response in
|
mastodonController.run(req) { response in
|
||||||
if case .failure(let error) = response {
|
if case .failure(let error) = response {
|
||||||
self.handleError(error, title: "Error Adding to List")
|
self.handleError(error, title: "Error Adding to List")
|
||||||
|
@ -86,7 +86,7 @@ extension MenuActionProvider {
|
||||||
let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in
|
let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in
|
||||||
self.navigationDelegate!.present($0, animated: true)
|
self.navigationDelegate!.present($0, animated: true)
|
||||||
}) { list in
|
}) { list in
|
||||||
let req = List.add(list, accounts: [accountID])
|
let req = List.add(list.id, accounts: [accountID])
|
||||||
let response = await mastodonController.runResponse(req)
|
let response = await mastodonController.runResponse(req)
|
||||||
if case .failure(let error) = response {
|
if case .failure(let error) = response {
|
||||||
self.handleError(error, title: "Error Adding to List")
|
self.handleError(error, title: "Error Adding to List")
|
||||||
|
|
|
@ -143,6 +143,17 @@ class SplitNavigationController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pushViewController(_ vc: UIViewController, animated: Bool) {
|
||||||
|
if !canShowSecondaryNav {
|
||||||
|
rootNav.pushViewController(vc, animated: animated)
|
||||||
|
} else if rootNav.viewControllers.isEmpty {
|
||||||
|
rootNav.pushViewController(vc, animated: false)
|
||||||
|
} else {
|
||||||
|
secondaryNav.pushViewController(vc, animated: animated)
|
||||||
|
}
|
||||||
|
updateSecondaryNavVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSecondaryNavVisibility() {
|
private func updateSecondaryNavVisibility() {
|
||||||
guard isViewLoaded else {
|
guard isViewLoaded else {
|
||||||
return
|
return
|
||||||
|
@ -219,7 +230,9 @@ class SplitNavigationController: UIViewController {
|
||||||
|
|
||||||
private var isLayingOutForAnimation = false
|
private var isLayingOutForAnimation = false
|
||||||
|
|
||||||
func popToRootViewController(animated: Bool) {
|
@discardableResult
|
||||||
|
func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||||
|
let vcs = secondaryNav.viewControllers
|
||||||
if animated {
|
if animated {
|
||||||
// we don't update secondaryNav.viewControllers until after the animation is completed
|
// we don't update secondaryNav.viewControllers until after the animation is completed
|
||||||
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
|
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
|
||||||
|
@ -238,6 +251,7 @@ class SplitNavigationController: UIViewController {
|
||||||
self.secondaryNav.viewControllers = []
|
self.secondaryNav.viewControllers = []
|
||||||
self.updateSecondaryNavVisibility()
|
self.updateSecondaryNavVisibility()
|
||||||
}
|
}
|
||||||
|
return vcs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,4 @@ import UIKit
|
||||||
|
|
||||||
protocol StateRestorableViewController: UIViewController {
|
protocol StateRestorableViewController: UIViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity?
|
func stateRestorationActivity() -> NSUserActivity?
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,20 +35,20 @@ enum AppShortcutItem: String, CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handle() {
|
func handle() {
|
||||||
let tab: MainTabBarViewController.Tab
|
|
||||||
switch self {
|
|
||||||
case .showHomeTimeline:
|
|
||||||
tab = .timelines
|
|
||||||
case .showNotifications:
|
|
||||||
tab = .notifications
|
|
||||||
case .composePost:
|
|
||||||
tab = .compose
|
|
||||||
}
|
|
||||||
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
|
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
|
||||||
let window = scene.windows.first { $0.isKeyWindow }!
|
let window = scene.windows.first { $0.isKeyWindow }!
|
||||||
if let controller = window.rootViewController as? TuskerRootViewController {
|
guard let root = window.rootViewController as? TuskerRootViewController else {
|
||||||
controller.select(tab: tab)
|
return
|
||||||
|
}
|
||||||
|
switch self {
|
||||||
|
case .showHomeTimeline:
|
||||||
|
root.select(route: .timelines, animated: false)
|
||||||
|
case .showNotifications:
|
||||||
|
root.select(route: .notifications, animated: false)
|
||||||
|
case .composePost:
|
||||||
|
root.compose(editing: nil, animated: false, isDucked: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,6 +60,7 @@ extension AppShortcutItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||||
guard let type = AppShortcutItem(rawValue: shortcutItem.type) else { return false }
|
guard let type = AppShortcutItem(rawValue: shortcutItem.type) else { return false }
|
||||||
type.handle()
|
type.handle()
|
||||||
|
|
|
@ -41,6 +41,7 @@ extension NSUserActivity {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func handleResume(manager: UserActivityManager) -> Bool {
|
func handleResume(manager: UserActivityManager) -> Bool {
|
||||||
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
||||||
type.handle(manager)(self)
|
type.handle(manager)(self)
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
//
|
||||||
|
// UserActivityHandlingContext.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/25/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Duckable
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol UserActivityHandlingContext {
|
||||||
|
var isHandoff: Bool { get }
|
||||||
|
|
||||||
|
func select(route: TuskerRoute)
|
||||||
|
func present(_ vc: UIViewController)
|
||||||
|
|
||||||
|
var topViewController: UIViewController? { get }
|
||||||
|
func popToRoot()
|
||||||
|
func push(_ vc: UIViewController)
|
||||||
|
|
||||||
|
func compose(editing draft: Draft)
|
||||||
|
|
||||||
|
func finalize(activity: NSUserActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
||||||
|
let isHandoff: Bool
|
||||||
|
let root: TuskerRootViewController
|
||||||
|
var navigationDelegate: TuskerNavigationDelegate {
|
||||||
|
root.getNavigationDelegate()!
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(route: TuskerRoute) {
|
||||||
|
root.select(route: route, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(_ vc: UIViewController) {
|
||||||
|
navigationDelegate.present(vc, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
||||||
|
|
||||||
|
func popToRoot() {
|
||||||
|
_ = root.getNavigationController().popToRootViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(_ vc: UIViewController) {
|
||||||
|
navigationDelegate.show(vc, sender: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compose(editing draft: Draft) {
|
||||||
|
navigationDelegate.compose(editing: draft, animated: true, isDucked: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalize(activity: NSUserActivity) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
||||||
|
private var state = State.initial
|
||||||
|
let root: TuskerRootViewController
|
||||||
|
|
||||||
|
init(root: TuskerRootViewController) {
|
||||||
|
self.root = root
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHandoff: Bool { false }
|
||||||
|
|
||||||
|
func select(route: TuskerRoute) {
|
||||||
|
root.select(route: route, animated: false)
|
||||||
|
state = .selectedRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
||||||
|
|
||||||
|
func popToRoot() {
|
||||||
|
// unnecessary during state restoration
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(_ vc: UIViewController) {
|
||||||
|
precondition(state >= .selectedRoute)
|
||||||
|
root.getNavigationController().pushViewController(vc, animated: false)
|
||||||
|
state = .pushed
|
||||||
|
}
|
||||||
|
|
||||||
|
func present(_ vc: UIViewController) {
|
||||||
|
root.present(vc, animated: false)
|
||||||
|
state = .presented
|
||||||
|
}
|
||||||
|
|
||||||
|
func compose(editing draft: Draft) {
|
||||||
|
if #available(iOS 16.0, *),
|
||||||
|
UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
|
self.root.compose(editing: draft, animated: false, isDucked: true)
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.root.compose(editing: draft, animated: true, isDucked: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state = .presented
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalize(activity: NSUserActivity) {
|
||||||
|
precondition(state > .initial)
|
||||||
|
if #available(iOS 16.0, *),
|
||||||
|
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
|
||||||
|
self.root.compose(editing: duckedDraft, animated: false, isDucked: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State: Comparable {
|
||||||
|
case initial
|
||||||
|
case selectedRoute
|
||||||
|
case pushed
|
||||||
|
case presented
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,12 +13,15 @@ import OSLog
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class UserActivityManager {
|
class UserActivityManager {
|
||||||
|
|
||||||
private let scene: UIWindowScene
|
private let scene: UIWindowScene
|
||||||
|
private let context: any UserActivityHandlingContext
|
||||||
|
|
||||||
init(scene: UIWindowScene) {
|
init(scene: UIWindowScene, context: any UserActivityHandlingContext) {
|
||||||
self.scene = scene
|
self.scene = scene
|
||||||
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Utils
|
// MARK: - Utils
|
||||||
|
@ -73,12 +76,13 @@ class UserActivityManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNewPost(activity: NSUserActivity) {
|
func handleNewPost(activity: NSUserActivity) {
|
||||||
// TODO: check not currently showing compose screen
|
if let draft = Self.getDraft(from: activity) {
|
||||||
|
context.compose(editing: draft)
|
||||||
|
} else {
|
||||||
let mentioning = activity.userInfo?["mentioning"] as? String
|
let mentioning = activity.userInfo?["mentioning"] as? String
|
||||||
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
|
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
|
||||||
// todo: this shouldn't use self.mastodonController, it should get the right one based on the userInfo accountID
|
context.compose(editing: draft)
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
|
}
|
||||||
present(UINavigationController(rootViewController: composeVC))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity {
|
static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity {
|
||||||
|
@ -100,25 +104,16 @@ class UserActivityManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
|
static func getDraft(from activity: NSUserActivity) -> Draft? {
|
||||||
if let activity {
|
guard let idStr = activity.userInfo?["draftID"] as? String,
|
||||||
activity.addUserInfoEntries(from: [
|
let uuid = UUID(uuidString: idStr) else {
|
||||||
"editedDraftID": draft.id.uuidString
|
return nil
|
||||||
])
|
|
||||||
return activity
|
|
||||||
} else {
|
|
||||||
return editDraftActivity(id: draft.id, accountID: draft.accountID)
|
|
||||||
}
|
}
|
||||||
|
return DraftsManager.shared.getBy(id: uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getDraft(from activity: NSUserActivity) -> Draft? {
|
static func getDuckedDraft(from activity: NSUserActivity) -> Draft? {
|
||||||
let idStr: String?
|
guard let idStr = activity.userInfo?["duckedDraftID"] as? 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 {
|
let uuid = UUID(uuidString: idStr) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -129,6 +124,7 @@ class UserActivityManager {
|
||||||
static func checkNotificationsActivity(mode: NotificationsMode = .allNotifications, accountID: String) -> NSUserActivity {
|
static func checkNotificationsActivity(mode: NotificationsMode = .allNotifications, accountID: String) -> NSUserActivity {
|
||||||
let activity = NSUserActivity(type: .checkNotifications, accountID: accountID)
|
let activity = NSUserActivity(type: .checkNotifications, accountID: accountID)
|
||||||
activity.isEligibleForPrediction = true
|
activity.isEligibleForPrediction = true
|
||||||
|
activity.isEligibleForHandoff = true
|
||||||
activity.addUserInfoEntries(from: [
|
activity.addUserInfoEntries(from: [
|
||||||
"notificationsMode": mode.rawValue
|
"notificationsMode": mode.rawValue
|
||||||
])
|
])
|
||||||
|
@ -144,11 +140,9 @@ class UserActivityManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCheckNotifications(activity: NSUserActivity) {
|
func handleCheckNotifications(activity: NSUserActivity) {
|
||||||
let mainViewController = getMainViewController()
|
context.select(route: .notifications)
|
||||||
mainViewController.select(tab: .notifications)
|
context.popToRoot()
|
||||||
if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController,
|
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
|
||||||
let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController {
|
|
||||||
navigationController.popToRootViewController(animated: false)
|
|
||||||
notificationsPageController.loadViewIfNeeded()
|
notificationsPageController.loadViewIfNeeded()
|
||||||
notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
|
notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
|
||||||
}
|
}
|
||||||
|
@ -167,6 +161,7 @@ class UserActivityManager {
|
||||||
|
|
||||||
let activity = NSUserActivity(type: .showTimeline, accountID: accountID)
|
let activity = NSUserActivity(type: .showTimeline, accountID: accountID)
|
||||||
activity.isEligibleForPrediction = true
|
activity.isEligibleForPrediction = true
|
||||||
|
activity.isEligibleForHandoff = true
|
||||||
activity.addUserInfoEntries(from: [
|
activity.addUserInfoEntries(from: [
|
||||||
"timelineData": timelineData,
|
"timelineData": timelineData,
|
||||||
])
|
])
|
||||||
|
@ -194,31 +189,53 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getTimeline(from activity: NSUserActivity) -> Timeline? {
|
static func addTimelinePositionInfo(to activity: NSUserActivity, statusIDs: [String], centerStatusID: String) {
|
||||||
|
activity.addUserInfoEntries(from: [
|
||||||
|
"statusIDs": statusIDs,
|
||||||
|
"centerStatusID": centerStatusID
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getTimeline(from activity: NSUserActivity) -> (Timeline, (statusIDs: [String], centerStatusID: String)?)? {
|
||||||
guard activity.activityType == UserActivityType.showTimeline.rawValue,
|
guard activity.activityType == UserActivityType.showTimeline.rawValue,
|
||||||
let data = activity.userInfo?["timelineData"] as? Data else {
|
let data = activity.userInfo?["timelineData"] as? Data,
|
||||||
|
let timeline = try? UserActivityManager.decoder.decode(Timeline.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return try? UserActivityManager.decoder.decode(Timeline.self, from: data)
|
var positionInfo: ([String], String)?
|
||||||
|
if let ids = activity.userInfo?["statusIDs"] as? [String],
|
||||||
|
let center = activity.userInfo?["centerStatusID"] as? String {
|
||||||
|
positionInfo = (ids, center)
|
||||||
|
}
|
||||||
|
return (timeline, positionInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowTimeline(activity: NSUserActivity) {
|
func handleShowTimeline(activity: NSUserActivity) {
|
||||||
guard let timeline = Self.getTimeline(from: activity) else { return }
|
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
|
||||||
|
|
||||||
let mainViewController = getMainViewController()
|
|
||||||
mainViewController.select(tab: .timelines)
|
|
||||||
guard let navigationController = mainViewController.getTabController(tab: .timelines) as? UINavigationController else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let timelineVC: TimelineViewController
|
||||||
if let pinned = PinnedTimeline(timeline: timeline),
|
if let pinned = PinnedTimeline(timeline: timeline),
|
||||||
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
||||||
navigationController.popToRootViewController(animated: false)
|
context.select(route: .timelines)
|
||||||
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
context.popToRoot()
|
||||||
rootController.selectTimeline(pinned, animated: false)
|
let pageController = context.topViewController as! TimelinesPageViewController
|
||||||
|
pageController.selectTimeline(pinned, animated: false)
|
||||||
|
timelineVC = pageController.currentViewController as! TimelineViewController
|
||||||
|
} else if case .list(let id) = timeline {
|
||||||
|
context.select(route: .list(id: id))
|
||||||
|
timelineVC = context.topViewController! as! TimelineViewController
|
||||||
} else {
|
} else {
|
||||||
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
context.select(route: .explore)
|
||||||
navigationController.pushViewController(timeline, animated: false)
|
context.popToRoot()
|
||||||
|
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
|
context.push(timelineVC)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let positionInfo,
|
||||||
|
context.isHandoff {
|
||||||
|
Task {
|
||||||
|
await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,6 +246,7 @@ class UserActivityManager {
|
||||||
"mainStatusID": mainStatusID,
|
"mainStatusID": mainStatusID,
|
||||||
])
|
])
|
||||||
activity.isEligibleForPrediction = isEligibleForPrediction
|
activity.isEligibleForPrediction = isEligibleForPrediction
|
||||||
|
activity.isEligibleForHandoff = true
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,6 +254,14 @@ class UserActivityManager {
|
||||||
return activity.userInfo?["mainStatusID"] as? String
|
return activity.userInfo?["mainStatusID"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleShowConversation(activity: NSUserActivity) {
|
||||||
|
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.select(route: .timelines)
|
||||||
|
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Explore
|
// MARK: - Explore
|
||||||
|
|
||||||
static func searchActivity(query: String?, accountID: String) -> NSUserActivity {
|
static func searchActivity(query: String?, accountID: String) -> NSUserActivity {
|
||||||
|
@ -254,19 +280,31 @@ class UserActivityManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSearch(activity: NSUserActivity) {
|
func handleSearch(activity: NSUserActivity) {
|
||||||
let mainViewController = getMainViewController()
|
context.select(route: .explore)
|
||||||
mainViewController.select(tab: .explore)
|
context.popToRoot()
|
||||||
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController,
|
|
||||||
let exploreController = navigationController.viewControllers.first as? ExploreViewController {
|
let searchController: UISearchController
|
||||||
navigationController.popToRootViewController(animated: false)
|
let resultsController: SearchResultsViewController
|
||||||
exploreController.loadViewIfNeeded()
|
if let explore = context.topViewController as? ExploreViewController {
|
||||||
exploreController.searchController.isActive = true
|
explore.loadViewIfNeeded()
|
||||||
|
explore.searchControllerStatusOnAppearance = true
|
||||||
|
searchController = explore.searchController
|
||||||
|
resultsController = explore.resultsController
|
||||||
|
} else if let inlineTrends = context.topViewController as? InlineTrendsViewController {
|
||||||
|
inlineTrends.loadViewIfNeeded()
|
||||||
|
inlineTrends.searchControllerStatusOnAppearance = true
|
||||||
|
searchController = inlineTrends.searchController
|
||||||
|
resultsController = inlineTrends.resultsController
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let query = Self.getSearchQuery(from: activity),
|
if let query = Self.getSearchQuery(from: activity),
|
||||||
!query.isEmpty {
|
!query.isEmpty {
|
||||||
exploreController.searchController.searchBar.text = query
|
searchController.searchBar.text = query
|
||||||
|
resultsController.performSearch(query: query)
|
||||||
} else {
|
} else {
|
||||||
exploreController.searchController.searchBar.becomeFirstResponder()
|
searchController.searchBar.becomeFirstResponder()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -279,26 +317,21 @@ class UserActivityManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBookmarks(activity: NSUserActivity) {
|
func handleBookmarks(activity: NSUserActivity) {
|
||||||
let mainViewController = getMainViewController()
|
context.select(route: .bookmarks)
|
||||||
mainViewController.select(tab: .explore)
|
|
||||||
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController {
|
|
||||||
navigationController.popToRootViewController(animated: false)
|
|
||||||
navigationController.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - My Profile
|
// MARK: - My Profile
|
||||||
static func myProfileActivity(accountID: String) -> NSUserActivity {
|
static func myProfileActivity(accountID: String) -> NSUserActivity {
|
||||||
let activity = NSUserActivity(type: .myProfile, accountID: accountID)
|
let activity = NSUserActivity(type: .myProfile, accountID: accountID)
|
||||||
activity.isEligibleForPrediction = true
|
activity.isEligibleForPrediction = true
|
||||||
|
activity.isEligibleForHandoff = true
|
||||||
activity.title = NSLocalizedString("My Profile", comment: "my profile shortcut title")
|
activity.title = NSLocalizedString("My Profile", comment: "my profile shortcut title")
|
||||||
activity.suggestedInvocationPhrase = NSLocalizedString("Show my Mastodon profile", comment: "my profile shortuct invocation phrase")
|
activity.suggestedInvocationPhrase = NSLocalizedString("Show my Mastodon profile", comment: "my profile shortuct invocation phrase")
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMyProfile(activity: NSUserActivity) {
|
func handleMyProfile(activity: NSUserActivity) {
|
||||||
let mainViewController = getMainViewController()
|
context.select(route: .myProfile)
|
||||||
mainViewController.select(tab: .myProfile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Show Profile
|
// MARK: - Show Profile
|
||||||
|
@ -308,6 +341,7 @@ class UserActivityManager {
|
||||||
"profileID": profileID,
|
"profileID": profileID,
|
||||||
])
|
])
|
||||||
activity.isEligibleForPrediction = true
|
activity.isEligibleForPrediction = true
|
||||||
|
activity.isEligibleForHandoff = true
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -315,4 +349,12 @@ class UserActivityManager {
|
||||||
return activity.userInfo?["profileID"] as? String
|
return activity.userInfo?["profileID"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleShowProfile(activity: NSUserActivity) {
|
||||||
|
guard let accountID = Self.getProfile(from: activity) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.select(route: .timelines)
|
||||||
|
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ enum UserActivityType: String {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UserActivityType {
|
extension UserActivityType {
|
||||||
var handle: (UserActivityManager) -> (NSUserActivity) -> Void {
|
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
|
||||||
switch self {
|
switch self {
|
||||||
case .mainScene:
|
case .mainScene:
|
||||||
fatalError("cannot handle main scene activity")
|
fatalError("cannot handle main scene activity")
|
||||||
|
@ -36,11 +36,11 @@ extension UserActivityType {
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
return UserActivityManager.handleBookmarks
|
return UserActivityManager.handleBookmarks
|
||||||
case .showConversation:
|
case .showConversation:
|
||||||
fatalError("cannot handle show conversation activity")
|
return UserActivityManager.handleShowConversation
|
||||||
case .myProfile:
|
case .myProfile:
|
||||||
return UserActivityManager.handleMyProfile
|
return UserActivityManager.handleMyProfile
|
||||||
case .showProfile:
|
case .showProfile:
|
||||||
fatalError("cannot handle show profile activity")
|
return UserActivityManager.handleShowProfile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,8 @@ extension TuskerNavigationDelegate {
|
||||||
show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
|
show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func compose(editing draft: Draft, animated: Bool = true) {
|
func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) {
|
||||||
|
let draft = draft ?? apiController.createDraft()
|
||||||
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()
|
||||||
|
@ -98,7 +99,7 @@ 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, animated: animated) {
|
presentDuckable(compose, animated: animated, isDucked: isDucked) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
|
|
|
@ -0,0 +1,158 @@
|
||||||
|
//
|
||||||
|
// ProfileHeaderMovedOverlayView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 2/23/23.
|
||||||
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class ProfileHeaderMovedOverlayView: UIView {
|
||||||
|
|
||||||
|
private var movedToID: String!
|
||||||
|
weak var delegate: TuskerNavigationDelegate?
|
||||||
|
|
||||||
|
var collapse: (() -> Void)?
|
||||||
|
|
||||||
|
private var avatarImageView: CachedImageView!
|
||||||
|
private var displayNameLabel: EmojiLabel!
|
||||||
|
private var usernameLabel: UILabel!
|
||||||
|
private(set) var collapseButton: UIButton!
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
let blur = UIBlurEffect(style: .systemUltraThinMaterial)
|
||||||
|
let blurView = UIVisualEffectView(effect: blur)
|
||||||
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(blurView)
|
||||||
|
|
||||||
|
let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label)
|
||||||
|
let vibrancyView = UIVisualEffectView(effect: vibrancy)
|
||||||
|
vibrancyView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
blurView.contentView.addSubview(vibrancyView)
|
||||||
|
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "This account has moved to"
|
||||||
|
label.font = .preferredFont(forTextStyle: .title3).withTraits(.traitBold)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.textColor = .label
|
||||||
|
|
||||||
|
avatarImageView = CachedImageView(cache: .avatars)
|
||||||
|
avatarImageView.layer.masksToBounds = true
|
||||||
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
|
||||||
|
avatarImageView.addInteraction(UIPointerInteraction(delegate: self))
|
||||||
|
avatarImageView.isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
displayNameLabel = EmojiLabel()
|
||||||
|
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
|
||||||
|
.traits: [
|
||||||
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
|
||||||
|
]
|
||||||
|
]), size: 0)
|
||||||
|
|
||||||
|
usernameLabel = UILabel()
|
||||||
|
usernameLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
usernameLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
usernameLabel.textColor = .secondaryLabel
|
||||||
|
|
||||||
|
let nameVStack = UIStackView(arrangedSubviews: [
|
||||||
|
displayNameLabel,
|
||||||
|
usernameLabel,
|
||||||
|
])
|
||||||
|
nameVStack.axis = .vertical
|
||||||
|
nameVStack.alignment = .leading
|
||||||
|
nameVStack.spacing = 4
|
||||||
|
|
||||||
|
let accountHStack = UIStackView(arrangedSubviews: [
|
||||||
|
avatarImageView,
|
||||||
|
nameVStack,
|
||||||
|
])
|
||||||
|
accountHStack.axis = .horizontal
|
||||||
|
accountHStack.alignment = .top
|
||||||
|
accountHStack.spacing = 4
|
||||||
|
accountHStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountTapped)))
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
label,
|
||||||
|
accountHStack,
|
||||||
|
])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
vibrancyView.contentView.addSubview(stack)
|
||||||
|
|
||||||
|
var config = UIButton.Configuration.plain()
|
||||||
|
config.image = UIImage(systemName: "chevron.up")
|
||||||
|
collapseButton = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
|
self.collapse?()
|
||||||
|
}))
|
||||||
|
collapseButton.accessibilityLabel = "Shrink banner"
|
||||||
|
collapseButton.isPointerInteractionEnabled = true
|
||||||
|
collapseButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(collapseButton)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
blurView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
|
vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
|
||||||
|
vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
|
||||||
|
vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
|
||||||
|
vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
|
||||||
|
|
||||||
|
stack.centerXAnchor.constraint(equalTo: vibrancyView.contentView.readableContentGuide.centerXAnchor),
|
||||||
|
stack.centerYAnchor.constraint(equalTo: vibrancyView.contentView.centerYAnchor),
|
||||||
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.readableContentGuide.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.readableContentGuide.trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.topAnchor),
|
||||||
|
stack.bottomAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.bottomAnchor),
|
||||||
|
|
||||||
|
avatarImageView.widthAnchor.constraint(equalToConstant: 50),
|
||||||
|
avatarImageView.heightAnchor.constraint(equalToConstant: 50),
|
||||||
|
|
||||||
|
bottomAnchor.constraint(equalToSystemSpacingBelow: collapseButton.bottomAnchor, multiplier: 1),
|
||||||
|
collapseButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func preferencesChanged() {
|
||||||
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUI(movedTo: AccountMO) {
|
||||||
|
movedToID = movedTo.id
|
||||||
|
|
||||||
|
avatarImageView.update(for: movedTo.avatar)
|
||||||
|
displayNameLabel.text = movedTo.displayOrUserName
|
||||||
|
displayNameLabel.setEmojis(movedTo.emojis, identifier: movedTo.id)
|
||||||
|
usernameLabel.text = "@\(movedTo.acct)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func accountTapped() {
|
||||||
|
delegate?.selected(account: movedToID)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfileHeaderMovedOverlayView: UIPointerInteractionDelegate {
|
||||||
|
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
||||||
|
return defaultRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||||
|
let preview = UITargetedPreview(view: interaction.view!)
|
||||||
|
return UIPointerStyle(effect: .lift(preview))
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,7 @@ class ProfileHeaderView: UIView {
|
||||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||||
@IBOutlet weak var followCountButton: UIButton!
|
@IBOutlet weak var followCountButton: UIButton!
|
||||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||||
|
private var movedOverlayView: ProfileHeaderMovedOverlayView?
|
||||||
|
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
|
|
||||||
|
@ -170,6 +171,21 @@ class ProfileHeaderView: UIView {
|
||||||
followCountButton.setAttributedTitle(followCountTitle, for: .normal)
|
followCountButton.setAttributedTitle(followCountTitle, for: .normal)
|
||||||
followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers"
|
followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers"
|
||||||
|
|
||||||
|
if let movedTo = account.movedTo {
|
||||||
|
if let movedOverlayView {
|
||||||
|
movedOverlayView.updateUI(movedTo: movedTo)
|
||||||
|
} else {
|
||||||
|
let overlay = createMovedOverlayView(movedTo: movedTo)
|
||||||
|
movedOverlayView = overlay
|
||||||
|
|
||||||
|
accessibilityElements = [
|
||||||
|
overlay,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
movedOverlayView?.removeFromSuperview()
|
||||||
|
movedOverlayView = nil
|
||||||
|
|
||||||
accessibilityElements = [
|
accessibilityElements = [
|
||||||
displayNameLabel!,
|
displayNameLabel!,
|
||||||
usernameLabel!,
|
usernameLabel!,
|
||||||
|
@ -180,6 +196,40 @@ class ProfileHeaderView: UIView {
|
||||||
pagesSegmentedControl!,
|
pagesSegmentedControl!,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView {
|
||||||
|
let overlay = ProfileHeaderMovedOverlayView()
|
||||||
|
overlay.delegate = delegate
|
||||||
|
overlay.updateUI(movedTo: movedTo)
|
||||||
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(overlay)
|
||||||
|
let bottomConstraint = overlay.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
bottomConstraint,
|
||||||
|
])
|
||||||
|
|
||||||
|
overlay.collapse = { [weak self, weak overlay] in
|
||||||
|
guard let self, let overlay else { return }
|
||||||
|
bottomConstraint.isActive = false
|
||||||
|
overlay.bottomAnchor.constraint(equalTo: self.avatarContainerView.topAnchor, constant: -2).isActive = true
|
||||||
|
let animator = UIViewPropertyAnimator(duration: 0.35, dampingRatio: 0.8)
|
||||||
|
animator.addAnimations {
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
overlay.collapseButton.layer.opacity = 0
|
||||||
|
}
|
||||||
|
animator.addCompletion { _ in
|
||||||
|
overlay.collapseButton.layer.opacity = 1
|
||||||
|
overlay.collapseButton?.removeFromSuperview()
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
|
||||||
private func updateRelationship() {
|
private func updateRelationship() {
|
||||||
guard let mastodonController = mastodonController,
|
guard let mastodonController = mastodonController,
|
||||||
|
|
|
@ -23,15 +23,16 @@ class StatusCardView: UIView {
|
||||||
private let activeBackgroundColor = UIColor.secondarySystemFill
|
private let activeBackgroundColor = UIColor.secondarySystemFill
|
||||||
private let inactiveBackgroundColor = UIColor.secondarySystemBackground
|
private let inactiveBackgroundColor = UIColor.secondarySystemBackground
|
||||||
|
|
||||||
private var imageRequest: ImageCache.Request?
|
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
|
|
||||||
private var hStack: UIStackView!
|
private var hStack: UIStackView!
|
||||||
private var titleLabel: UILabel!
|
private var titleLabel: UILabel!
|
||||||
private var descriptionLabel: UILabel!
|
private var descriptionLabel: UILabel!
|
||||||
private var domainLabel: UILabel!
|
private var domainLabel: UILabel!
|
||||||
private var imageView: UIImageView!
|
private var imageView: CachedImageView!
|
||||||
private var placeholderImageView: UIImageView!
|
private var placeholderImageView: UIImageView!
|
||||||
|
private var leadingSpacer: UIView!
|
||||||
|
private var trailingSpacer: UIView!
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
@ -76,20 +77,22 @@ class StatusCardView: UIView {
|
||||||
])
|
])
|
||||||
vStack.axis = .vertical
|
vStack.axis = .vertical
|
||||||
vStack.alignment = .leading
|
vStack.alignment = .leading
|
||||||
vStack.distribution = .fill
|
|
||||||
vStack.spacing = 0
|
vStack.spacing = 0
|
||||||
|
|
||||||
imageView = UIImageView()
|
imageView = CachedImageView(cache: .attachments)
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.clipsToBounds = true
|
imageView.clipsToBounds = true
|
||||||
|
|
||||||
let spacer = UIView()
|
leadingSpacer = UIView()
|
||||||
spacer.backgroundColor = .clear
|
leadingSpacer.backgroundColor = .clear
|
||||||
|
trailingSpacer = UIView()
|
||||||
|
trailingSpacer.backgroundColor = .clear
|
||||||
|
|
||||||
hStack = UIStackView(arrangedSubviews: [
|
hStack = UIStackView(arrangedSubviews: [
|
||||||
|
leadingSpacer,
|
||||||
imageView,
|
imageView,
|
||||||
vStack,
|
vStack,
|
||||||
spacer,
|
trailingSpacer,
|
||||||
])
|
])
|
||||||
hStack.translatesAutoresizingMaskIntoConstraints = false
|
hStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
hStack.axis = .horizontal
|
hStack.axis = .horizontal
|
||||||
|
@ -107,6 +110,7 @@ class StatusCardView: UIView {
|
||||||
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
|
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
placeholderImageView.contentMode = .scaleAspectFit
|
placeholderImageView.contentMode = .scaleAspectFit
|
||||||
placeholderImageView.tintColor = .gray
|
placeholderImageView.tintColor = .gray
|
||||||
|
placeholderImageView.isHidden = true
|
||||||
|
|
||||||
addSubview(placeholderImageView)
|
addSubview(placeholderImageView)
|
||||||
|
|
||||||
|
@ -114,9 +118,10 @@ class StatusCardView: UIView {
|
||||||
imageView.heightAnchor.constraint(equalTo: heightAnchor),
|
imageView.heightAnchor.constraint(equalTo: heightAnchor),
|
||||||
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
|
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
|
||||||
|
|
||||||
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
|
vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8),
|
||||||
|
|
||||||
spacer.widthAnchor.constraint(equalToConstant: 4),
|
leadingSpacer.widthAnchor.constraint(equalToConstant: 4),
|
||||||
|
trailingSpacer.widthAnchor.constraint(equalToConstant: 4),
|
||||||
|
|
||||||
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
@ -128,8 +133,6 @@ class StatusCardView: UIView {
|
||||||
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
|
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
|
||||||
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
|
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
@ -161,10 +164,15 @@ class StatusCardView: UIView {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.imageView.image = nil
|
if let image = card.image {
|
||||||
|
imageView.update(for: URL(image), blurhash: card.blurhash)
|
||||||
updateGrayscaleableUI(card: card)
|
imageView.isHidden = false
|
||||||
updateUIForPreferences()
|
leadingSpacer.isHidden = true
|
||||||
|
} else {
|
||||||
|
imageView.update(for: nil)
|
||||||
|
imageView.isHidden = true
|
||||||
|
leadingSpacer.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
|
@ -182,37 +190,6 @@ class StatusCardView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func updateUIForPreferences() {
|
|
||||||
if isGrayscale != Preferences.shared.grayscaleImages,
|
|
||||||
let card = card {
|
|
||||||
updateGrayscaleableUI(card: card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateGrayscaleableUI(card: Card) {
|
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
|
||||||
|
|
||||||
if let imageURL = card.image {
|
|
||||||
placeholderImageView.isHidden = true
|
|
||||||
|
|
||||||
imageRequest = ImageCache.attachments.get(URL(imageURL)!, completion: { (_, image) in
|
|
||||||
guard let image = image,
|
|
||||||
self.card?.image == imageURL,
|
|
||||||
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(imageURL)!, image: image) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.imageView.image = transformedImage
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if imageRequest != nil {
|
|
||||||
loadBlurHash()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
placeholderImageView.isHidden = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBlurHash() {
|
private func loadBlurHash() {
|
||||||
guard let card = card, let hash = card.blurhash else { return }
|
guard let card = card, let hash = card.blurhash else { return }
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue