Compare commits

...

7 Commits

Author SHA1 Message Date
Shadowfacts cc0da2ec54 Fix user activities not continuing when passed at launch
Fix crash when continuing user activities on iPad
2022-05-13 17:10:18 -04:00
Shadowfacts a2868739c2 Fix crash when poll voting fails 2022-05-13 10:00:11 -04:00
Shadowfacts 2f75510889 Disable transparent nav bar in conversation vc 2022-05-11 19:15:56 -04:00
Shadowfacts 46332cd1b9 Jump to statuses below parent when expanding subthread in conversation 2022-05-11 19:12:28 -04:00
Shadowfacts 21e9ca990d Use async/await for conversation loading 2022-05-11 19:10:38 -04:00
Shadowfacts 1a02319894 Fix using old style for show all statuses bar button item when showing a
conversation that initially expands all statuses
2022-05-11 11:33:18 -04:00
Shadowfacts 4a95ccccdb Show expand thread indicator when there are additional replies to an
intermediate post in thread authored by a single person
2022-05-11 11:20:01 -04:00
24 changed files with 263 additions and 105 deletions

View File

@ -9,6 +9,9 @@
import UIKit import UIKit
import CrashReporter import CrashReporter
import CoreData import CoreData
import OSLog
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -85,7 +88,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
switch type { switch type {
case .mainScene: case .mainScene:
return "main-scene" return "main-scene"
case .showConversation, case .showConversation,
.showTimeline, .showTimeline,
.checkNotifications, .checkNotifications,
@ -93,7 +96,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
.bookmarks, .bookmarks,
.myProfile, .myProfile,
.showProfile: .showProfile:
return "auxiliary" if activity.displaysAuxiliaryScene {
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
return "auxiliary"
} else {
return "main-scene"
}
case .newPost: case .newPost:
return "compose" return "compose"

View File

@ -87,6 +87,16 @@ class MastodonCachePersistentStore: NSPersistentContainer {
self.statusSubject.send(status.id) self.statusSubject.send(status.id)
} }
} }
@MainActor
func addOrUpdateOnViewContext(status: Status) -> StatusMO {
let statusMO = self.upsert(status: status, context: viewContext)
if viewContext.hasChanges {
try! viewContext.save()
}
statusSubject.send(status.id)
return statusMO
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) { func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
@ -98,6 +108,14 @@ class MastodonCachePersistentStore: NSPersistentContainer {
completion?() completion?()
} }
} }
func addAll(statuses: [Status]) async {
return await withCheckedContinuation { continuation in
addAll(statuses: statuses) {
continuation.resume()
}
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? { func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext let context = context ?? viewContext

View File

@ -28,6 +28,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity { if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
launchActivity = activity launchActivity = activity
} }
stateRestorationLogger.info("MainSceneDelegate.launchActivity = \(self.launchActivity?.activityType ?? "nil", privacy: .public)")
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
@ -69,7 +70,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
_ = userActivity.handleResume() stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
} }
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -169,6 +171,11 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
activateAccount(account, animated: false) activateAccount(account, animated: false)
if let activity = launchActivity,
activity.activityType != UserActivityType.mainScene.rawValue {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
}
} else { } else {
window!.rootViewController = createOnboardingUI() window!.rootViewController = createOnboardingUI()
} }

View File

@ -31,6 +31,7 @@ class ConversationTableViewController: EnhancedTableViewController {
let mainStatusID: String let mainStatusID: String
let mainStatusState: StatusState let mainStatusState: StatusState
var statusIDToScrollToOnLoad: String
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>! private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
var showStatusesAutomatically = false var showStatusesAutomatically = false
@ -41,6 +42,7 @@ class ConversationTableViewController: EnhancedTableViewController {
init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) { init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID self.mainStatusID = mainStatusID
self.mainStatusState = state self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(style: .plain) super.init(style: .plain)
@ -115,52 +117,59 @@ class ConversationTableViewController: EnhancedTableViewController {
return cell return cell
case let .expandThread(childThreads: childThreads): case let .expandThread(childThreads: childThreads, inline: inline):
let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell
cell.updateUI(childThreads: childThreads) cell.updateUI(childThreads: childThreads, inline: inline)
return cell return cell
} }
}) })
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage if #available(iOS 15.0, *) {
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed)) visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
visibilityBarButtonItem.isSelected = showStatusesAutomatically
} else {
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
}
navigationItem.rightBarButtonItem = visibilityBarButtonItem navigationItem.rightBarButtonItem = visibilityBarButtonItem
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
loadMainStatus() Task {
await loadMainStatus()
}
} }
private func loadMainStatus() { @MainActor
private func loadMainStatus() async {
guard loadingState == .unloaded else { return } guard loadingState == .unloaded else { return }
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) { if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
self.mainStatusLoaded(mainStatus) await mainStatusLoaded(mainStatus)
} else { } else {
loadingState = .loadingMain loadingState = .loadingMain
let request = Client.getStatus(id: mainStatusID) let req = Client.getStatus(id: mainStatusID)
mastodonController.run(request) { (response) in do {
switch response { let (status, _) = try await mastodonController.run(req)
case let .success(status, _): let statusMO = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
let viewContext = self.mastodonController.persistentContainer.viewContext await mainStatusLoaded(statusMO)
self.mastodonController.persistentContainer.addOrUpdate(status: status, context: viewContext) { (statusMO) in } catch {
self.mainStatusLoaded(statusMO) let error = error as! Client.Error
} loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
case let .failure(error): toast.dismissToast(animated: true)
DispatchQueue.main.async { await self?.loadMainStatus()
self.loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadMainStatus()
}
self.showToast(configuration: config, animated: true)
}
} }
showToast(configuration: config, animated: true)
return
} }
} }
} }
private func mainStatusLoaded(_ mainStatus: StatusMO) { private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
@ -170,10 +179,11 @@ class ConversationTableViewController: EnhancedTableViewController {
loadingState = .loadedMain loadingState = .loadedMain
loadContext(for: mainStatus) await loadContext(for: mainStatus)
} }
private func loadContext(for mainStatus: StatusMO) { @MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard loadingState == .loadedMain else { return } guard loadingState == .loadedMain else { return }
loadingState = .loadingContext loadingState = .loadingContext
@ -183,30 +193,24 @@ class ConversationTableViewController: EnhancedTableViewController {
// todo: it would be nice to cache these contexts // todo: it would be nice to cache these contexts
let request = Status.getContext(mainStatusID) let request = Status.getContext(mainStatusID)
mastodonController.run(request) { response in do {
switch response { let (context, _) = try await mastodonController.run(request)
case let .success(context, _): let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors) let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
// todo: should this really be blindly adding all the descendants?
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs)
} catch {
let error = error as! Client.Error
self.loadingState = .loadedMain
// todo: should this really be blindly adding all the descendants? let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) { toast.dismissToast(animated: true)
DispatchQueue.main.async { await self?.loadContext(for: mainStatus)
self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs)
}
}
case let .failure(error):
DispatchQueue.main.async {
self.loadingState = .loadedMain
let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
self?.loadContext(for: mainStatus)
}
self.showToast(configuration: config, animated: true)
}
} }
self.showToast(configuration: config, animated: true)
} }
} }
@ -230,10 +234,19 @@ class ConversationTableViewController: EnhancedTableViewController {
} }
self.dataSource.apply(snapshot, animatingDifferences: false) { self.dataSource.apply(snapshot, animatingDifferences: false) {
// ensure that the main status is on-screen after newly loaded statuses are added let item: Item
let position: UITableView.ScrollPosition
if self.statusIDToScrollToOnLoad == self.mainStatusID {
item = mainStatusItem
position = .middle
} else {
item = Item.status(id: self.statusIDToScrollToOnLoad, state: .unknown)
position = .top
}
// ensure that the status is on-screen after newly loaded statuses are added
// todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)? // todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)?
if let indexPath = self.dataSource.indexPath(for: mainStatusItem) { if let indexPath = self.dataSource.indexPath(for: item) {
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false) self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
} }
} }
@ -314,8 +327,10 @@ class ConversationTableViewController: EnhancedTableViewController {
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id }) let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
if sameAuthorStatuses.count == 1 { if sameAuthorStatuses.count == 1 {
next = sameAuthorStatuses[0] next = sameAuthorStatuses[0]
let nonSameAuthorChildren = currentNode.children.filter { $0.status.id != sameAuthorStatuses[0].status.id }
snapshot.appendItems([.expandThread(childThreads: nonSameAuthorChildren, inline: true)])
} else { } else {
snapshot.appendItems([.expandThread(childThreads: currentNode.children)]) snapshot.appendItems([.expandThread(childThreads: currentNode.children, inline: false)])
break break
} }
} }
@ -340,9 +355,11 @@ class ConversationTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case .expandThread = dataSource.itemIdentifier(for: indexPath), if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: indexPath),
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
self.selected(status: id, state: state) let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
show(conv)
} else { } else {
super.tableView(tableView, didSelectRowAt: indexPath) super.tableView(tableView, didSelectRowAt: indexPath)
} }
@ -399,14 +416,14 @@ extension ConversationTableViewController {
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, state: StatusState) case status(id: String, state: StatusState)
case expandThread(childThreads: [ConversationNode]) case expandThread(childThreads: [ConversationNode], inline: Bool)
static func == (lhs: Item, rhs: Item) -> Bool { static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)): case let (.status(id: a, state: _), .status(id: b, state: _)):
return a == b return a == b
case let (.expandThread(childThreads: a), .expandThread(childThreads: b)): case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return zip(a, b).allSatisfy { $0.status.id == $1.status.id } return zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
default: default:
return false return false
} }
@ -417,9 +434,10 @@ extension ConversationTableViewController {
case let .status(id: id, state: _): case let .status(id: id, state: _):
hasher.combine("status") hasher.combine("status")
hasher.combine(id) hasher.combine(id)
case let .expandThread(childThreads: children): case let .expandThread(childThreads: children, inline: inline):
hasher.combine("expandThread") hasher.combine("expandThread")
hasher.combine(children.map(\.status.id)) hasher.combine(children.map(\.status.id))
hasher.combine(inline)
} }
} }
} }

View File

@ -10,34 +10,49 @@ import UIKit
class ExpandThreadTableViewCell: UITableViewCell { class ExpandThreadTableViewCell: UITableViewCell {
@IBOutlet weak var stackViewLeadingConstraint: NSLayoutConstraint!
@IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint! @IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint!
@IBOutlet weak var replyCountLabel: UILabel! @IBOutlet weak var replyCountLabel: UILabel!
var avatarImageViews: [UIImageView] = [] private var threadLinkView: UIView!
private var threadLinkViewFullHeightConstraint: NSLayoutConstraint!
private var threadLinkViewShortHeightConstraint: NSLayoutConstraint!
private var avatarImageViews: [UIImageView] = []
private var avatarRequests: [ImageCache.Request] = [] private var avatarRequests: [ImageCache.Request] = []
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
let prevThreadLinkView = UIView() threadLinkView = UIView()
prevThreadLinkView.translatesAutoresizingMaskIntoConstraints = false threadLinkView.translatesAutoresizingMaskIntoConstraints = false
prevThreadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5) threadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5)
prevThreadLinkView.layer.cornerRadius = 2.5 threadLinkView.layer.cornerRadius = 2.5
prevThreadLinkView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] addSubview(threadLinkView)
contentView.addSubview(prevThreadLinkView) threadLinkViewFullHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: bottomAnchor)
threadLinkViewShortHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
prevThreadLinkView.widthAnchor.constraint(equalToConstant: 5), threadLinkView.widthAnchor.constraint(equalToConstant: 5),
prevThreadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 16 + 25), threadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 25 + 16 /* system spacing */),
prevThreadLinkView.topAnchor.constraint(equalTo: topAnchor), threadLinkView.topAnchor.constraint(equalTo: topAnchor),
prevThreadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2), threadLinkViewFullHeightConstraint,
]) ])
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
func updateUI(childThreads: [ConversationNode]) { func updateUI(childThreads: [ConversationNode], inline: Bool) {
let format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label") stackViewLeadingConstraint.constant = inline ? 50 + 4 : 0
threadLinkView.layer.maskedCorners = inline ? [] : [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
threadLinkViewFullHeightConstraint.isActive = inline
threadLinkViewShortHeightConstraint.isActive = !inline
let format: String
if inline {
format = NSLocalizedString("expand threads inline count", comment: "expnad converstaion threads inline button label")
} else {
format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label")
}
replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count) replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count)
let accounts = childThreads.map(\.status.account).uniques().prefix(3) let accounts = childThreads.map(\.status.account).uniques().prefix(3)

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -62,6 +62,7 @@
<outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/> <outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/>
<outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/> <outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/>
<outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/> <outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/>
<outlet property="stackViewLeadingConstraint" destination="iD5-Av-ORS" id="Try-cG-8uA"/>
</connections> </connections>
<point key="canvasLocation" x="132" y="132"/> <point key="canvasLocation" x="132" y="132"/>
</tableViewCell> </tableViewCell>

View File

@ -123,6 +123,7 @@ extension DraftsTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let draft = self.draft(for: indexPath) let draft = self.draft(for: indexPath)
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id) let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id)
activity.displaysAuxiliaryScene = true
let provider = NSItemProvider(object: activity) let provider = NSItemProvider(object: activity)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -574,13 +574,17 @@ extension ExploreViewController: UICollectionViewDragDelegate {
let provider: NSItemProvider let provider: NSItemProvider
switch item { switch item {
case .bookmarks: case .bookmarks:
provider = NSItemProvider(object: UserActivityManager.bookmarksActivity()) let activity = UserActivityManager.bookmarksActivity()
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .list(list): case let .list(list):
guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] } guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] }
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity) provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
provider = NSItemProvider(object: hashtag.url as NSURL) provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
} }
case let .savedInstance(url): case let .savedInstance(url):

View File

@ -183,6 +183,7 @@ extension ProfileDirectoryViewController: UICollectionViewDragDelegate {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -90,6 +90,7 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
} }
let provider = NSItemProvider(object: hashtag.url as NSURL) let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
} }
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]

View File

@ -19,6 +19,11 @@ class MainSidebarViewController: UIViewController {
private weak var mastodonController: MastodonController! private weak var mastodonController: MastodonController!
weak var sidebarDelegate: MainSidebarViewControllerDelegate? weak var sidebarDelegate: MainSidebarViewControllerDelegate?
var onViewDidLoad: (() -> Void)? = nil {
willSet {
precondition(onViewDidLoad == nil)
}
}
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -95,6 +100,8 @@ class MainSidebarViewController: UIViewController {
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
onViewDidLoad?()
} }
func select(item: Item, animated: Bool) { func select(item: Item, animated: Bool) {
@ -553,6 +560,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
collectionView.contextMenuInteraction?.menuAppearance == .rich { collectionView.contextMenuInteraction?.menuAppearance == .rich {
return nil return nil
} }
activity.displaysAuxiliaryScene = true
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
return UIMenu(children: [ return UIMenu(children: [
UIWindowScene.ActivationAction({ action in UIWindowScene.ActivationAction({ action in
@ -573,6 +581,7 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
return [] return []
} }
activity.displaysAuxiliaryScene = true
let provider = NSItemProvider(object: activity) let provider = NSItemProvider(object: activity)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -59,10 +59,15 @@ class MainSplitViewController: UISplitViewController {
switcher.itemOrientation = .iconsLeading switcher.itemOrientation = .iconsLeading
switcher.view.translatesAutoresizingMaskIntoConstraints = false switcher.view.translatesAutoresizingMaskIntoConstraints = false
switcher.delegate = self switcher.delegate = self
sidebar.view.addGestureRecognizer(switcher.createSwitcherGesture()) // accessing .view unconditionally loads the view, which we don't want to happen
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped)) // because the sidebar view not being loaded is how we know not to transfer nav state
tapRecognizer.cancelsTouchesInView = false // in splitViewControllerDidCollapse on devices where the sidebar is never shown
sidebar.view.addGestureRecognizer(tapRecognizer) sidebar.onViewDidLoad = { [unowned self] in
self.sidebar.view.addGestureRecognizer(switcher.createSwitcherGesture())
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped))
tapRecognizer.cancelsTouchesInView = false
self.sidebar.view.addGestureRecognizer(tapRecognizer)
}
} }
tabBarViewController = MainTabBarViewController(mastodonController: mastodonController) tabBarViewController = MainTabBarViewController(mastodonController: mastodonController)

View File

@ -42,6 +42,8 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
stateRestorationLogger.info("MainTabBarViewController: viewDidLoad, selectedIndex=\(self.selectedIndex, privacy: .public)")
self.delegate = self self.delegate = self
@ -75,6 +77,16 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
tabBar.isSpringLoaded = true tabBar.isSpringLoaded = true
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewWillAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
}
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
@ -240,6 +252,7 @@ extension MainTabBarViewController: TuskerRootViewController {
self.selectedIndex = tab.rawValue self.selectedIndex = tab.rawValue
} }
} else { } else {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
selectedIndex = tab.rawValue selectedIndex = tab.rawValue
} }
} }

View File

@ -10,13 +10,25 @@ import Foundation
extension NSUserActivity { extension NSUserActivity {
var displaysAuxiliaryScene: Bool {
get {
(userInfo?["displaysAuxiliaryScene"] as? Bool) ?? false
}
set {
if userInfo == nil {
userInfo = [:]
}
userInfo!["displaysAuxiliaryScene"] = newValue
}
}
convenience init(type: UserActivityType) { convenience init(type: UserActivityType) {
self.init(activityType: type.rawValue) self.init(activityType: type.rawValue)
} }
func handleResume() -> 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(self) type.handle(manager)(self)
return true return true
} }

View File

@ -9,25 +9,32 @@
import UIKit import UIKit
import Intents import Intents
import Pachyderm import Pachyderm
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
class UserActivityManager { class UserActivityManager {
private let scene: UIWindowScene
init(scene: UIWindowScene) {
self.scene = scene
}
// MARK: - Utils // MARK: - Utils
private static let encoder = PropertyListEncoder() private static let encoder = PropertyListEncoder()
private static let decoder = PropertyListDecoder() private static let decoder = PropertyListDecoder()
private static var mastodonController: MastodonController { private var mastodonController: MastodonController {
let scene = UIApplication.shared.activeOrBackgroundScene! scene.session.mastodonController!
return scene.session.mastodonController!
} }
private static func getMainViewController() -> TuskerRootViewController { private func getMainViewController() -> TuskerRootViewController {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! let window = scene.windows.first { $0.isKeyWindow } ?? scene.windows.first!
let window = scene.windows.first { $0.isKeyWindow }!
return window.rootViewController as! TuskerRootViewController return window.rootViewController as! TuskerRootViewController
} }
private static func present(_ vc: UIViewController, animated: Bool = true) { private func present(_ vc: UIViewController, animated: Bool = true) {
getMainViewController().present(vc, animated: animated) getMainViewController().present(vc, animated: animated)
} }
@ -66,7 +73,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleNewPost(activity: NSUserActivity) { func handleNewPost(activity: NSUserActivity) {
// TODO: check not currently showing compose screen // TODO: check not currently showing compose screen
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)
@ -111,14 +118,14 @@ class UserActivityManager {
return activity return activity
} }
static func handleCheckNotifications(activity: NSUserActivity) { func handleCheckNotifications(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .notifications) mainViewController.select(tab: .notifications)
if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController, if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController,
let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController { let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController {
navigationController.popToRootViewController(animated: false) navigationController.popToRootViewController(animated: false)
notificationsPageController.loadViewIfNeeded() notificationsPageController.loadViewIfNeeded()
notificationsPageController.selectMode(getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode) notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
} }
} }
@ -168,11 +175,11 @@ class UserActivityManager {
let data = activity.userInfo?["timelineData"] as? Data else { let data = activity.userInfo?["timelineData"] as? Data else {
return nil return nil
} }
return try? decoder.decode(Timeline.self, from: data) return try? UserActivityManager.decoder.decode(Timeline.self, from: data)
} }
static func handleShowTimeline(activity: NSUserActivity) { func handleShowTimeline(activity: NSUserActivity) {
guard let timeline = getTimeline(from: activity) else { return } guard let timeline = Self.getTimeline(from: activity) else { return }
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .timelines) mainViewController.select(tab: .timelines)
@ -228,7 +235,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleSearch(activity: NSUserActivity) { func handleSearch(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .explore) mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController, if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController,
@ -247,7 +254,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleBookmarks(activity: NSUserActivity) { func handleBookmarks(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .explore) mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController { if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController {
@ -265,7 +272,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleMyProfile(activity: NSUserActivity) { func handleMyProfile(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .myProfile) mainViewController.select(tab: .myProfile)
} }

View File

@ -21,7 +21,7 @@ enum UserActivityType: String {
} }
extension UserActivityType { extension UserActivityType {
var handle: (NSUserActivity) -> Void { var handle: (UserActivityManager) -> (NSUserActivity) -> Void {
switch self { switch self {
case .mainScene: case .mainScene:
fatalError("cannot handle main scene activity") fatalError("cannot handle main scene activity")

View File

@ -115,6 +115,7 @@ extension AccountTableViewCell: DraggableTableViewCell {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -223,6 +223,7 @@ extension FollowNotificationGroupTableViewCell: DraggableTableViewCell {
let notification = group.notifications[0] let notification = group.notifications[0]
let provider = NSItemProvider(object: notification.account.url as NSURL) let provider = NSItemProvider(object: notification.account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id) let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -206,6 +206,7 @@ extension FollowRequestNotificationTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] { func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -21,6 +21,7 @@ class StatusPollView: UIView {
}() }()
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
weak var toastableViewController: ToastableViewController?
private var statusID: String! private var statusID: String!
private(set) var poll: Poll? private(set) var poll: Poll?
@ -151,7 +152,14 @@ class StatusPollView: UIView {
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
switch response { switch response {
case let .failure(error): case let .failure(error):
fatalError("error voting in poll: \(error)") DispatchQueue.main.async {
self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll)
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error Voting", in: toastable, retryAction: nil)
toastable.showToast(configuration: config, animated: true)
}
}
case let .success(poll, _): case let .success(poll, _):
let container = self.mastodonController.persistentContainer let container = self.mastodonController.persistentContainer

View File

@ -211,6 +211,7 @@ class BaseStatusTableViewCell: UITableViewCell {
pollView.isHidden = status.poll == nil pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate?.toastableViewController
pollView.updateUI(status: status, poll: status.poll) pollView.updateUI(status: status, poll: status.poll)
} }
@ -530,6 +531,7 @@ extension BaseStatusTableViewCell: UIDragInteractionDelegate {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -356,6 +356,7 @@ extension TimelineStatusTableViewCell: DraggableTableViewCell {
} }
let provider = NSItemProvider(object: status.url! as NSURL) let provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -57,6 +57,14 @@ extension ToastConfiguration {
viewController.present(reporter, animated: true) viewController.present(reporter, animated: true)
} }
} }
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
self.init(from: error, with: title, in: viewController) { toast in
Task {
await retryAction(toast)
}
}
}
} }
fileprivate extension Client.Error { fileprivate extension Client.Error {

View File

@ -61,6 +61,22 @@
<string>%u replies</string> <string>%u replies</string>
</dict> </dict>
</dict> </dict>
<key>expand threads inline count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@replies@</string>
<key>replies</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>u</string>
<key>one</key>
<string>1 more reply</string>
<key>other</key>
<string>%u more replies</string>
</dict>
</dict>
<key>favorites count</key> <key>favorites count</key>
<dict> <dict>
<key>NSStringLocalizedFormatKey</key> <key>NSStringLocalizedFormatKey</key>