diff --git a/Artwork/Tusker no shadow.svg b/Artwork/Tusker no shadow.svg
new file mode 100644
index 000000000..3bca8d5fe
--- /dev/null
+++ b/Artwork/Tusker no shadow.svg
@@ -0,0 +1,157 @@
+
+
diff --git a/Artwork/Tusker transparent.svg b/Artwork/Tusker transparent.svg
new file mode 100644
index 000000000..bf0925165
--- /dev/null
+++ b/Artwork/Tusker transparent.svg
@@ -0,0 +1,153 @@
+
+
diff --git a/Artwork/Tusker.svg b/Artwork/Tusker.svg
new file mode 100644
index 000000000..dd4c3f12c
--- /dev/null
+++ b/Artwork/Tusker.svg
@@ -0,0 +1,162 @@
+
+
diff --git a/CHANGELOG-release.md b/CHANGELOG-release.md
index ff7729b1d..e5a02a67a 100644
--- a/CHANGELOG-release.md
+++ b/CHANGELOG-release.md
@@ -1,3 +1,18 @@
+## 2024.4
+This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
+
+Features/Improvements:
+- Import image description when adding attachments from Photos if possible
+- iPadOS 18: New floating sidebar/tab bar
+
+Bugfixes:
+- Fix crash when viewing profiles in certain circumstances
+- Fix video controls in attachment gallery not auto-hiding
+- Fix crash if hashtag search results includes duplicates
+- Fix "no content" text not being removed from list timeline after refreshing
+- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
+- macOS: Fix reselecting current item not navigating back
+
## 2024.3
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdc0161c0..7e2b99ab0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## 2024.4 (136)
+Features/Improvements:
+- Import image description when adding attachments from Photos if possible
+- Reorganize toolbar buttons when adding saved hashtag
+- Show errors when loading video in attachment gallery fails
+
+Bugfixes:
+- Fix crash when viewing profiles in certain circumstances
+- Fix profile tab switching animation getting stuck
+- Fix video controls in attachment gallery not auto-hiding
+- Pleroma: Fix error when loading polls in some circumstances
+- iPadOS 18: Fix incorrect two-column layout when closing sidebar
+- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
+- macOS: Fix reselecting current item not navigating back
+
+## 2024.4 (135)
+Features/Improvements:
+- iOS 18: New floating sidebar/tab bar
+
+Bugfixes:
+- Fix crash when hashtag search results include duplicates
+- Fix "no content" text not being removed from list timeline after refreshing
+
## 2024.3 (133)
- Add additional info to Tip Jar
diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift
index c33f72563..6270a3358 100644
--- a/NotificationExtension/NotificationService.swift
+++ b/NotificationExtension/NotificationService.swift
@@ -15,9 +15,13 @@ import Pachyderm
import Intents
import HTMLStreamer
import WebURL
+import UIKit
+import TuskerPreferences
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
+private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
+
class NotificationService: UNNotificationServiceExtension {
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
@@ -225,8 +229,33 @@ class NotificationService: UNNotificationServiceExtension {
}
let updatedContent: UNMutableNotificationContent
+
+ let contentProviding: any UNNotificationContentProviding
+ if #available(iOS 18.0, visionOS 2.0, *),
+ await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
+ let attributedString = NSMutableAttributedString(string: content.body)
+
+ for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
+ let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
+ guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
+ let url = URL(emoji.url),
+ let (data, _) = try? await URLSession.shared.data(from: url),
+ let image = UIImage(data: data) else {
+ continue
+ }
+ let attachment = NSTextAttachment(image: image)
+ let attachmentStr = NSAttributedString(attachment: attachment)
+ attributedString.replaceCharacters(in: match.range, with: attachmentStr)
+ }
+
+ let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
+ contentProviding = attributedCtx
+ } else {
+ contentProviding = intent
+ }
+
do {
- let newContent = try content.updating(from: intent)
+ let newContent = try content.updating(from: contentProviding)
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
pendingRequest?.0 = newMutableContent
updatedContent = newMutableContent
diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift
index 64ecf9459..d71fb082f 100644
--- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift
+++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift
@@ -167,11 +167,23 @@ extension DraftAttachment: NSItemProviderReading {
type = .png
}
+ // Read the caption from the image itself, if there is one.
+ let caption: String
+ if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
+ // This is the dictionary for TIFF properties, but it's present for other image types too
+ let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
+ let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
+ caption = imageDescription
+ } else {
+ caption = ""
+ }
+
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
attachment.fileType = type.identifier
- attachment.attachmentDescription = ""
+ attachment.attachmentDescription = caption
return attachment
}
diff --git a/Packages/Duckable/Sources/Duckable/API.swift b/Packages/Duckable/Sources/Duckable/API.swift
index 86b682fea..4f102149f 100644
--- a/Packages/Duckable/Sources/Duckable/API.swift
+++ b/Packages/Duckable/Sources/Duckable/API.swift
@@ -33,11 +33,11 @@ public enum DuckAttemptAction {
extension UIViewController {
@available(iOS 16.0, *)
- public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
+ public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
var cur: UIViewController? = self
while let vc = cur {
if let container = vc as? DuckableContainerViewController {
- container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
+ container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
return true
} else {
cur = vc.parent
diff --git a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift
index 705ab3da5..adc0ca805 100644
--- a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift
+++ b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift
@@ -58,7 +58,7 @@ public class DuckableContainerViewController: UIViewController {
])
}
- func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
+ func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift
index a20dfacd7..e2bbbfa58 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryContentViewController.swift
@@ -17,7 +17,7 @@ public protocol GalleryContentViewController: UIViewController {
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
- func setControlsVisible(_ visible: Bool, animated: Bool)
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func galleryContentDidAppear()
func galleryContentWillDisappear()
}
@@ -35,7 +35,7 @@ public extension GalleryContentViewController {
true
}
- func setControlsVisible(_ visible: Bool, animated: Bool) {
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
}
func galleryContentDidAppear() {
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift
index 10f5b5416..df960cb13 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissAnimationController.swift
@@ -106,7 +106,7 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
- itemViewController.setControlsVisible(false, animated: false)
+ itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
animator.addCompletion { _ in
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift
index f49b96218..e7d1ee364 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryDismissInteraction.swift
@@ -42,7 +42,7 @@ class GalleryDismissInteraction: NSObject {
origControlsVisible = viewController.currentItemViewController.controlsVisible
if origControlsVisible! {
- viewController.currentItemViewController.setControlsVisible(false, animated: true)
+ viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
}
case .changed:
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift
index 5578e6234..b41f73975 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift
@@ -81,10 +81,10 @@ class GalleryItemViewController: UIViewController {
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([
- overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
- overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
- overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
- overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
+ overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
+ overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
@@ -213,7 +213,7 @@ class GalleryItemViewController: UIViewController {
updateZoomScale(resetZoom: false)
// Ensure the transform is correct if the controls are hidden
- setControlsVisible(controlsVisible, animated: false)
+ setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
updateTopControlsInsets()
}
@@ -229,7 +229,7 @@ class GalleryItemViewController: UIViewController {
}
centerContent()
// Ensure the transform is correct if the controls are hidden and their size changed.
- setControlsVisible(controlsVisible, animated: false)
+ setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
}
override func viewDidAppear(_ animated: Bool) {
@@ -250,7 +250,7 @@ class GalleryItemViewController: UIViewController {
func addContent() {
content.loadViewIfNeeded()
- content.setControlsVisible(controlsVisible, animated: false)
+ content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
content.view.translatesAutoresizingMaskIntoConstraints = false
if content.parent != self {
@@ -290,7 +290,7 @@ class GalleryItemViewController: UIViewController {
content.view.layoutIfNeeded()
}
- func setControlsVisible(_ visible: Bool, animated: Bool) {
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
controlsVisible = visible
guard let topControlsView,
@@ -301,7 +301,7 @@ class GalleryItemViewController: UIViewController {
func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
- content.setControlsVisible(visible, animated: animated)
+ content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
@@ -378,9 +378,6 @@ class GalleryItemViewController: UIViewController {
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini
]
- let islandDeviceTopInsets: [CGFloat] = [
- 59, // iPhone 14 Pro, 14 Pro Max, 15 Pro, 15 Pro Max
- ]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
@@ -390,16 +387,18 @@ class GalleryItemViewController: UIViewController {
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
- } else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) {
- shareButtonLeadingConstraint.constant = 24
- shareButtonTopConstraint.constant = 24
- closeButtonTrailingConstraint.constant = 24
- closeButtonTopConstraint.constant = 24
- } else {
+ } else if view.safeAreaInsets.top == 0 {
+ // square corner devices
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
closeButtonTrailingConstraint.constant = 8
closeButtonTopConstraint.constant = 8
+ } else {
+ // dynamic island devices
+ shareButtonLeadingConstraint.constant = 24
+ shareButtonTopConstraint.constant = 24
+ closeButtonTrailingConstraint.constant = 24
+ closeButtonTopConstraint.constant = 24
}
}
@@ -429,7 +428,7 @@ class GalleryItemViewController: UIViewController {
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
- setControlsVisible(!controlsVisible, animated: true)
+ setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
}
}
@@ -531,7 +530,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
}
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
- setControlsVisible(visible, animated: animated)
+ setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
}
}
@@ -546,9 +545,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
- setControlsVisible(true, animated: true)
+ setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else {
- setControlsVisible(false, animated: true)
+ setControlsVisible(false, animated: true, dueToUserInteraction: true)
}
centerContent()
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift
index 1a58ec06d..ebd26f72f 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryPresentationAnimationController.swift
@@ -75,7 +75,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
container.layoutIfNeeded()
// This needs to take place after the layout, so that the transform is correct.
- itemViewController.setControlsVisible(false, animated: false)
+ itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3
@@ -90,7 +90,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
content.view.frame = destFrameInContainer
content.view.layer.opacity = 1
- itemViewController.setControlsVisible(true, animated: false)
+ itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform {
self.sourceView.transform = sourceToDestTransform
diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift
index e53e90ea9..8013c3fa7 100644
--- a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift
+++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift
@@ -126,7 +126,7 @@ extension GalleryViewController: UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
currentItemViewController.content.galleryContentWillDisappear()
let new = pendingViewControllers[0] as! GalleryItemViewController
- new.setControlsVisible(currentItemViewController.controlsVisible, animated: false)
+ new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Poll.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Poll.swift
index d1c7a6638..29d46bd52 100644
--- a/Packages/Pachyderm/Sources/Pachyderm/Model/Poll.swift
+++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Poll.swift
@@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
public let votesCount: Int
public let votersCount: Int?
public let voted: Bool?
- public let ownVotes: [Int]?
+ public let ownVotes: [Int?]?
public let options: [Option]
public let emojis: [Emoji]
@@ -33,7 +33,7 @@ public struct Poll: Codable, Sendable {
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
- self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
+ self.ownVotes = try container.decodeIfPresent([Int?].self, forKey: .ownVotes)
self.options = try container.decode([Poll.Option].self, forKey: .options)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
}
diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift
index 50431cc58..fd7845d5e 100644
--- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift
+++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift
@@ -10,4 +10,5 @@ import Foundation
public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation"
case composeRewrite = "compose-rewrite"
+ case pushNotifCustomEmoji = "push-notif-custom-emoji"
}
diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj
index 5151e0655..e9b86e8a3 100644
--- a/Tusker.xcodeproj/project.pbxproj
+++ b/Tusker.xcodeproj/project.pbxproj
@@ -77,6 +77,7 @@
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
+ D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D62220462C7EA8DF003E43B7 /* TuskerPreferences */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A5402635FB3C0095BD04 /* PollOptionView.swift */; };
@@ -129,6 +130,10 @@
D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */; };
D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */; };
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */; };
+ D64A50462C739DC0009D7193 /* NewMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */; };
+ D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */; };
+ D64A50BC2C74F8F4009D7193 /* FindInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50BB2C74F8F4009D7193 /* FindInstanceViewController.swift */; };
+ D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50BD2C752247009D7193 /* AdaptableNavigationController.swift */; };
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
@@ -161,7 +166,6 @@
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
- D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
@@ -215,15 +219,12 @@
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */; };
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
- D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
- D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; };
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */; };
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */; };
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */; };
- D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */; };
D6958F3D2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */; };
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */; };
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
@@ -560,6 +561,10 @@
D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationCollectionViewCell.swift; sourceTree = ""; };
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedNotificationCollectionViewCell.swift; sourceTree = ""; };
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationCollectionViewCell.swift; sourceTree = ""; };
+ D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMainTabBarViewController.swift; sourceTree = ""; };
+ D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseMainTabBarViewController.swift; sourceTree = ""; };
+ D64A50BB2C74F8F4009D7193 /* FindInstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInstanceViewController.swift; sourceTree = ""; };
+ D64A50BD2C752247009D7193 /* AdaptableNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptableNavigationController.swift; sourceTree = ""; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; };
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = ""; };
@@ -594,7 +599,6 @@
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusStateResolver.swift; sourceTree = ""; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = ""; };
- D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = ""; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = ""; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = ""; };
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = ""; };
@@ -649,15 +653,12 @@
D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = ""; };
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = ""; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; };
- D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; };
- D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = ""; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = ""; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = ""; };
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = ""; };
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; };
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSavedHashtagViewController.swift; sourceTree = ""; };
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTimelineViewController.swift; sourceTree = ""; };
- D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FindInstanceViewController.swift; path = Tusker/Screens/FindInstanceViewController.swift; sourceTree = SOURCE_ROOT; };
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidescreenNavigationPrefsView.swift; sourceTree = ""; };
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowSceneDelegate+Close.swift"; sourceTree = ""; };
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = ""; };
@@ -828,6 +829,7 @@
buildActionMask = 2147483647;
files = (
D630C4252BC7845800208903 /* WebURL in Frameworks */,
+ D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */,
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */,
@@ -977,7 +979,7 @@
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
- D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
+ D64A50BB2C74F8F4009D7193 /* FindInstanceViewController.swift */,
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */,
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */,
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */,
@@ -987,8 +989,6 @@
D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */,
D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */,
D6BC74852AFC4772000DD603 /* SuggestedProfileCardView.swift */,
- D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
- D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */,
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */,
@@ -1124,7 +1124,9 @@
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */,
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
+ D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
+ D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */,
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */,
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */,
@@ -1322,7 +1324,6 @@
D667E5F62135C2ED0057A976 /* Extensions */ = {
isa = PBXGroup;
children = (
- D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */,
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */,
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */,
D6333B362137838300CE884A /* AttributedString+Helpers.swift */,
@@ -1556,6 +1557,7 @@
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
+ D64A50BD2C752247009D7193 /* AdaptableNavigationController.swift */,
);
path = Utilities;
sourceTree = "";
@@ -1795,6 +1797,7 @@
D630C3E42BC6313400208903 /* Pachyderm */,
D630C4222BC7842C00208903 /* HTMLStreamer */,
D630C4242BC7845800208903 /* WebURL */,
+ D62220462C7EA8DF003E43B7 /* TuskerPreferences */,
);
productName = NotificationExtension;
productReference = D630C3D12BC61B6000208903 /* NotificationExtension.appex */;
@@ -2014,7 +2017,6 @@
buildActionMask = 2147483647;
files = (
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
- D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D691296E2BA75ADF005C58ED /* PrivacyInfo.xcprivacy in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
@@ -2141,6 +2143,7 @@
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
+ D64A50462C739DC0009D7193 /* NewMainTabBarViewController.swift in Sources */,
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
@@ -2193,6 +2196,7 @@
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
+ D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */,
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
@@ -2212,7 +2216,6 @@
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */,
- D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameView.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
@@ -2233,6 +2236,7 @@
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
+ D64A50BC2C74F8F4009D7193 /* FindInstanceViewController.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
@@ -2252,7 +2256,6 @@
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */,
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
- D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
@@ -2272,7 +2275,6 @@
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
- D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
@@ -2341,6 +2343,7 @@
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
+ D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
@@ -3299,6 +3302,10 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
+ D62220462C7EA8DF003E43B7 /* TuskerPreferences */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = TuskerPreferences;
+ };
D630C3C72BC43AFD00208903 /* PushNotifications */ = {
isa = XCSwiftPackageProductDependency;
productName = PushNotifications;
diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift
index 8810bf138..5778348b8 100644
--- a/Tusker/AppDelegate.swift
+++ b/Tusker/AppDelegate.swift
@@ -292,12 +292,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
let rootViewController = delegate.rootViewController {
let mastodonController = MastodonController.getForAccount(account)
- // if the scene is already active, then we animate the account switching if necessary
- delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
+ // if the scene is already active, then we animate things
+ let animated = scene.activationState == .foregroundActive
- rootViewController.select(route: .notifications, animated: false) {
+ delegate.activateAccount(account, animated: animated)
+
+ rootViewController.runNavigation(animated: animated) { navigation in
+ navigation.select(route: .notifications)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
- rootViewController.getNavigationController().pushViewController(vc, animated: false)
+ navigation.push(viewController: vc)
}
} else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024-dark@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024-dark@1x.png
new file mode 100644
index 000000000..eb7553157
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024-dark@1x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png
index adf6a10e7..2366e8ac7 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@2x.png
new file mode 100644
index 000000000..58bd8159c
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@3x.png
new file mode 100644
index 000000000..66fdd1295
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@1x.png
deleted file mode 100644
index 04c4c0f89..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@1x.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x-1.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x-1.png
deleted file mode 100644
index f371b08b4..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x-1.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x.png
index f371b08b4..5c0a91984 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@3x.png
index 42a422e90..3b4699173 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@3x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/20x20@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@2x.png
new file mode 100644
index 000000000..b022575e2
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@3x.png
new file mode 100644
index 000000000..cf0b249c2
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@1x.png
deleted file mode 100644
index 92da03337..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@1x.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x-1.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x-1.png
deleted file mode 100644
index 68a6e1bea..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x-1.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x.png
index 68a6e1bea..3debd78e9 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@3x.png
index acb8136e0..cebc387b4 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@3x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/29x29@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@2x.png
new file mode 100644
index 000000000..d03ef0b1c
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@3x.png
new file mode 100644
index 000000000..0ba067a6d
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@2x.png
new file mode 100644
index 000000000..2bd5d75a7
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@3x.png
new file mode 100644
index 000000000..3ab4a3416
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/38x38@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@2x.png
new file mode 100644
index 000000000..446031195
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@3x.png
new file mode 100644
index 000000000..7c22141b1
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@1x.png
deleted file mode 100644
index f371b08b4..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@1x.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x-1.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x-1.png
deleted file mode 100644
index 772f73bda..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x-1.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x.png
index 772f73bda..b0985b15f 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x 1.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x 1.png
new file mode 100644
index 000000000..ab34ae95c
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x 1.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x.png
index b266c20e6..ab34ae95c 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/40x40@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@2x.png
new file mode 100644
index 000000000..7c22141b1
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@3x.png
new file mode 100644
index 000000000..a13c22797
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@2x.png
deleted file mode 100644
index b266c20e6..000000000
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@2x.png and /dev/null differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@3x.png
index 2e912cc16..2baed2811 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@3x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/60x60@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@2x.png
new file mode 100644
index 000000000..08816920a
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@3x.png
new file mode 100644
index 000000000..5c23d5bc9
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64-dark@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@2x.png
new file mode 100644
index 000000000..407d4c2ab
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@3x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@3x.png
new file mode 100644
index 000000000..a504cff97
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/64x64@3x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/68x68-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/68x68-dark@2x.png
new file mode 100644
index 000000000..160250db0
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/68x68-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/68x68@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/68x68@2x.png
new file mode 100644
index 000000000..b03df9525
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/68x68@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/76x76-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/76x76-dark@2x.png
new file mode 100644
index 000000000..7c4ae67a7
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/76x76-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/76x76@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/76x76@2x.png
index 355a31e77..8b40d4257 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/76x76@2x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/76x76@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5-dark@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5-dark@2x.png
new file mode 100644
index 000000000..1b3843ec1
Binary files /dev/null and b/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5-dark@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5@2x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5@2x.png
index 3dbad7c19..d3eff1738 100644
Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5@2x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/83.5x83.5@2x.png differ
diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tusker/Assets.xcassets/AppIcon.appiconset/Contents.json
index 6624667a5..36a8e7aa1 100644
--- a/Tusker/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Tusker/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -2,109 +2,511 @@
"images" : [
{
"filename" : "20x20@2x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "20x20@3x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "29x29@2x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "29x29@3x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "3x",
"size" : "29x29"
},
+ {
+ "filename" : "38x38@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "38x38"
+ },
+ {
+ "filename" : "38x38@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "38x38"
+ },
{
"filename" : "40x40@2x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "40x40@3x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "3x",
"size" : "40x40"
},
{
- "filename" : "60x60@2x.png",
- "idiom" : "iphone",
+ "filename" : "40x40@3x 1.png",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "60x60@3x.png",
- "idiom" : "iphone",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "3x",
"size" : "60x60"
},
{
- "filename" : "20x20@1x.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "20x20"
- },
- {
- "filename" : "20x20@2x-1.png",
- "idiom" : "ipad",
+ "filename" : "64x64@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
- "size" : "20x20"
+ "size" : "64x64"
},
{
- "filename" : "29x29@1x.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "29x29"
+ "filename" : "64x64@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "64x64"
},
{
- "filename" : "29x29@2x-1.png",
- "idiom" : "ipad",
+ "filename" : "68x68@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
- "size" : "29x29"
- },
- {
- "filename" : "40x40@1x.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "40x40"
- },
- {
- "filename" : "40x40@2x-1.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "40x40"
- },
- {
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "76x76"
+ "size" : "68x68"
},
{
"filename" : "76x76@2x.png",
- "idiom" : "ipad",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "83.5x83.5@2x.png",
- "idiom" : "ipad",
+ "idiom" : "universal",
+ "platform" : "ios",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "1024x1024@1x.png",
- "idiom" : "ios-marketing",
- "scale" : "1x",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "20x20-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "20x20-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "29x29-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "29x29-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "38x38-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "38x38"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "38x38-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "38x38"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "40x40-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "40x40-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "60x60-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "60x60-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "64x64-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "64x64"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "64x64-dark@3x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "64x64"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "68x68-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "68x68"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "76x76-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "83.5x83.5-dark@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "1024x1024-dark@1x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "38x38"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "38x38"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "64x64"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "3x",
+ "size" : "64x64"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "68x68"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
"size" : "1024x1024"
}
],
diff --git a/Tusker/Box.swift b/Tusker/Box.swift
index d1dce9f6d..f39690d63 100644
--- a/Tusker/Box.swift
+++ b/Tusker/Box.swift
@@ -9,10 +9,14 @@
import Foundation
@propertyWrapper
-class Box {
+final class Box {
var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
+
+ var projectedValue: Box {
+ self
+ }
}
diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift
index 44fd8bc93..0c9746f35 100644
--- a/Tusker/CoreData/MastodonCachePersistentStore.swift
+++ b/Tusker/CoreData/MastodonCachePersistentStore.swift
@@ -19,7 +19,7 @@ import UserAccounts
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
-class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
+class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Sendable {
private let accountInfo: UserAccountInfo?
diff --git a/Tusker/Extensions/Mastodon+Equatable.swift b/Tusker/Extensions/Mastodon+Equatable.swift
deleted file mode 100644
index 3ee2f750c..000000000
--- a/Tusker/Extensions/Mastodon+Equatable.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// Status+Equatable.swift
-// Tusker
-//
-// Created by Shadowfacts on 8/28/18.
-// Copyright © 2018 Shadowfacts. All rights reserved.
-//
-
-import Pachyderm
-
-extension Status: Equatable {
- public static func ==(lhs: Status, rhs: Status) -> Bool {
- return lhs.id == rhs.id
- }
-}
-
-extension Account: Equatable {
- public static func ==(lhs: Account, rhs: Account) -> Bool {
- return lhs.id == rhs.id
- }
-}
diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift
index 7b0966a7c..31e5b6678 100644
--- a/Tusker/Scenes/MainSceneDelegate.swift
+++ b/Tusker/Scenes/MainSceneDelegate.swift
@@ -63,7 +63,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
let draft = mastodonController.createDraft()
let text = components.queryItems?.first(where: { $0.name == "text" })?.value
draft.text = text ?? ""
- rootViewController.compose(editing: draft, animated: true, isDucked: false)
+ rootViewController.compose(editing: draft, animated: true, isDucked: false, completion: nil)
}
} else {
// Assume anything else is a search query
@@ -83,9 +83,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
}
- Task(priority: .userInitiated) {
- _ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
- }
+ _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@@ -193,10 +191,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
- Task(priority: .userInitiated) {
- _ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
- context.finalize(activity: activity)
- }
+ _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
+ context.finalize(activity: activity)
}
if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
@@ -266,15 +262,24 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
mastodonController.initialize()
#if os(visionOS)
- return MainTabBarViewController(mastodonController: mastodonController)
+ if #available(visionOS 2.0, *) {
+ return NewMainTabBarViewController(mastodonController: mastodonController)
+ } else {
+ return MainTabBarViewController(mastodonController: mastodonController)
+ }
#else
- let split = MainSplitViewController(mastodonController: mastodonController)
+ let mainVC: UIViewController & AccountSwitchableViewController
+ if #available(iOS 18.0, *) {
+ mainVC = NewMainTabBarViewController(mastodonController: mastodonController)
+ } else {
+ mainVC = MainSplitViewController(mastodonController: mastodonController)
+ }
if UIDevice.current.userInterfaceIdiom == .phone,
#available(iOS 16.0, *) {
// TODO: maybe the duckable container should be outside the account switching container
- return DuckableContainerViewController(child: split)
+ return DuckableContainerViewController(child: mainVC)
} else {
- return split
+ return mainVC
}
#endif
}
diff --git a/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift b/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift
index 32d95c26c..07cd76707 100644
--- a/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift
+++ b/Tusker/Screens/Account Follows/AccountFollowsListViewController.swift
@@ -17,9 +17,7 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
let mastodonController: MastodonController
let mode: AccountFollowsViewController.Mode
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
private var state: State = .unloaded
@@ -40,7 +38,11 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .appGroupedBackground
+
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
@@ -65,10 +67,19 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
section.readableContentInset(in: environment)
return section
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
}
diff --git a/Tusker/Screens/Account List/AccountListViewController.swift b/Tusker/Screens/Account List/AccountListViewController.swift
index c598abacb..f0b533dbd 100644
--- a/Tusker/Screens/Account List/AccountListViewController.swift
+++ b/Tusker/Screens/Account List/AccountListViewController.swift
@@ -14,9 +14,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController
private let accountIDs: [String]
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
init(accountIDs: [String], mastodonController: MastodonController) {
@@ -30,7 +28,11 @@ class AccountListViewController: UIViewController, CollectionViewController {
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .appGroupedBackground
+
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
@@ -40,11 +42,25 @@ class AccountListViewController: UIViewController, CollectionViewController {
section.readableContentInset(in: environment)
return section
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
+
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.accounts])
+ snapshot.appendItems(accountIDs)
+ dataSource.apply(snapshot, animatingDifferences: false)
}
private func createDataSource() -> UICollectionViewDiffableDataSource {
@@ -56,16 +72,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: itemIdentifier)
}
}
-
- override func viewDidLoad() {
- super.viewDidLoad()
- var snapshot = NSDiffableDataSourceSnapshot()
- snapshot.appendSections([.accounts])
- snapshot.appendItems(accountIDs)
- dataSource.apply(snapshot, animatingDifferences: false)
- }
-
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
diff --git a/Tusker/Screens/Announcements/AnnouncementListRow.swift b/Tusker/Screens/Announcements/AnnouncementListRow.swift
index 646c6edf3..412338779 100644
--- a/Tusker/Screens/Announcements/AnnouncementListRow.swift
+++ b/Tusker/Screens/Announcements/AnnouncementListRow.swift
@@ -88,7 +88,7 @@ struct AnnouncementListRow: View {
Button(role: .destructive) {
Task {
await dismissAnnouncement()
- await removeAnnouncement()
+ removeAnnouncement()
}
} label: {
Label("Dismiss", systemImage: "xmark")
diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift
index 8f16309e5..b3559e302 100644
--- a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift
+++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift
@@ -19,9 +19,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
init(for mainStatusID: String, state: CollapseState, conversationViewController: ConversationViewController) {
@@ -38,7 +36,9 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appSecondaryBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
@@ -66,13 +66,19 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
return section
}
viewRespectsSystemMinimumLayoutMargins = false
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
- // something about the autoresizing mask breaks resizing the vc
- view.translatesAutoresizingMaskIntoConstraints = false
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
-
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
diff --git a/Tusker/Screens/Explore/AddSavedHashtagViewController.swift b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift
index 1eb7134a0..5439e63da 100644
--- a/Tusker/Screens/Explore/AddSavedHashtagViewController.swift
+++ b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift
@@ -9,14 +9,14 @@
import UIKit
import Pachyderm
-class AddSavedHashtagViewController: UIViewController {
+class AddSavedHashtagViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
- private var collectionView: UICollectionView!
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
init(mastodonController: MastodonController) {
@@ -91,6 +91,12 @@ class AddSavedHashtagViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
+ if searchController.isActive {
+ resultsController.clearSelectionOnAppear(animated: animated)
+ }
+
+ clearSelectionOnAppear(animated: animated)
+
let request = Client.getTrendingHashtags(limit: 10)
mastodonController.run(request) { (response) in
var snapshot = NSDiffableDataSourceSnapshot()
@@ -108,7 +114,63 @@ class AddSavedHashtagViewController: UIViewController {
}
private func selectHashtag(_ hashtag: Hashtag) {
- show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
+ let vc = HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
+ vc.loadViewIfNeeded()
+
+ let mastodonController = mastodonController!
+ let context = mastodonController.persistentContainer.viewContext
+ let existingSaved = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first
+ let saveItem = UIBarButtonItem()
+ func updateSaveItem(saved: Bool) {
+ saveItem.title = saved ? "Unsave Hashag" : "Save Hashtag"
+ saveItem.image = UIImage(systemName: saved ? "minus" : "plus")
+ }
+ saveItem.primaryAction = UIAction(handler: { [unowned self] _ in
+ // re-fetch this in case the button's been tapped before and the captured var would be out of date
+ let existingSaved = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first
+ if let existingSaved {
+ context.delete(existingSaved)
+ } else {
+ _ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
+ }
+ mastodonController.persistentContainer.save(context: context)
+ updateSaveItem(saved: existingSaved == nil)
+ if existingSaved == nil {
+ self.presentingViewController?.dismiss(animated: true)
+ }
+ })
+ // setting primaryAction replace's the bar button's title/image with the action, so do this after
+ updateSaveItem(saved: existingSaved != nil)
+
+ vc.navigationItem.rightBarButtonItems = [
+ saveItem,
+ ]
+
+ if mastodonController.instanceFeatures.canFollowHashtags {
+ let existingFollowed = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == hashtag.name })
+ let followItem = UIBarButtonItem()
+ func updateFollowItem(followed: Bool) {
+ followItem.title = followed ? "Unfollow Hashtag" : "Follow Hashtag"
+ followItem.image = UIImage(systemName: "person.badge.\(followed ? "minus" : "plus")")
+ }
+ followItem.primaryAction = UIAction(handler: { [unowned self] _ in
+ Task {
+ let success = await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: self).toggleFollow()
+ if success {
+ let followed = mastodonController.followedHashtags.contains(where: { $0.name.lowercased() == hashtag.name })
+ updateFollowItem(followed: followed)
+ if followed {
+ self.presentingViewController?.dismiss(animated: true)
+ }
+ }
+ }
+ })
+ updateFollowItem(followed: existingFollowed != nil)
+
+ vc.navigationItem.rightBarButtonItems!.append(followItem)
+ }
+
+ show(vc, sender: self)
}
// MARK: - Interaction
@@ -144,3 +206,7 @@ extension AddSavedHashtagViewController: SearchResultsViewControllerDelegate {
selectHashtag(hashtag)
}
}
+
+extension AddSavedHashtagViewController: TuskerNavigationDelegate {
+ var apiController: MastodonController! { mastodonController }
+}
diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift
index 75c58f655..1534baa7a 100644
--- a/Tusker/Screens/Explore/ExploreViewController.swift
+++ b/Tusker/Screens/Explore/ExploreViewController.swift
@@ -48,12 +48,18 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
- collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
-
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
applyInitialSnapshot()
@@ -90,7 +96,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
// so we manually propagate this down to the results controller
// so that it can deselect on appear
if searchController.isActive {
- resultsController.viewWillAppear(animated)
+ resultsController.clearSelectionOnAppear(animated: animated)
}
clearSelectionOnAppear(animated: animated)
@@ -302,6 +308,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in
context.delete(existing)
try! context.save()
+ completion(true)
}))
}
if mastodonController.instanceFeatures.canFollowHashtags,
diff --git a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift
deleted file mode 100644
index b253e068f..000000000
--- a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift
+++ /dev/null
@@ -1,152 +0,0 @@
-//
-// FeaturedProfileCollectionViewCell.swift
-// Tusker
-//
-// Created by Shadowfacts on 2/6/21.
-// Copyright © 2021 Shadowfacts. All rights reserved.
-//
-
-import UIKit
-import Pachyderm
-
-class FeaturedProfileCollectionViewCell: UICollectionViewCell {
-
- @IBOutlet weak var clippingView: UIView!
- @IBOutlet weak var headerImageView: UIImageView!
- @IBOutlet weak var avatarContainerView: UIView!
- @IBOutlet weak var avatarImageView: UIImageView!
- @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
- @IBOutlet weak var noteTextView: StatusContentTextView!
-
- var account: Account?
-
- private var accountImagesTask: Task?
-
- deinit {
- accountImagesTask?.cancel()
- }
-
- override func awakeFromNib() {
- super.awakeFromNib()
-
- avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
- avatarContainerView.layer.cornerCurve = .continuous
- avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
- avatarImageView.layer.cornerCurve = .continuous
-
- displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
- displayNameLabel.adjustsFontForContentSizeCategory = true
-
- noteTextView.adjustsFontForContentSizeCategory = true
- noteTextView.textContainer.lineBreakMode = .byTruncatingTail
- noteTextView.textContainerInset = UIEdgeInsets(top: 16, left: 4, bottom: 16, right: 4)
-
- backgroundColor = .clear
- clippingView.backgroundColor = .appBackground
- clippingView.layer.cornerRadius = 5
- clippingView.layer.cornerCurve = .continuous
- clippingView.layer.borderWidth = 1
- clippingView.layer.masksToBounds = true
- layer.shadowOpacity = 0.2
- layer.shadowRadius = 8
- layer.shadowOffset = .zero
- layer.masksToBounds = false
- updateLayerColors()
-
- NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
- }
-
- func updateUI(account: Account) {
- self.account = account
-
- displayNameLabel.updateForAccountDisplayName(account: account)
-
- noteTextView.setBodyTextFromHTML(account.note)
- noteTextView.setEmojis(account.emojis, identifier: account.id)
-
- avatarImageView.image = nil
- headerImageView.image = nil
-
- accountImagesTask?.cancel()
- accountImagesTask = Task {
- await updateImages(account: account)
- }
- }
-
- private nonisolated func updateImages(account: Account) async {
- await withTaskGroup(of: Void.self) { group in
- group.addTask {
- guard let avatar = account.avatar,
- let image = await ImageCache.avatars.get(avatar).1 else {
- return
- }
- await MainActor.run {
- self.avatarImageView.image = image
- }
- }
- group.addTask {
- guard let header = account.header,
- let image = await ImageCache.headers.get(header).1 else {
- return
- }
- await MainActor.run {
- self.headerImageView.image = image
- }
- }
- await group.waitForAll()
- }
- }
-
- private func updateLayerColors() {
- if traitCollection.userInterfaceStyle == .dark {
- clippingView.layer.borderColor = UIColor.darkGray.withAlphaComponent(0.5).cgColor
- layer.shadowColor = UIColor.darkGray.cgColor
- } else {
- clippingView.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
- layer.shadowColor = UIColor.black.cgColor
- }
- }
-
- // Unneeded on visionOS because there is no light/dark mode
- #if !os(visionOS)
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
- updateLayerColors()
- }
- #endif
-
- override func layoutSubviews() {
- super.layoutSubviews()
-
- layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 5, cornerHeight: 5, transform: nil)
- }
-
- @objc private func preferencesChanged() {
- avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
- avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
-
- if let account = account {
- displayNameLabel.updateForAccountDisplayName(account: account)
- }
- }
-
- // MARK: Accessibility
-
- override var isAccessibilityElement: Bool {
- get { true }
- set {}
- }
-
- override var accessibilityAttributedLabel: NSAttributedString? {
- get {
- guard let account else {
- return nil
- }
- let s = NSMutableAttributedString(string: "\(account.displayNameWithoutCustomEmoji), ")
- s.append(noteTextView.attributedText)
- return s
- }
- set {}
- }
-
-}
diff --git a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib
deleted file mode 100644
index 0c1d02e43..000000000
--- a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.xib
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Tusker/Screens/FindInstanceViewController.swift b/Tusker/Screens/Explore/FindInstanceViewController.swift
similarity index 100%
rename from Tusker/Screens/FindInstanceViewController.swift
rename to Tusker/Screens/Explore/FindInstanceViewController.swift
diff --git a/Tusker/Screens/Explore/SuggestedProfilesViewController.swift b/Tusker/Screens/Explore/SuggestedProfilesViewController.swift
index b9c07e770..b1da2f895 100644
--- a/Tusker/Screens/Explore/SuggestedProfilesViewController.swift
+++ b/Tusker/Screens/Explore/SuggestedProfilesViewController.swift
@@ -46,8 +46,8 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
collectionView.allowsFocus = true
view.addSubview(collectionView)
NSLayoutConstraint.activate([
- collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
diff --git a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift
index 6e5f8c14c..28691fa4d 100644
--- a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift
+++ b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift
@@ -53,12 +53,18 @@ class TrendingHashtagsViewController: UIViewController, CollectionViewController
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
- collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
-
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
diff --git a/Tusker/Screens/Explore/TrendingLinksViewController.swift b/Tusker/Screens/Explore/TrendingLinksViewController.swift
index 3859fad27..af73cbdf5 100644
--- a/Tusker/Screens/Explore/TrendingLinksViewController.swift
+++ b/Tusker/Screens/Explore/TrendingLinksViewController.swift
@@ -40,6 +40,8 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
title = NSLocalizedString("Trending Links", comment: "trending links screen title")
+ view.backgroundColor = .appGroupedBackground
+
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex) {
case nil:
@@ -80,8 +82,8 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
collectionView.allowsFocus = true
view.addSubview(collectionView)
NSLayoutConstraint.activate([
- collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift
index 63d23a480..db6e5cac5 100644
--- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift
+++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift
@@ -14,9 +14,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
private let mastodonController: MastodonController
let filterer: Filterer
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
private var loaded = false
@@ -34,7 +32,9 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
@@ -62,12 +62,22 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
section.readableContentInset(in: environment)
return section
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
-
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
+
+ NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func createDataSource() -> UICollectionViewDiffableDataSource {
@@ -96,12 +106,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
}
}
- override func viewDidLoad() {
- super.viewDidLoad()
-
- NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
- }
-
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
diff --git a/Tusker/Screens/Explore/TrendsViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift
index 52c7786d2..4b09e2e79 100644
--- a/Tusker/Screens/Explore/TrendsViewController.swift
+++ b/Tusker/Screens/Explore/TrendsViewController.swift
@@ -44,6 +44,8 @@ class TrendsViewController: UIViewController, CollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()
+
+ view.backgroundColor = .appGroupedBackground
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
@@ -114,13 +116,19 @@ class TrendsViewController: UIViewController, CollectionViewController {
}
}
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
- collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.backgroundColor = .appGroupedBackground
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
-
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
index 692cf23a9..f4df41f35 100644
--- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
+++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift
@@ -11,6 +11,7 @@ import UserAccounts
@MainActor
protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
+ func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
/// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached.
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool
@@ -31,7 +32,7 @@ class FastAccountSwitcherViewController: UIViewController {
#endif
private var touchBeganFeedbackWorkItem: DispatchWorkItem?
- var itemOrientation: ItemOrientation = .iconsTrailing
+ private var itemOrientation: ItemOrientation = .iconsTrailing
init() {
super.init(nibName: "FastAccountSwitcherViewController", bundle: .main)
@@ -60,6 +61,9 @@ class FastAccountSwitcherViewController: UIViewController {
}
func show() {
+ if let delegate {
+ itemOrientation = delegate.fastAccountSwitcherItemOrientation(self)
+ }
createAccountViews()
// add after creating account views so that the presenter can align based on them
delegate?.fastAccountSwitcherAddToViewHierarchy(self)
diff --git a/Tusker/Screens/Gallery/ImageGalleryContentViewController.swift b/Tusker/Screens/Gallery/ImageGalleryContentViewController.swift
index b728f4c30..c7a5b2e04 100644
--- a/Tusker/Screens/Gallery/ImageGalleryContentViewController.swift
+++ b/Tusker/Screens/Gallery/ImageGalleryContentViewController.swift
@@ -128,7 +128,7 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
- func setControlsVisible(_ visible: Bool, animated: Bool) {
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
diff --git a/Tusker/Screens/Gallery/LoadingGalleryContentViewController.swift b/Tusker/Screens/Gallery/LoadingGalleryContentViewController.swift
index 899206373..77199efe8 100644
--- a/Tusker/Screens/Gallery/LoadingGalleryContentViewController.swift
+++ b/Tusker/Screens/Gallery/LoadingGalleryContentViewController.swift
@@ -52,7 +52,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
if let wrapped = await provider() {
self.wrapped = wrapped
wrapped.container = container
- wrapped.setControlsVisible(container?.galleryControlsVisible ?? false, animated: false)
+ wrapped.setControlsVisible(container?.galleryControlsVisible ?? false, animated: false, dueToUserInteraction: false)
addChild(wrapped)
wrapped.view.translatesAutoresizingMaskIntoConstraints = false
@@ -102,8 +102,8 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
])
}
- func setControlsVisible(_ visible: Bool, animated: Bool) {
- wrapped?.setControlsVisible(visible, animated: animated)
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
+ wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
func galleryContentDidAppear() {
diff --git a/Tusker/Screens/Gallery/VideoControlsViewController.swift b/Tusker/Screens/Gallery/VideoControlsViewController.swift
index 7b2160d5e..d2c582e6d 100644
--- a/Tusker/Screens/Gallery/VideoControlsViewController.swift
+++ b/Tusker/Screens/Gallery/VideoControlsViewController.swift
@@ -408,7 +408,7 @@ private class MuteButton: UIControl {
let image = UIImage(systemName: muted ? "speaker.slash.fill" : "speaker.wave.3.fill")!
if animated,
#available(iOS 17.0, *) {
- imageView.setSymbolImage(image, contentTransition: .replace.wholeSymbol, options: .speed(5))
+ imageView.setSymbolImage(image, contentTransition: .replace.byLayer)
} else {
imageView.image = image
}
diff --git a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift
index 8301787bb..797c72a37 100644
--- a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift
+++ b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift
@@ -86,17 +86,10 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
updateItemObservations()
rateObservation = player.observe(\.rate, options: .old, changeHandler: { [unowned self] player, info in
- hideControlsWorkItem?.cancel()
- if player.rate > 0 && info.oldValue == 0 {
- hideControlsWorkItem = DispatchWorkItem { [weak self] in
- guard let self,
- let container = self.container,
- container.galleryControlsVisible else {
- return
- }
- container.setGalleryControlsVisible(false, animated: true)
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: hideControlsWorkItem!)
+ if player.rate == 0 {
+ hideControlsWorkItem?.cancel()
+ } else if player.rate > 0 && info.oldValue == 0 {
+ scheduleControlsHide()
}
})
@@ -114,12 +107,52 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
MainActor.runUnsafely {
if item.status == .readyToPlay {
self.container?.setGalleryContentLoading(false)
- statusObservation = nil
+ self.statusObservation = nil
+ } else if item.status == .failed,
+ let error = item.error {
+ self.container?.setGalleryContentLoading(false)
+ self.showErrorView(error)
+ self.statusObservation = nil
}
}
})
}
+ private func showErrorView(_ error: any Error) {
+ let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
+ image.tintColor = .secondaryLabel
+ image.contentMode = .scaleAspectFit
+
+ let label = UILabel()
+ label.text = "Error Loading"
+ label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
+ label.textColor = .secondaryLabel
+ label.adjustsFontForContentSizeCategory = true
+
+ let reason = UILabel()
+ reason.text = error.localizedDescription
+ reason.font = .preferredFont(forTextStyle: .subheadline)
+ reason.textColor = .secondaryLabel
+ reason.adjustsFontForContentSizeCategory = true
+
+ let stackView = UIStackView(arrangedSubviews: [
+ image,
+ label,
+ reason,
+ ])
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ stackView.axis = .vertical
+ stackView.alignment = .center
+ stackView.spacing = 8
+ view.addSubview(stackView)
+ NSLayoutConstraint.activate([
+ image.widthAnchor.constraint(equalToConstant: 64),
+ image.heightAnchor.constraint(equalToConstant: 64),
+ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ ])
+ }
+
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
let isPlaying = player.rate > 0
@@ -137,6 +170,20 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
+ private func scheduleControlsHide() {
+ hideControlsWorkItem = DispatchWorkItem { [weak self] in
+ MainActor.runUnsafely {
+ guard let self,
+ let container = self.container,
+ container.galleryControlsVisible else {
+ return
+ }
+ container.setGalleryControlsVisible(false, animated: true)
+ }
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5), execute: hideControlsWorkItem!)
+ }
+
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
@@ -164,9 +211,15 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
- func setControlsVisible(_ visible: Bool, animated: Bool) {
+ func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
overlayVC.setVisible(visible)
- hideControlsWorkItem?.cancel()
+
+ if !visible {
+ hideControlsWorkItem?.cancel()
+ } else if dueToUserInteraction,
+ player.rate > 0 {
+ scheduleControlsHide()
+ }
}
func galleryContentDidAppear() {
diff --git a/Tusker/Screens/Lists/ListTimelineViewController.swift b/Tusker/Screens/Lists/ListTimelineViewController.swift
index 678f2fc6e..b18eda7da 100644
--- a/Tusker/Screens/Lists/ListTimelineViewController.swift
+++ b/Tusker/Screens/Lists/ListTimelineViewController.swift
@@ -56,6 +56,10 @@ class ListTimelineViewController: TimelineViewController {
}
private func createNoContentView() {
+ guard noContentView == nil else {
+ return
+ }
+
let title = UILabel()
title.textColor = .secondaryLabel
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
@@ -133,6 +137,9 @@ class ListTimelineViewController: TimelineViewController {
override func handleReplaceAllItems(_ timelineItems: [String]) async {
if timelineItems.isEmpty {
createNoContentView()
+ } else {
+ noContentView?.removeFromSuperview()
+ noContentView = nil
}
await super.handleReplaceAllItems(timelineItems)
}
diff --git a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift
index a743da7ee..1d5081b29 100644
--- a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift
+++ b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift
@@ -19,9 +19,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
private let predicateTitle: String
private let request: (RequestRange) -> Request<[TryDecode]>
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
private var state = State.unloaded
@@ -43,7 +41,9 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
@@ -71,12 +71,30 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
section.readableContentInset(in: environment)
return section
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
-
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
+
+ #if !targetEnvironment(macCatalyst)
+ collectionView.refreshControl = UIRefreshControl()
+ collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
+ #endif
+
+ addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
+
+ NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
+ NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
}
private func createDataSource() -> UICollectionViewDiffableDataSource {
@@ -97,20 +115,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
}
}
- override func viewDidLoad() {
- super.viewDidLoad()
-
- #if !targetEnvironment(macCatalyst)
- collectionView.refreshControl = UIRefreshControl()
- collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
- #endif
-
- addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
-
- NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
- NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
- }
-
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
index 6f6aede07..afdc00020 100644
--- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
+++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift
@@ -39,7 +39,16 @@ class AccountSwitchingContainerViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
- embedChild(root)
+ addChild(root)
+ root.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(root.view)
+ NSLayoutConstraint.activate([
+ root.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ root.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ root.view.topAnchor.constraint(equalTo: view.topAnchor),
+ root.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ root.didMove(toParent: self)
}
override func didReceiveMemoryWarning() {
@@ -70,12 +79,10 @@ class AccountSwitchingContainerViewController: UIViewController {
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
} else {
newRoot = newRootProvider()
- Task(priority: .userInitiated) {
- stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
- let context = StateRestorationUserActivityHandlingContext(root: newRoot)
- _ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
- context.finalize(activity: activity)
- }
+ stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
+ let context = StateRestorationUserActivityHandlingContext(root: newRoot)
+ _ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
+ context.finalize(activity: activity)
}
} else {
newRoot = newRootProvider()
@@ -147,9 +154,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
return root.stateRestorationActivity()
}
- func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) {
+ func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
loadViewIfNeeded()
- root.compose(editing: draft, animated: animated, isDucked: isDucked)
+ root.compose(editing: draft, animated: animated, isDucked: isDucked, completion: completion)
}
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
@@ -157,11 +164,6 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.select(route: route, animated: animated, completion: completion)
}
- func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
- loadViewIfNeeded()
- return root.getTabController(tab: tab)
- }
-
func getNavigationDelegate() -> TuskerNavigationDelegate? {
loadViewIfNeeded()
return root.getNavigationDelegate()
diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift
new file mode 100644
index 000000000..3019d6a41
--- /dev/null
+++ b/Tusker/Screens/Main/BaseMainTabBarViewController.swift
@@ -0,0 +1,193 @@
+//
+// BaseMainTabBarViewController.swift
+// Tusker
+//
+// Created by Shadowfacts on 8/19/24.
+// Copyright © 2024 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+
+class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewControllerDelegate {
+
+ let mastodonController: MastodonController
+
+ #if !os(visionOS)
+ private(set) var fastAccountSwitcher: FastAccountSwitcherViewController!
+ private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
+ private var fastSwitcherConstraints: [NSLayoutConstraint] = []
+ #endif
+
+ init(mastodonController: MastodonController) {
+ self.mastodonController = mastodonController
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func show(_ vc: UIViewController, sender: Any?) {
+ if let nav = selectedViewController as? UINavigationController {
+ nav.pushViewController(vc, animated: true)
+ } else {
+ present(vc, animated: true)
+ }
+ }
+
+ // Fast account switcher is not supported on visionOS
+ #if !os(visionOS)
+ func setupFastAccountSwitcher() {
+ fastAccountSwitcher = FastAccountSwitcherViewController()
+ fastAccountSwitcher.delegate = self
+ fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
+
+ tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture())
+ let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped))
+ tapRecognizer.cancelsTouchesInView = false
+ tabBar.addGestureRecognizer(tapRecognizer)
+
+ if findMyProfileTabBarButton() != nil {
+ fastSwitcherIndicator = FastAccountSwitcherIndicatorView()
+ fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(fastSwitcherIndicator)
+ }
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+
+ // i hate that we have to do this so often :S
+ // but doing it only in viewWillAppear makes it not appear initially
+ // doing it in viewWillAppear inside a DispatchQueue.main.async works initially but then it disappears when long-pressed
+ repositionFastSwitcherIndicator()
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ repositionFastSwitcherIndicator()
+ }
+
+ private func repositionFastSwitcherIndicator() {
+ guard let myProfileButton = findMyProfileTabBarButton(),
+ myProfileButton.window != nil,
+ let fastSwitcherIndicator else {
+ fastSwitcherIndicator?.isHidden = true
+ return
+ }
+ fastSwitcherIndicator.isHidden = false
+ NSLayoutConstraint.deactivate(fastSwitcherConstraints)
+ let isPortrait = view.bounds.width < view.bounds.height
+ if traitCollection.horizontalSizeClass == .compact && isPortrait {
+ fastSwitcherConstraints = [
+ fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4),
+ // tab bar button image width is 30
+ fastSwitcherIndicator.leftAnchor.constraint(equalTo: myProfileButton.centerXAnchor, constant: 15 + 2),
+ ]
+ } else {
+ fastSwitcherConstraints = [
+ fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor),
+ fastSwitcherIndicator.trailingAnchor.constraint(equalTo: myProfileButton.trailingAnchor),
+ ]
+ }
+ NSLayoutConstraint.activate(fastSwitcherConstraints)
+ }
+
+ private func findMyProfileTabBarButton() -> UIView? {
+ let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") }
+ let tabCount: Int?
+ if #available(iOS 18.0, *) {
+ tabCount = viewControllers?.count ?? tabs.count
+ } else {
+ tabCount = viewControllers?.count
+ }
+ // sanity check that there is 1 button per VC
+ guard tabBarButtons.count == tabCount,
+ let myProfileButton = tabBarButtons.last else {
+ return nil
+ }
+ return myProfileButton
+ }
+
+ @objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) {
+ fastAccountSwitcher.hide()
+ }
+ #endif // !os(visionOS)
+
+ // MARK: FastAccountSwitcherViewControllerDelegate
+
+ func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation {
+ return .iconsTrailing
+ }
+
+ func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
+ #if !os(visionOS)
+ fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(fastAccountSwitcher.view)
+ NSLayoutConstraint.activate([
+ fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor),
+
+ fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
+ fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
+
+ // The safe area insets don't automatically propagate for some reason, so do it ourselves.
+ fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ ])
+ #endif // !os(visionOS)
+ }
+
+ func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
+ #if !os(visionOS)
+ guard let myProfileButton = findMyProfileTabBarButton() else {
+ return false
+ }
+ let locationInButton = myProfileButton.convert(point, from: tabBar)
+ return myProfileButton.bounds.contains(locationInButton)
+ #else
+ return false
+ #endif // !os(visionOS)
+ }
+}
+
+extension BaseMainTabBarViewController: TuskerNavigationDelegate {
+ var apiController: MastodonController! { mastodonController }
+}
+
+extension BaseMainTabBarViewController: StateRestorableViewController {
+ func stateRestorationActivity() -> NSUserActivity? {
+ var activity: NSUserActivity?
+ if let presentedNav = presentedViewController as? UINavigationController,
+ let compose = presentedNav.viewControllers.first as? ComposeHostingController {
+ let draft = compose.controller.draft
+ activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
+ } else if let vc = (selectedViewController as? any NavigationControllerProtocol)?.topViewController as? StateRestorableViewController {
+ activity = vc.stateRestorationActivity()
+ }
+ if activity == nil {
+ stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController")
+ }
+ return activity
+ }
+}
+
+extension BaseMainTabBarViewController: BackgroundableViewController {
+ func sceneDidEnterBackground() {
+ if let selectedVC = selectedViewController as? BackgroundableViewController {
+ selectedVC.sceneDidEnterBackground()
+ }
+ }
+}
+
+extension BaseMainTabBarViewController: StatusBarTappableViewController {
+ func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
+ guard presentedViewController == nil,
+ let vc = selectedViewController as? StatusBarTappableViewController else {
+ return .continue
+ }
+ return vc.handleStatusBarTapped(xPosition: xPosition)
+ }
+}
diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift
index 893ce9e22..5ab80ca5f 100644
--- a/Tusker/Screens/Main/Duckable+Root.swift
+++ b/Tusker/Screens/Main/Duckable+Root.swift
@@ -23,8 +23,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
return activity
}
- func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) {
- (child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked)
+ func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
+ (child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked, completion: completion)
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {
@@ -39,10 +39,6 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
}
- func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
- return (child as? TuskerRootViewController)?.getTabController(tab: tab)
- }
-
func performSearch(query: String) {
(child as? TuskerRootViewController)?.performSearch(query: query)
}
diff --git a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
index 8e9d66b03..48ed96df4 100644
--- a/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
+++ b/Tusker/Screens/Main/MainSidebarMyProfileCollectionViewCell.swift
@@ -11,14 +11,14 @@ import UserAccounts
class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
- private var verticalImageInset: CGFloat {
+ static var verticalImageInset: CGFloat {
if UIDevice.current.userInterfaceIdiom == .mac {
return (28 - avatarImageSize) / 2
} else {
return (44 - avatarImageSize) / 2
}
}
- private var avatarImageSize: CGFloat {
+ static var avatarImageSize: CGFloat {
if UIDevice.current.userInterfaceIdiom == .mac {
return 20
} else {
@@ -72,11 +72,11 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
return
}
config.image = image
- config.directionalLayoutMargins.top = self.verticalImageInset
- config.directionalLayoutMargins.bottom = self.verticalImageInset
- config.imageProperties.maximumSize = CGSize(width: self.avatarImageSize, height: self.avatarImageSize)
+ config.directionalLayoutMargins.top = MainSidebarMyProfileCollectionViewCell.verticalImageInset
+ config.directionalLayoutMargins.bottom = MainSidebarMyProfileCollectionViewCell.verticalImageInset
+ config.imageProperties.maximumSize = CGSize(width: MainSidebarMyProfileCollectionViewCell.avatarImageSize, height: MainSidebarMyProfileCollectionViewCell.avatarImageSize)
config.imageProperties.reservedLayoutSize = CGSize(width: UIListContentConfiguration.ImageProperties.standardDimension, height: 0)
- config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * self.avatarImageSize
+ config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * MainSidebarMyProfileCollectionViewCell.avatarImageSize
self.contentConfiguration = config
}
}
@@ -86,7 +86,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
guard var config = self.contentConfiguration as? UIListContentConfiguration else {
return
}
- config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
+ config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * MainSidebarMyProfileCollectionViewCell.avatarImageSize
self.contentConfiguration = config
}
diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift
index 6b0908bc3..38a88385d 100644
--- a/Tusker/Screens/Main/MainSidebarViewController.swift
+++ b/Tusker/Screens/Main/MainSidebarViewController.swift
@@ -15,7 +15,7 @@ protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
- func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
+ func sidebar(_ sidebarViewController: MainSidebarViewController, didReselectItem item: MainSidebarViewController.Item)
}
class MainSidebarViewController: UIViewController {
@@ -269,8 +269,9 @@ class MainSidebarViewController: UIViewController {
}
private func showAddList() {
- let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
- ) }) { list in
+ let service = CreateListService(mastodonController: mastodonController, present: {
+ self.present($0, animated: true)
+ }) { list in
let oldItem = self.selectedItem
self.select(item: .list(list), animated: false)
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
@@ -370,7 +371,7 @@ extension MainSidebarViewController {
case let .savedInstance(url):
return url.host!
case .addSavedInstance:
- return "Find An Instance..."
+ return "Find an Instance..."
}
}
@@ -451,7 +452,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
}
itemLastSelectedTimestamps[item] = Date()
if previouslySelectedItem == item {
- sidebarDelegate?.sidebar(self, scrollToTopFor: item)
+ sidebarDelegate?.sidebar(self, didReselectItem: item)
} else if [MainSidebarViewController.Item.tab(.compose), .addList, .addSavedHashtag, .addSavedInstance].contains(item) {
if let previous = previouslySelectedItem, let indexPath = dataSource.indexPath(for: previous) {
collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift
index 24bd1670c..687be122b 100644
--- a/Tusker/Screens/Main/MainSplitViewController.swift
+++ b/Tusker/Screens/Main/MainSplitViewController.swift
@@ -10,6 +10,7 @@ import UIKit
import Combine
import TuskerPreferences
+@available(iOS, obsoleted: 18.0)
class MainSplitViewController: UISplitViewController {
private let mastodonController: MastodonController
@@ -92,7 +93,6 @@ class MainSplitViewController: UISplitViewController {
if UIDevice.current.userInterfaceIdiom != .mac {
let switcher = FastAccountSwitcherViewController()
fastAccountSwitcher = switcher
- switcher.itemOrientation = .iconsLeading
switcher.view.translatesAutoresizingMaskIntoConstraints = false
switcher.delegate = self
// accessing .view unconditionally loads the view, which we don't want to happen
@@ -333,14 +333,14 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .tab(.explore), to: exploreNav, dropFirst: true, append: true)
- tabBarViewController.select(tab: .explore, dismissPresented: false)
+ tabBarViewController.select(tab: .explore, dismissPresented: false, animated: false)
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
- tabBarViewController.select(tab: tab, dismissPresented: false)
+ tabBarViewController.select(tab: tab, dismissPresented: false, animated: false)
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
- tabBarViewController.select(tab: .explore, dismissPresented: false)
+ tabBarViewController.select(tab: .explore, dismissPresented: false, animated: false)
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously
// in compact mode and performing a search.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
@@ -493,8 +493,12 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
secondaryNavController.viewControllers = [viewController]
}
- func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item) {
- (secondaryNavController as? TabBarScrollableViewController)?.tabBarScrollToTop()
+ func sidebar(_ sidebarViewController: MainSidebarViewController, didReselectItem item: MainSidebarViewController.Item) {
+ if secondaryNavController.viewControllers.count == 1 {
+ (secondaryNavController.topViewController as? TabBarScrollableViewController)?.tabBarScrollToTop()
+ } else {
+ secondaryNavController.popToRootViewController(animated: true)
+ }
}
}
@@ -578,20 +582,6 @@ extension MainSplitViewController: TuskerRootViewController {
completion?()
}
- func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
- if traitCollection.horizontalSizeClass == .compact {
- return tabBarViewController?.getTabController(tab: tab)
- } else {
- if tab == .compose {
- return nil
- } else if case .tab(tab) = sidebar.selectedItem {
- return secondaryNavController
- } else {
- return nil
- }
- }
- }
-
func getNavigationDelegate() -> TuskerNavigationDelegate? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.getNavigationDelegate()
@@ -677,6 +667,10 @@ extension MainSplitViewController: BackgroundableViewController {
}
extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate {
+ func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation {
+ return .iconsLeading
+ }
+
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
view.addSubview(fastAccountSwitcher.view)
let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)!
@@ -690,6 +684,7 @@ extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate {
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
+
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
guard !isCollapsed,
let cell = sidebar.myProfileCell() else {
diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift
index cbf00b12d..6436050d1 100644
--- a/Tusker/Screens/Main/MainTabBarViewController.swift
+++ b/Tusker/Screens/Main/MainTabBarViewController.swift
@@ -9,18 +9,11 @@
import UIKit
import ComposeUI
-class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
-
- private let mastodonController: MastodonController
+@available(iOS, obsoleted: 18.0)
+class MainTabBarViewController: BaseMainTabBarViewController {
private var composePlaceholder: UIViewController!
- #if !os(visionOS)
- private var fastAccountSwitcher: FastAccountSwitcherViewController!
- private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
- private var fastSwitcherConstraints: [NSLayoutConstraint] = []
- #endif
-
var currentTab: Tab {
return Tab(rawValue: selectedIndex)!
}
@@ -33,16 +26,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
}
}
- init(mastodonController: MastodonController) {
- self.mastodonController = mastodonController
-
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
override func viewDidLoad() {
super.viewDidLoad()
@@ -63,107 +46,34 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
]
#if !os(visionOS)
- fastAccountSwitcher = FastAccountSwitcherViewController()
- fastAccountSwitcher.delegate = self
- fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
-
- tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture())
- let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped))
- tapRecognizer.cancelsTouchesInView = false
- tabBar.addGestureRecognizer(tapRecognizer)
-
- if findMyProfileTabBarButton() != nil {
- fastSwitcherIndicator = FastAccountSwitcherIndicatorView()
- fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(fastSwitcherIndicator)
- }
+ setupFastAccountSwitcher()
#endif
tabBar.isSpringLoaded = true
- }
-
- // Fast account switcher is not supported on visionOS
- #if !os(visionOS)
- override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- // i hate that we have to do this so often :S
- // but doing it only in viewWillAppear makes it not appear initially
- // doing it in viewWillAppear inside a DispatchQueue.main.async works initially but then it disappears when long-pressed
- repositionFastSwitcherIndicator()
+ view.backgroundColor = .appBackground
}
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
-
- repositionFastSwitcherIndicator()
- }
- #endif
-
- func select(tab: Tab, dismissPresented: Bool) {
+ func select(tab: Tab, dismissPresented: Bool, animated: Bool, completion: (() -> Void)? = nil) {
if tab == .compose {
- compose(editing: nil)
+ compose(editing: nil, completion: completion)
} else {
// when switching tabs, dismiss the currently presented VC
// otherwise the selected tab changes behind the presented VC
if presentedViewController != nil && dismissPresented {
- dismiss(animated: true) {
+ dismiss(animated: animated) {
+ stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
self.selectedIndex = tab.rawValue
+ completion?()
}
} else {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
selectedIndex = tab.rawValue
+ completion?()
}
}
}
- override func show(_ vc: UIViewController, sender: Any?) {
- if let nav = selectedViewController as? UINavigationController {
- nav.pushViewController(vc, animated: true)
- } else {
- present(vc, animated: true)
- }
- }
-
- #if !os(visionOS)
- private func repositionFastSwitcherIndicator() {
- guard let myProfileButton = findMyProfileTabBarButton() else {
- return
- }
- NSLayoutConstraint.deactivate(fastSwitcherConstraints)
- let isPortrait = view.bounds.width < view.bounds.height
- if traitCollection.horizontalSizeClass == .compact && isPortrait {
- fastSwitcherConstraints = [
- fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4),
- // tab bar button image width is 30
- fastSwitcherIndicator.leftAnchor.constraint(equalTo: myProfileButton.centerXAnchor, constant: 15 + 2),
- ]
- } else {
- fastSwitcherConstraints = [
- fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor),
- fastSwitcherIndicator.trailingAnchor.constraint(equalTo: myProfileButton.trailingAnchor),
- ]
- }
- NSLayoutConstraint.activate(fastSwitcherConstraints)
- }
- #endif
-
- private func findMyProfileTabBarButton() -> UIView? {
- let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") }
- // sanity check that there is 1 button per VC
- guard tabBarButtons.count == viewControllers!.count,
- let myProfileButton = tabBarButtons.last else {
- return nil
- }
- return myProfileButton
- }
-
- #if !os(visionOS)
- @objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) {
- fastAccountSwitcher.hide()
- }
- #endif
-
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
@@ -177,22 +87,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
}
}
- func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
- if viewController == composePlaceholder {
- compose(editing: nil)
- return false
- }
- if selectedIndex != NSNotFound,
- viewController == viewControllers![selectedIndex],
- let nav = viewController as? UINavigationController,
- nav.viewControllers.count == 1,
- let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController {
- scrollableVC.tabBarScrollToTop()
- return false
- }
- return true
- }
-
func setViewController(_ viewController: UIViewController, forTab tab: Tab) {
viewControllers![tab.rawValue] = viewController
}
@@ -227,7 +121,7 @@ extension MainTabBarViewController {
}
}
- func getTabController(tab: Tab) -> UIViewController? {
+ private func getTabController(tab: Tab) -> UIViewController? {
if tab == .compose {
return nil
} else {
@@ -238,53 +132,21 @@ extension MainTabBarViewController {
}
}
-#if !os(visionOS)
-extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
- func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
- fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(fastAccountSwitcher.view)
- NSLayoutConstraint.activate([
- fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor),
-
- fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
- fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
-
- // The safe area insets don't automatically propagate for some reason, so do it ourselves.
- fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
- fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
- ])
- }
-
- func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
- guard let myProfileButton = findMyProfileTabBarButton() else {
+extension MainTabBarViewController: UITabBarControllerDelegate {
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
+ if viewController == composePlaceholder {
+ compose(editing: nil)
return false
}
- let locationInButton = myProfileButton.convert(point, from: tabBar)
- return myProfileButton.bounds.contains(locationInButton)
- }
-}
-#endif
-
-extension MainTabBarViewController: TuskerNavigationDelegate {
- var apiController: MastodonController! { mastodonController }
-}
-
-extension MainTabBarViewController: StateRestorableViewController {
- func stateRestorationActivity() -> NSUserActivity? {
- var activity: NSUserActivity?
- if let presentedNav = presentedViewController as? UINavigationController,
- let compose = presentedNav.viewControllers.first as? ComposeHostingController {
- let draft = compose.controller.draft
- activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID)
- } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController {
- activity = vc.stateRestorationActivity()
+ if selectedIndex != NSNotFound,
+ viewController == viewControllers![selectedIndex],
+ let nav = viewController as? UINavigationController,
+ nav.viewControllers.count == 1,
+ let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController {
+ scrollableVC.tabBarScrollToTop()
+ return false
}
- if activity == nil {
- stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController")
- }
- return activity
+ return true
}
}
@@ -292,25 +154,24 @@ extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
switch route {
case .timelines:
- select(tab: .timelines, dismissPresented: true)
+ select(tab: .timelines, dismissPresented: true, animated: animated, completion: completion)
case .notifications:
- select(tab: .notifications, dismissPresented: true)
+ select(tab: .notifications, dismissPresented: true, animated: animated, completion: completion)
case .myProfile:
- select(tab: .myProfile, dismissPresented: true)
+ select(tab: .myProfile, dismissPresented: true, animated: animated, completion: completion)
case .explore:
- select(tab: .explore, dismissPresented: true)
+ select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
case .bookmarks:
- select(tab: .explore, dismissPresented: true)
+ select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
case .list(id: let id):
- select(tab: .explore, dismissPresented: true)
+ select(tab: .explore, dismissPresented: true, animated: animated, completion: completion)
if let list = mastodonController.getCachedList(id: id) {
let nav = getNavigationController()
_ = nav.popToRootViewController(animated: animated)
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
}
}
- completion?()
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {
@@ -327,7 +188,7 @@ extension MainTabBarViewController: TuskerRootViewController {
return
}
- select(tab: .explore, dismissPresented: true)
+ select(tab: .explore, dismissPresented: true, animated: false)
exploreNavController.popToRootViewController(animated: false)
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time
@@ -348,24 +209,6 @@ extension MainTabBarViewController: TuskerRootViewController {
present(vc, animated: true, completion: completion)
return vc
}
-
- func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
- guard presentedViewController == nil else {
- return .stop
- }
- guard let vc = viewController(for: currentTab) as? StatusBarTappableViewController else {
- return .continue
- }
- return vc.handleStatusBarTapped(xPosition: xPosition)
- }
-}
-
-extension MainTabBarViewController: BackgroundableViewController {
- func sceneDidEnterBackground() {
- if let selectedVC = selectedViewController as? BackgroundableViewController {
- selectedVC.sceneDidEnterBackground()
- }
- }
}
extension MainTabBarViewController: AccountSwitchableViewController {
diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift
new file mode 100644
index 000000000..3b7c32a08
--- /dev/null
+++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift
@@ -0,0 +1,866 @@
+//
+// NewMainTabBarViewController.swift
+// Tusker
+//
+// Created by Shadowfacts on 8/19/24.
+// Copyright © 2024 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+import Combine
+import Pachyderm
+import TuskerPreferences
+
+@available(iOS 18.0, *)
+final class NewMainTabBarViewController: BaseMainTabBarViewController {
+
+ private let composePlaceholder = UIViewController()
+
+ private var homeTab: UITab!
+ private var notificationsTab: UITab!
+ private var composeTab: UITab!
+ private var exploreTab: UITab!
+ private var bookmarksTab: UITab!
+ private var favoritesTab: UITab!
+ private var myProfileTab: UITab!
+ private var listsGroup: UITabGroup!
+ private var hashtagsGroup: UITabGroup!
+ private var instancesGroup: UITabGroup!
+
+ private var cancellables = Set()
+
+ private var navigationStacks = [String: [UIViewController]]()
+ private var isCompact: Bool?
+ @Box fileprivate var myProfileCell: UIView?
+ private var sidebarTapRecognizer: UITapGestureRecognizer?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ mode = .tabSidebar
+ delegate = self
+ sidebar.delegate = self
+ tabBar.isSpringLoaded = true
+ view.backgroundColor = .appBackground
+
+ let viewControllerProvider = { [unowned self] (tab: UITab) -> UIViewController in
+ self.makeViewController(for: tab)
+ }
+
+ homeTab = UITab(title: "Home", image: UIImage(systemName: "house"), identifier: Tab.home.rawValue, viewControllerProvider: viewControllerProvider)
+ notificationsTab = UITab(title: "Notifications", image: UIImage(systemName: "bell"), identifier: Tab.notifications.rawValue, viewControllerProvider: viewControllerProvider)
+ composeTab = UITab(title: "Compose", image: UIImage(systemName: "pencil"), identifier: Tab.compose.rawValue, viewControllerProvider: viewControllerProvider)
+ exploreTab = UITab(title: "Explore", image: UIImage(systemName: "magnifyingglass"), identifier: Tab.explore.rawValue, viewControllerProvider: viewControllerProvider)
+ bookmarksTab = UITab(title: "Bookmarks", image: UIImage(systemName: "bookmark"), identifier: Tab.bookmarks.rawValue, viewControllerProvider: viewControllerProvider)
+ bookmarksTab.preferredPlacement = .optional
+ favoritesTab = UITab(title: "Favorites", image: UIImage(systemName: "star"), identifier: Tab.favorites.rawValue, viewControllerProvider: viewControllerProvider)
+ favoritesTab.preferredPlacement = .optional
+ myProfileTab = MyProfileTab(mastodonController: mastodonController, viewControllerProvider: viewControllerProvider)
+
+ listsGroup = UITabGroup(title: "Lists", image: nil, identifier: Tab.lists.rawValue, children: []) { _ in
+ // this closure is necessary to prevent UIKit from crashing (FB14860961)
+ return AdaptableNavigationController()
+ }
+ listsGroup.preferredPlacement = .sidebarOnly
+ listsGroup.sidebarActions = [
+ UIAction(title: "New List…", image: UIImage(systemName: "plus"), handler: { [unowned self] _ in
+ self.showAddList()
+ })
+ ]
+ reloadLists(mastodonController.lists)
+
+ hashtagsGroup = UITabGroup(title: "Hashtags", image: nil, identifier: Tab.hashtags.rawValue, children: []) { _ in
+ return AdaptableNavigationController()
+ }
+ hashtagsGroup.preferredPlacement = .sidebarOnly
+ hashtagsGroup.sidebarActions = [
+ UIAction(title: "Add Hashtag…", image: UIImage(systemName: "plus"), handler: { [unowned self] _ in
+ self.showAddSavedHashtag()
+ })
+ ]
+ reloadHashtags()
+
+ instancesGroup = UITabGroup(title: "Instance Timelines", image: nil, identifier: Tab.instances.rawValue, children: []) { _ in
+ return AdaptableNavigationController()
+ }
+ instancesGroup.preferredPlacement = .sidebarOnly
+ instancesGroup.sidebarActions = [
+ UIAction(title: "Find an Instance…", image: UIImage(systemName: "plus"), handler: { [unowned self] _ in
+ self.showAddSavedInstance()
+ })
+ ]
+ reloadSavedInstances()
+
+ if UIDevice.current.userInterfaceIdiom == .phone || UIDevice.current.userInterfaceIdiom == .vision {
+ self.tabs = [
+ homeTab,
+ notificationsTab,
+ composeTab,
+ exploreTab,
+ myProfileTab,
+ ]
+ } else {
+ self.updatePadTabs()
+ registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: NewMainTabBarViewController, previousTraitCollection) in
+ self.updatePadTabs()
+
+ let vcToUpdate = self.selectedTab!.parent?.viewController ?? self.selectedTab!.viewController!
+ self.updateViewControllerSafeAreaInsets(vcToUpdate)
+ }
+
+ mastodonController.$lists
+ .sink { [unowned self] in self.reloadLists($0) }
+ .store(in: &cancellables)
+
+ mastodonController.$followedHashtags
+ .map { _ in () }
+ .merge(with: NotificationCenter.default.publisher(for: .savedHashtagsChanged).map { _ in () })
+ .sink { [unowned self] in self.reloadHashtags() }
+ .store(in: &cancellables)
+
+ NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
+ }
+
+ #if !os(visionOS)
+ setupFastAccountSwitcher()
+ #endif
+ }
+
+ private func updatePadTabs() {
+ let wasCompact = isCompact
+
+ if self.traitCollection.horizontalSizeClass == .compact {
+ isCompact = true
+
+ var exploreNavStack: [UIViewController]? = nil
+ if let parent = selectedTab?.parent,
+ parent === listsGroup || parent === hashtagsGroup || parent === instancesGroup {
+ let nav = parent.viewController as! any NavigationControllerProtocol
+ exploreNavStack = nav.viewControllers
+ nav.viewControllers = []
+ }
+
+ self.tabs = [
+ homeTab,
+ notificationsTab,
+ composeTab,
+ exploreTab,
+ myProfileTab,
+ ]
+
+ if let exploreNavStack {
+ selectedTab = exploreTab
+ let nav = exploreTab.viewController as! any NavigationControllerProtocol
+ nav.viewControllers = exploreNavStack
+ }
+ } else {
+ isCompact = false
+
+ var newTabAndNavigationStack: (UITab, [UIViewController])? = nil
+ if wasCompact == true,
+ selectedTab == exploreTab {
+ let nav = exploreTab.viewController as! any NavigationControllerProtocol
+ // skip over the ExploreViewController
+ if nav.viewControllers.count > 1 {
+ var newTab: UITab?
+ switch nav.viewControllers[1] {
+ case let listVC as ListTimelineViewController:
+ if let tab = listsGroup.tab(forIdentifier: ListTab.identifier(for: listVC.list)) {
+ newTab = tab
+ }
+ case let hashtagVC as HashtagTimelineViewController:
+ if let tab = hashtagsGroup.tab(forIdentifier: HashtagTab.identifier(for: hashtagVC.hashtagName)) {
+ newTab = tab
+ }
+ default:
+ break
+ }
+
+ if let newTab {
+ newTabAndNavigationStack = (newTab, Array(nav.viewControllers[1...]))
+ nav.viewControllers = [
+ nav.viewControllers[0], // leave the ExploreVC in place
+ InlineTrendsViewController(mastodonController: mastodonController), // re-insert an InlineTrendsVC
+ ]
+ }
+ }
+ }
+
+ self.tabs = [
+ homeTab,
+ notificationsTab,
+ exploreTab,
+ bookmarksTab,
+ favoritesTab,
+ myProfileTab,
+ composeTab,
+ listsGroup,
+ hashtagsGroup,
+ instancesGroup,
+ ]
+
+ if let (tab, navStack) = newTabAndNavigationStack {
+ let nav = tab.parent!.viewController as! any NavigationControllerProtocol
+ nav.viewControllers = navStack
+ // Setting the tab now seems to be clobbered by the UITabBarController itself updating in response
+ // to the size class change. So wait until it finishes to do so.
+ DispatchQueue.main.async {
+ self.selectedTab = tab
+ }
+ }
+ }
+ }
+
+ private func makeViewController(for tab: UITab) -> UIViewController {
+ guard let tab = Tab(rawValue: tab.identifier) else {
+ fatalError("unreachable")
+ }
+ let root: UIViewController
+ switch tab {
+ case .home:
+ root = TimelinesPageViewController(mastodonController: mastodonController)
+ case .notifications:
+ root = NotificationsPageViewController(mastodonController: mastodonController)
+ case .compose:
+ return composePlaceholder
+ case .explore:
+ if UIDevice.current.userInterfaceIdiom == .phone {
+ root = ExploreViewController(mastodonController: mastodonController)
+ } else {
+ let nav = AdaptableNavigationController(viewControllersToPrependInCompact: [
+ ExploreViewController(mastodonController: mastodonController)
+ ])
+ nav.viewControllers = [InlineTrendsViewController(mastodonController: mastodonController)]
+ return nav
+ }
+ case .bookmarks:
+ root = BookmarksViewController(mastodonController: mastodonController)
+ case .favorites:
+ root = FavoritesViewController(mastodonController: mastodonController)
+ case .myProfile:
+ root = MyProfileViewController(mastodonController: mastodonController)
+ case .lists, .hashtags, .instances:
+ fatalError("unreachable")
+ }
+ return embedInNavigationController(root)
+ }
+
+ private func embedInNavigationController(_ vc: UIViewController) -> UIViewController {
+ if UIDevice.current.userInterfaceIdiom == .phone {
+ return EnhancedNavigationViewController(rootViewController: vc)
+ } else {
+ let nav = AdaptableNavigationController()
+ nav.viewControllers = [vc]
+ return nav
+ }
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ if sidebarTapRecognizer == nil,
+ let sidebarView = findSidebarView() {
+ sidebarTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped))
+ sidebarTapRecognizer!.cancelsTouchesInView = false
+ sidebarView.addGestureRecognizer(sidebarTapRecognizer!)
+ }
+ }
+
+ private func reloadLists(_ lists: [List]) {
+ let viewControllerProvider = { [unowned self] (tab: UITab) in
+ let tab = tab as! ListTab
+ return ListTimelineViewController(for: tab.list, mastodonController: self.mastodonController)
+ }
+ listsGroup.children = lists.map { list in
+ ListTab(list: list, viewControllerProvider: viewControllerProvider)
+ }
+ }
+
+ private func reloadHashtags() {
+ let viewControllerProvider = { [unowned self] (tab: UITab) in
+ let tab = tab as! HashtagTab
+ return HashtagTimelineViewController(forNamed: tab.hashtagName, mastodonController: self.mastodonController)
+ }
+ var seenTags: Set = []
+ var tabs: [UITab] = []
+ let savedReq = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
+ let saved = (try? mastodonController.persistentContainer.viewContext.fetch(savedReq)) ?? []
+ for hashtag in saved where !seenTags.contains(hashtag.name) {
+ seenTags.insert(hashtag.name)
+ tabs.append(HashtagTab(hashtagName: hashtag.name, viewControllerProvider: viewControllerProvider))
+ }
+
+ let followedReq = FollowedHashtag.fetchRequest()
+ let followed = (try? mastodonController.persistentContainer.viewContext.fetch(followedReq)) ?? []
+ for hashtag in followed where !seenTags.contains(hashtag.name) {
+ seenTags.insert(hashtag.name)
+ tabs.append(HashtagTab(hashtagName: hashtag.name, viewControllerProvider: viewControllerProvider))
+ }
+
+ tabs.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
+ hashtagsGroup.children = tabs
+ }
+
+ @objc private func reloadSavedInstances() {
+ let viewControllerProvider = { [unowned self] (tab: UITab) in
+ let tab = tab as! InstanceTab
+ return InstanceTimelineViewController(for: tab.instance.url, parentMastodonController: self.mastodonController)
+ }
+ let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
+ req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
+ let instances = (try? mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)) ?? []
+ instancesGroup.children = instances.map {
+ InstanceTab(instance: $0, viewControllerProvider: viewControllerProvider)
+ }
+ }
+
+ @objc private func sidebarTapped() {
+ #if !os(visionOS)
+ fastAccountSwitcher?.hide()
+ #endif
+ }
+
+ private func showAddList() {
+ let service = CreateListService(mastodonController: mastodonController, present: {
+ self.present($0, animated: true)
+ }) { list in
+ let tab = self.listsGroup.tab(forIdentifier: ListTab.identifier(for: list))!
+ let listVC = tab.viewController as! ListTimelineViewController
+ listVC.presentEditOnAppear = true
+ self.selectedTab = tab
+ }
+ service.run()
+ }
+
+ private func showAddSavedHashtag() {
+ let addController = AddSavedHashtagViewController(mastodonController: mastodonController)
+ let nav = EnhancedNavigationViewController(rootViewController: addController)
+ present(nav, animated: true)
+ }
+
+ private func showAddSavedInstance() {
+ let findController = FindInstanceViewController(parentMastodonController: mastodonController)
+ findController.instanceTimelineDelegate = self
+ let nav = EnhancedNavigationViewController(rootViewController: findController)
+ present(nav, animated: true)
+ }
+
+ fileprivate func updateViewControllerSafeAreaInsets(_ vc: UIViewController) {
+ guard vc is MultiColumnNavigationController || (vc as? AdaptableNavigationController)?.current is MultiColumnNavigationController else {
+ return
+ }
+ // When in sidebar mode, for multi column mode, don't leave an inset for the floating tab bar, because it leaves a massive gap.
+ // The floating tab bar seems to always be 88pt tall, regardless of, e.g., Dynamic Type size.
+ vc.additionalSafeAreaInsets = UIEdgeInsets(top: sidebar.isHidden ? 0 : -88, left: 0, bottom: 0, right: 0)
+ }
+
+ private func findSidebarView() -> UIView? {
+ var next = myProfileCell
+ while let cur = next {
+ if cur.superview?.superview === self.view {
+ return cur
+ } else {
+ next = cur.superview
+ }
+ }
+ return nil
+ }
+
+ #if !os(visionOS)
+ override func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation {
+ guard !sidebar.isHidden,
+ myProfileCell != nil else {
+ return super.fastAccountSwitcherItemOrientation(fastAccountSwitcher)
+ }
+ return .iconsLeading
+ }
+
+ override func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
+ guard !sidebar.isHidden,
+ let myProfileCell else {
+ super.fastAccountSwitcherAddToViewHierarchy(fastAccountSwitcher)
+ return
+ }
+
+ fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(fastAccountSwitcher.view)
+
+ let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)!
+ NSLayoutConstraint.activate([
+ currentAccount.centerYAnchor.constraint(equalTo: myProfileCell.centerYAnchor),
+
+ fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: selectedTab!.viewController!.view.safeAreaLayoutGuide.leadingAnchor),
+ fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
+ fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ }
+
+ override func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
+ guard !sidebar.isHidden,
+ myProfileCell != nil else {
+ return super.fastAccountSwitcher(fastAccountSwitcher, triggerZoneContains: point)
+ }
+ return true
+ }
+ #endif
+
+ // MARK: Keyboard shortcuts
+
+ @objc func handleSidebarCommandTimelines() {
+ selectedTab = homeTab
+ }
+
+ @objc func handleSidebarCommandNotifications() {
+ selectedTab = notificationsTab
+ }
+
+ @objc func handleSidebarCommandExplore() {
+ selectedTab = exploreTab
+ }
+
+ @objc func handleSidebarCommandBookmarks() {
+ selectedTab = bookmarksTab
+ }
+
+ @objc func handleSidebarCommandMyProfile() {
+ selectedTab = myProfileTab
+ }
+
+ @objc func handleComposeKeyCommand() {
+ compose(editing: nil)
+ }
+
+}
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController {
+ enum Tab: String, Hashable, CaseIterable {
+ case home
+ case notifications
+ case compose
+ case explore
+ case bookmarks
+ case favorites
+ case myProfile
+
+ case lists
+ case hashtags
+ case instances
+ }
+}
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController: UITabBarControllerDelegate {
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
+ if tab.identifier == Tab.compose.rawValue {
+ let currentTab = selectedTab
+ // returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254)
+ // returning false and then setting selectedTab=tab and selectedTab=currentTab seems to leave things in a bad state (currentTab's VC is on screen but in the disappeared state)
+ // so return true, and then after the tab bar VC has finished updating, go back to currentTab
+ DispatchQueue.main.async {
+ self.selectedTab = currentTab
+ }
+ compose(editing: nil)
+ return true
+ } else if let selectedTab,
+ selectedTab == tab,
+ let nav = selectedViewController as? any NavigationControllerProtocol {
+ if nav.viewControllers.count == 1 {
+ (nav.viewControllers[0] as? TabBarScrollableViewController)?.tabBarScrollToTop()
+ } else {
+ nav.popToRootViewController(animated: true)
+ }
+ return false
+ } else {
+ return true
+ }
+ }
+
+ func tabBarController(_ tabBarController: UITabBarController, didSelectTab newTab: UITab, previousTab: UITab?) {
+ self.updateViewControllerSafeAreaInsets(newTab.viewController!)
+
+ // All tabs in a tab group deliberately share the same view controller, so we have to do this ourselves.
+ // I think this is pretty unfortunate API design--half the time, the tab bar controller takes care of
+ // this, but the rest of the time it's up to you.
+ // The managingNavigationController API would theoretically solve this, but split-screen/multi-column
+ // nav can't straightforwardly be implemented as UINavigationController subclasses.
+ // Unfortunately this, in turn, means that when switching between tabs in the same group, we don't
+ // get the new transition animation.
+ // This would be much less complicated if the controller just used the individual VCs of items in a group.
+ if let group = newTab.parent,
+ group === listsGroup || group === hashtagsGroup || group === instancesGroup,
+ let nav = group.viewController as? any NavigationControllerProtocol {
+ updateViewControllerSafeAreaInsets(nav)
+
+ if let previousTab {
+ navigationStacks[previousTab.identifier] = nav.viewControllers
+ }
+
+ if let existing = navigationStacks[newTab.identifier] {
+ nav.viewControllers = existing
+ } else if let newVC = newTab.viewController {
+ nav.viewControllers = [newVC]
+ } else {
+ fatalError("unreachable")
+ }
+ }
+ }
+}
+
+private var fastAccountSwitcherIndicator: UIView = {
+ let indicator = FastAccountSwitcherIndicatorView()
+ // need to explicitly set the frame to get it vertically centered
+ indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
+ return indicator
+}()
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
+ func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {
+ let vc = selectedTab!.parent?.viewController ?? selectedTab!.viewController!
+ animator.addAnimations {
+ self.updateViewControllerSafeAreaInsets(vc)
+ vc.view.layoutIfNeeded()
+ }
+ }
+
+ func tabBarController(_ tabBarController: UITabBarController, sidebar: UITabBarController.Sidebar, itemFor request: UITabSidebarItem.Request) -> UITabSidebarItem {
+ let item = UITabSidebarItem(request: request)
+ if case .tab(let tab) = request.content,
+ tab.identifier == Tab.myProfile.rawValue,
+ var config = item.contentConfiguration as? UIListContentConfiguration {
+ config.directionalLayoutMargins.top = MainSidebarMyProfileCollectionViewCell.verticalImageInset
+ config.directionalLayoutMargins.bottom = MainSidebarMyProfileCollectionViewCell.verticalImageInset
+ config.imageProperties.maximumSize = CGSize(width: MainSidebarMyProfileCollectionViewCell.avatarImageSize, height: MainSidebarMyProfileCollectionViewCell.avatarImageSize)
+ config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * MainSidebarMyProfileCollectionViewCell.avatarImageSize
+
+ #if os(visionOS)
+ item.contentConfiguration = config
+ #else
+ if UIDevice.current.userInterfaceIdiom != .mac {
+ item.accessories = [
+ .customView(configuration: .init(customView: fastAccountSwitcherIndicator, placement: .trailing()))
+ ]
+ item.contentConfiguration = MyProfileContentConfiguration(wrapped: config, view: $myProfileCell) { [unowned self] in
+ $0.addGestureRecognizer(self.fastAccountSwitcher.createSwitcherGesture())
+ }
+ } else {
+ item.contentConfiguration = config
+ }
+ #endif
+ }
+ return item
+ }
+
+ func tabBarController(_ tabBarController: UITabBarController, sidebar: UITabBarController.Sidebar, contextMenuConfigurationFor tab: UITab) -> UIContextMenuConfiguration? {
+ guard let id = mastodonController.accountInfo?.id else {
+ return nil
+ }
+
+ let activity: NSUserActivity
+
+ if let listTab = tab as? ListTab {
+ let timelineActivity = UserActivityManager.showTimelineActivity(timeline: .list(id: listTab.list.id), accountID: id)
+ if let timelineActivity {
+ activity = timelineActivity
+ } else {
+ return nil
+ }
+ } else if let hashtagTab = tab as? HashtagTab {
+ let timelineActivity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtagTab.hashtagName), accountID: id)
+ if let timelineActivity {
+ activity = timelineActivity
+ } else {
+ return nil
+ }
+ } else if tab is InstanceTab {
+ // don't currently have a scene type for this
+ return nil
+ } else if let tabID = Tab(rawValue: tab.identifier) {
+ switch tabID {
+ case .home:
+ return nil
+ case .notifications:
+ activity = UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode, accountID: id)
+ case .explore:
+ activity = UserActivityManager.searchActivity(query: nil, accountID: id)
+ case .bookmarks:
+ activity = UserActivityManager.bookmarksActivity(accountID: id)
+ case .favorites:
+ // TODO
+ return nil
+ case .myProfile:
+ // no 'Open in New Window' activity for my profile, because the context menu clashes with the fast account switcher
+ return nil
+ case .compose:
+ activity = UserActivityManager.newPostActivity(accountID: id)
+ case .lists, .hashtags, .instances:
+ return nil
+ }
+ } else {
+ return nil
+ }
+
+ activity.displaysAuxiliaryScene = true
+
+ return UIContextMenuConfiguration(actionProvider: { _ in
+ var actions: [UIAction] = [
+ UIWindowScene.ActivationAction({ action in
+ return UIWindowScene.ActivationConfiguration(userActivity: activity)
+ })
+ ]
+
+ if let listTab = tab as? ListTab {
+ actions.append(UIAction(title: "Delete List", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
+ Task {
+ let service = DeleteListService(list: listTab.list, mastodonController: self.mastodonController) {
+ self.present($0, animated: true)
+ }
+ await service.run()
+ }
+ }))
+ }
+
+ return UIMenu(children: actions)
+ })
+ }
+}
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController: TuskerRootViewController {
+ func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
+ func doSelect() {
+ switch route {
+ case .timelines:
+ selectedTab = tab(forIdentifier: Tab.home.rawValue)
+ case .notifications:
+ selectedTab = tab(forIdentifier: Tab.notifications.rawValue)
+ case .myProfile:
+ selectedTab = tab(forIdentifier: Tab.myProfile.rawValue)
+ case .explore:
+ selectedTab = tab(forIdentifier: Tab.explore.rawValue)
+ case .bookmarks:
+ selectedTab = tab(forIdentifier: Tab.explore.rawValue)
+ let nav = getNavigationController()
+ nav.popToRootViewController(animated: animated)
+ nav.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
+ case .list(let id):
+ selectedTab = tab(forIdentifier: Tab.explore.rawValue)
+ if let list = mastodonController.getCachedList(id: id) {
+ let nav = getNavigationController()
+ nav.popToRootViewController(animated: animated)
+ nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
+ }
+ }
+ completion?()
+ }
+ if presentedViewController != nil {
+ dismiss(animated: animated) {
+ doSelect()
+ }
+ } else {
+ doSelect()
+ }
+ }
+
+ func getNavigationDelegate() -> (any TuskerNavigationDelegate)? {
+ return self
+ }
+
+ func getNavigationController() -> any NavigationControllerProtocol {
+ return selectedViewController as! any NavigationControllerProtocol
+ }
+
+ func performSearch(query: String) {
+ selectedTab = tab(forIdentifier: Tab.explore.rawValue)
+ guard let exploreNavController = selectedViewController as? any NavigationControllerProtocol,
+ let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else {
+ return
+ }
+
+ exploreNavController.popToRootViewController(animated: false)
+
+ // setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time
+ if exploreController.isViewLoaded {
+ exploreController.searchController.isActive = true
+ } else {
+ exploreController.searchControllerStatusOnAppearance = true
+ // we still need to load the view so that we can setup the search query
+ exploreController.loadViewIfNeeded()
+ }
+
+ exploreController.searchController.searchBar.text = query
+ exploreController.resultsController.performSearch(query: query)
+ }
+
+ func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? {
+ let vc = PreferencesNavigationController(mastodonController: mastodonController)
+ present(vc, animated: true, completion: completion)
+ return vc
+ }
+}
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController: AccountSwitchableViewController {
+ var isFastAccountSwitcherActive: Bool {
+ #if os(visionOS)
+ return false
+ #else
+ if let fastAccountSwitcher {
+ return !fastAccountSwitcher.view.isHidden
+ } else {
+ return false
+ }
+ #endif
+ }
+}
+
+@available(iOS 18.0, *)
+extension NewMainTabBarViewController: InstanceTimelineViewControllerDelegate {
+ func didSaveInstance(url: URL) {
+ dismiss(animated: true) {
+ let tab = self.instancesGroup.tab(forIdentifier: InstanceTab.identifier(for: url))!
+ self.selectedTab = tab
+ }
+ }
+
+ func didUnsaveInstance(url: URL) {
+ dismiss(animated: true)
+ }
+}
+
+private struct MyProfileContentConfiguration: UIContentConfiguration {
+ let wrapped: any UIContentConfiguration
+ @Box var view: UIView?
+ let configureView: (UIView) -> Void
+
+ init(wrapped: any UIContentConfiguration, view: Box, configureView: @escaping (UIView) -> Void) {
+ self.wrapped = wrapped
+ self._view = view
+ self.configureView = configureView
+ }
+
+ func makeContentView() -> any UIView & UIContentView {
+ let view = wrapped.makeContentView()
+ self.view = view
+ configureView(view)
+ return view
+ }
+
+ func updated(for state: any UIConfigurationState) -> Self {
+ return .init(wrapped: wrapped.updated(for: state), view: $view, configureView: configureView)
+ }
+}
+
+@available(iOS 18.0, *)
+private class MyProfileTab: UITab {
+ private let mastodonController: MastodonController
+ private var avatarStyle: AvatarStyle?
+
+ init(mastodonController: MastodonController, viewControllerProvider: @escaping (UITab) -> UIViewController) {
+ self.mastodonController = mastodonController
+
+ // try to add the avatar image synchronously if possible
+ var avatarImage: UIImage?
+ if !Preferences.shared.grayscaleImages,
+ let account = mastodonController.account,
+ let avatarURL = account.avatar,
+ let avatar = ImageCache.avatars.get(avatarURL) {
+ avatarImage = Self.renderAvatar(avatar.image)
+ self.avatarStyle = Preferences.shared.avatarStyle
+ }
+
+ let image = avatarImage ?? UIImage(systemName: "person")!
+ super.init(title: "My Profile", image: image, identifier: NewMainTabBarViewController.Tab.myProfile.rawValue, viewControllerProvider: viewControllerProvider)
+
+ if avatarImage == nil {
+ Task {
+ await updateAvatar()
+ }
+ }
+
+ NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
+ }
+
+ private func updateAvatar() async {
+ guard let account = try? await mastodonController.getOwnAccount(),
+ let avatarURL = account.avatar,
+ let image = await ImageCache.avatars.get(avatarURL).1 else {
+ return
+ }
+
+ let maybeGrayscale = await ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) ?? image
+ let rendered = Self.renderAvatar(maybeGrayscale)
+
+ self.avatarStyle = Preferences.shared.avatarStyle
+ self.image = rendered
+ }
+
+ private static func renderAvatar(_ image: UIImage) -> UIImage {
+ let size = MainSidebarMyProfileCollectionViewCell.avatarImageSize
+ let radius = Preferences.shared.avatarStyle.cornerRadiusFraction * size
+ let rect = CGRect(x: 0, y: 0, width: size, height: size)
+ let renderer = UIGraphicsImageRenderer(bounds: rect)
+ let rendered = renderer.image { ctx in
+ UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
+ image.draw(in: rect)
+ }
+ return rendered.withRenderingMode(.alwaysOriginal)
+ }
+
+ @objc private func preferencesChanged() {
+ if avatarStyle != nil,
+ avatarStyle != Preferences.shared.avatarStyle {
+ Task {
+ await updateAvatar()
+ }
+ }
+ }
+}
+
+@available(iOS 18.0, *)
+private class ListTab: UITab {
+ let list: List
+
+ init(list: List, viewControllerProvider: @escaping (UITab) -> UIViewController) {
+ self.list = list
+ super.init(title: list.title, image: UIImage(systemName: "list.bullet"), identifier: Self.identifier(for: list), viewControllerProvider: viewControllerProvider)
+ }
+
+ static func identifier(for list: List) -> String {
+ "list:\(list.id)"
+ }
+}
+
+@available(iOS 18.0, *)
+private class HashtagTab: UITab {
+ let hashtagName: String
+
+ init(hashtagName: String, viewControllerProvider: @escaping (UITab) -> UIViewController) {
+ self.hashtagName = hashtagName
+ super.init(title: hashtagName, image: UIImage(systemName: "number"), identifier: Self.identifier(for: hashtagName), viewControllerProvider: viewControllerProvider)
+ }
+
+ static func identifier(for name: String) -> String {
+ "hashtag:\(name)"
+ }
+}
+
+@available(iOS 18.0, *)
+private class InstanceTab: UITab {
+ let instance: SavedInstance
+
+ init(instance: SavedInstance, viewControllerProvider: @escaping (UITab) -> UIViewController) {
+ self.instance = instance
+ super.init(title: instance.url.host!, image: UIImage(systemName: "globe"), identifier: Self.identifier(for: instance), viewControllerProvider: viewControllerProvider)
+ }
+
+ static func identifier(for instance: SavedInstance) -> String {
+ "instance:\(instance.url.host!)"
+ }
+
+ static func identifier(for instanceURL: URL) -> String {
+ "instance:\(instanceURL.host!)"
+ }
+}
diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift
index 4b3fc83ec..c779648c1 100644
--- a/Tusker/Screens/Main/TuskerRootViewController.swift
+++ b/Tusker/Screens/Main/TuskerRootViewController.swift
@@ -11,9 +11,8 @@ import ComposeUI
@MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
- func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
+ func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?)
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?)
- func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func getNavigationDelegate() -> TuskerNavigationDelegate?
func getNavigationController() -> NavigationControllerProtocol
func performSearch(query: String)
@@ -21,6 +20,14 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
}
+extension TuskerRootViewController {
+ func runNavigation(animated: Bool, _ builder: (_ navigation: TuskerNavigationSequence) -> Void) {
+ let sequence = TuskerNavigationSequence(root: self, animated: animated)
+ builder(sequence)
+ sequence.run()
+ }
+}
+
enum TuskerRoute {
case timelines
case notifications
@@ -30,6 +37,67 @@ enum TuskerRoute {
case list(id: String)
}
+/// A class that manages running a sequence of navigation operations on a ``TuskerRootViewController``.
+///
+/// Use this type, rather than calling multiple methods on the root VC in a row, because it manages waiting until each previous step finishes.
+@MainActor
+final class TuskerNavigationSequence {
+ private let root: any TuskerRootViewController
+ private let animated: Bool
+ private var operations = [() -> Void]()
+
+ init(root: any TuskerRootViewController, animated: Bool) {
+ self.root = root
+ self.animated = animated
+ }
+
+ func select(route: TuskerRoute) {
+ operations.append {
+ self.root.select(route: route, animated: self.animated, completion: self.run)
+ }
+ }
+
+ func push(viewController: UIViewController) {
+ operations.append {
+ let nav = self.root.getNavigationController()
+ nav.pushViewController(viewController, animated: self.animated)
+ self.run()
+ }
+ }
+
+ func popToRoot() {
+ operations.append {
+ let nav = self.root.getNavigationController()
+ nav.popToRootViewController(animated: self.animated)
+ self.run()
+ }
+ }
+
+ func present(viewController: UIViewController) {
+ operations.append {
+ self.root.present(viewController, animated: self.animated, completion: self.run)
+ }
+ }
+
+ func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
+ operations.append {
+ block(self.root.getNavigationController().topViewController, self.run)
+ }
+ }
+
+ func addOperation(_ operation: @escaping (_ completion: @escaping () -> Void) -> Void) {
+ operations.append {
+ operation(self.run)
+ }
+ }
+
+ func run() {
+ if !operations.isEmpty {
+ operations.removeFirst()()
+ }
+ }
+}
+
@MainActor
protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set }
diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift
index 561c9534b..eb042b724 100644
--- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift
+++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift
@@ -108,8 +108,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
- collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
diff --git a/Tusker/Screens/Preferences/AdvancedPrefsView.swift b/Tusker/Screens/Preferences/AdvancedPrefsView.swift
index 5ff4e57e4..c55c2b2e4 100644
--- a/Tusker/Screens/Preferences/AdvancedPrefsView.swift
+++ b/Tusker/Screens/Preferences/AdvancedPrefsView.swift
@@ -184,6 +184,7 @@ struct AdvancedPrefsView : View {
} header: {
Text("Feature Flags")
}
+ .appGroupedListRowBackground()
}
}
diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift
index 916b141f7..98c68d6d8 100644
--- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift
+++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift
@@ -87,7 +87,7 @@ struct PushInstanceSettingsView: View {
}
let subscription = try await PushManager.shared.createSubscription(account: account)
- let mastodonController = await MastodonController.getForAccount(account)
+ let mastodonController = MastodonController.getForAccount(account)
do {
let result = try await mastodonController.createPushSubscription(subscription: subscription)
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) created on \(account.instanceURL) with endpoint \(result.endpoint, privacy: .public)")
@@ -95,25 +95,25 @@ struct PushInstanceSettingsView: View {
return true
} catch {
// if creation failed, remove the subscription locally as well
- await PushManager.shared.removeSubscription(account: account)
+ PushManager.shared.removeSubscription(account: account)
throw error
}
}
private func disableNotifications() async throws {
- let mastodonController = await MastodonController.getForAccount(account)
+ let mastodonController = MastodonController.getForAccount(account)
try await mastodonController.deletePushSubscription()
- await PushManager.shared.removeSubscription(account: account)
+ PushManager.shared.removeSubscription(account: account)
subscription = nil
PushManager.logger.debug("Push subscription removed on \(account.instanceURL)")
}
private func updateSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async -> Bool {
- let mastodonController = await MastodonController.getForAccount(account)
+ let mastodonController = MastodonController.getForAccount(account)
do {
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) updated on \(account.instanceURL)")
- await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
+ PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
subscription?.alerts = alerts
subscription?.policy = policy
return true
diff --git a/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift b/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift
index bc7303d07..abf36ddc2 100644
--- a/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift
+++ b/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift
@@ -38,8 +38,10 @@ class ProfileHeaderCollectionViewCell: UICollectionViewCell {
header.translatesAutoresizingMaskIntoConstraints = false
contentView.embedSubview(header)
self.state = .view(header)
- case .view(_):
- fatalError("profile header collection view cell already has view")
+ case .view(let existing):
+ if existing !== header {
+ fatalError("profile header collection view cell already has view")
+ }
}
}
@@ -61,11 +63,18 @@ class ProfileHeaderCollectionViewCell: UICollectionViewCell {
}
}
- // overrides an internal method
- // when the super impl is used, preferredLayoutAttributesFitting(_:) isn't called while the view is offscreen (i.e., window == nil)
- // and so the collection view imposes a height of 44pts which breaks the layout
- @objc func _preferredLayoutAttributesFittingAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
- return preferredLayoutAttributesFitting(attributes)
+ override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
+ switch state {
+ case .unloaded:
+ return super.preferredLayoutAttributesFitting(layoutAttributes)
+ case .placeholder(let heightConstraint):
+ layoutAttributes.size.height = heightConstraint.constant
+ return layoutAttributes
+ case .view(let profileHeaderView):
+ let size = profileHeaderView.systemLayoutSizeFitting(layoutAttributes.size, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
+ layoutAttributes.size = size
+ return layoutAttributes
+ }
}
enum State {
diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift
index c7764ed9b..802b9da37 100644
--- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift
+++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift
@@ -17,7 +17,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
let filterer: Filterer
private(set) var accountID: String!
let kind: Kind
- var initialHeaderMode: HeaderMode?
+ var headerViewMode: HeaderMode?
weak var profileHeaderDelegate: ProfileHeaderViewDelegate?
private(set) var controller: TimelineLikeController!
@@ -26,11 +26,11 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private var older: RequestRange?
private var cancellables = Set()
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource!
- private(set) var headerCell: ProfileHeaderCollectionViewCell?
+ var headerCell: ProfileHeaderCollectionViewCell? {
+ collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) as? ProfileHeaderCollectionViewCell
+ }
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
@@ -54,7 +54,9 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
@@ -101,10 +103,18 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
return section
}
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
registerTimelineLikeCells()
dataSource = createDataSource()
@@ -113,10 +123,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
@@ -173,29 +179,29 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .header(let id):
- if let headerCell = self.headerCell {
- headerCell.view?.updateUI(for: id)
- return headerCell
- } else {
- let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell
- switch self.initialHeaderMode {
- case nil:
- fatalError("missing initialHeaderMode")
- case .createView:
+ let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell
+ switch self.headerViewMode {
+ case nil:
+ fatalError("missing headerViewMode")
+ case .createViewIfNeeded:
+ if let view = cell.view {
+ view.updateUI(for: id)
+ self.headerViewMode = .useExistingView(view)
+ } else {
let view = ProfileHeaderView.create()
view.delegate = self.profileHeaderDelegate
view.updateUI(for: id)
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false)
cell.addHeader(view)
- case .useExistingView(let view):
- view.updateUI(for: id)
- cell.addHeader(view)
- case .placeholder(height: let height):
- _ = cell.addConstraint(height: height)
+ self.headerViewMode = .useExistingView(view)
}
- self.headerCell = cell
- return cell
+ case .useExistingView(let view):
+ view.updateUI(for: id)
+ cell.addHeader(view)
+ case .placeholder(height: let height):
+ _ = cell.addConstraint(height: height)
}
+ return cell
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
let (result, precomputedContent) = filterResult(state: filterState, statusID: id)
switch result {
@@ -411,7 +417,9 @@ extension ProfileStatusesViewController {
case statuses, withReplies, onlyMedia
}
enum HeaderMode {
- case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat)
+ case createViewIfNeeded
+ case useExistingView(ProfileHeaderView)
+ case placeholder(height: CGFloat)
}
}
diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift
index f4266c74d..70633e577 100644
--- a/Tusker/Screens/Profile/ProfileViewController.swift
+++ b/Tusker/Screens/Profile/ProfileViewController.swift
@@ -178,7 +178,7 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
guard let currentIndex else {
assert(!animated)
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
- new.initialHeaderMode = .createView
+ new.headerViewMode = .createViewIfNeeded
new.view.translatesAutoresizingMaskIntoConstraints = false
addChild(new)
view.addSubview(new.view)
@@ -213,20 +213,24 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
// old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
+ // Set the outgoing VC's header view mode to placeholder, so that it does not steal the header view back
+ // in case it updates the cell in the background.
+ old.headerViewMode = .placeholder(height: oldHeaderCell.bounds.height)
if let newHeaderCell = new.headerCell {
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
} else {
- new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
+ new.headerViewMode = .placeholder(height: oldHeaderCell.bounds.height)
}
// disable user interaction during animation, to avoid any potential weird race conditions
- headerView.isUserInteractionEnabled = false
+ view.isUserInteractionEnabled = false
+
headerView.layer.zPosition = 100
view.addSubview(headerView)
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
- let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y// - view.safeAreaInsets.top
+ let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: headerBottomOffset),
@@ -269,23 +273,24 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
}
animator.addCompletion { _ in
old.removeViewAndController()
- old.collectionView.transform = .identity
+ old.view.transform = .identity
- new.collectionView.transform = .identity
+ new.view.transform = .identity
new.collectionView.contentOffset = origOldContentOffset
// reenable scroll indicators after the switching animation is done
old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true
- headerView.isUserInteractionEnabled = true
+ self.view.isUserInteractionEnabled = true
+
headerView.transform = .identity
headerView.layer.zPosition = 0
// move the header view into the new page controller's cell
if let newHeaderCell = new.headerCell {
newHeaderCell.addHeader(headerView)
} else {
- new.initialHeaderMode = .useExistingView(headerView)
+ new.headerViewMode = .useExistingView(headerView)
}
self.state = .idle
diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift
index 9d22138af..714769d85 100644
--- a/Tusker/Screens/Search/SearchResultsViewController.swift
+++ b/Tusker/Screens/Search/SearchResultsViewController.swift
@@ -36,7 +36,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
weak var delegate: SearchResultsViewControllerDelegate?
var tokenHandler: ((String, SearchOperatorType) -> Void)?
- var collectionView: UICollectionView! { view as? UICollectionView }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
/// Types of results to search for.
@@ -62,7 +62,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let sectionIdentifier = self.dataSource.sectionIdentifier(for: sectionIndex)!
switch sectionIdentifier {
@@ -102,7 +104,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
return .list(using: config, layoutEnvironment: environment)
}
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
@@ -110,12 +112,16 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
#if !os(visionOS)
collectionView.keyboardDismissMode = .interactive
#endif
-
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
- }
-
- override func viewDidLoad() {
- super.viewDidLoad()
searchCancellable = searchSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
@@ -295,7 +301,10 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) {
snapshot.appendSections([.hashtags])
- snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
+ // mastodon sometimes includes duplicate hashtags with the same name but different urls
+ // (e.g., containing %C3%B8 vs o)
+ let uniqueHashtags = results.hashtags.uniques(by: \.name)
+ snapshot.appendItems(uniqueHashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses])
diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift
index 6fe62d02a..da96e3947 100644
--- a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift
+++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift
@@ -19,9 +19,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
private var needsInaccurateCountWarning = false
- var collectionView: UICollectionView! {
- view as? UICollectionView
- }
+ private(set) var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource!
private var state: State = .unloaded
@@ -45,7 +43,11 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
fatalError("init(coder:) has not been implemented")
}
- override func loadView() {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .appGroupedBackground
+
var accountsConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
accountsConfig.backgroundColor = .appGroupedBackground
accountsConfig.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
@@ -85,10 +87,19 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
section.readableContentInset(in: environment)
return section
}
- view = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(collectionView)
+ NSLayoutConstraint.activate([
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
dataSource = createDataSource()
}
diff --git a/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift b/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift
index 2d2f0389f..0ff810e4d 100644
--- a/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift
+++ b/Tusker/Screens/Status Edit History/StatusEditHistoryViewController.swift
@@ -35,6 +35,8 @@ class StatusEditHistoryViewController: UIViewController, CollectionViewControlle
override func viewDidLoad() {
super.viewDidLoad()
+ view.backgroundColor = .appGroupedBackground
+
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
@@ -62,8 +64,8 @@ class StatusEditHistoryViewController: UIViewController, CollectionViewControlle
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
- collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift
index 197819569..663a4c49e 100644
--- a/Tusker/Screens/Timeline/TimelineViewController.swift
+++ b/Tusker/Screens/Timeline/TimelineViewController.swift
@@ -123,8 +123,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
- collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
@@ -1295,7 +1295,7 @@ extension TimelineViewController {
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
if !addedItems {
- var config = ToastConfiguration(title: "There's nothing in between!")
+ var config = ToastConfiguration(title: "That's all, folks!")
config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true)
}
diff --git a/Tusker/Screens/Utilities/AdaptableNavigationController.swift b/Tusker/Screens/Utilities/AdaptableNavigationController.swift
new file mode 100644
index 000000000..f991570f6
--- /dev/null
+++ b/Tusker/Screens/Utilities/AdaptableNavigationController.swift
@@ -0,0 +1,167 @@
+//
+// AdaptableNavigationController.swift
+// Tusker
+//
+// Created by Shadowfacts on 8/20/24.
+// Copyright © 2024 Shadowfacts. All rights reserved.
+//
+
+import UIKit
+import TuskerPreferences
+
+@available(iOS 17.0, *)
+class AdaptableNavigationController: UIViewController {
+
+ private let viewControllersToPrependInCompact: [UIViewController]
+
+ private var initialViewControllers: [UIViewController] = []
+ private var currentWidescreenNavigationMode: WidescreenNavigationMode?
+ private lazy var regular = makeRegularNavigationController()
+ private lazy var compact = makeCompactNavigationController()
+ private var _current: (any NavigationControllerProtocol)?
+ var current: any NavigationControllerProtocol {
+ traitCollection.horizontalSizeClass == .regular ? regular : compact
+ }
+
+ init(viewControllersToPrependInCompact: [UIViewController] = []) {
+ self.viewControllersToPrependInCompact = viewControllersToPrependInCompact
+
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ updateNavigationController()
+ registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: AdaptableNavigationController, previousTraitCollection) in
+ self.updateNavigationController()
+ }
+
+ NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
+ }
+
+ private func updateNavigationController() {
+ let isTransferring: Bool
+ var stack: [UIViewController]
+ if let _current {
+ _current.removeViewAndController()
+ stack = _current.viewControllers
+ _current.viewControllers = []
+ isTransferring = true
+ } else {
+ stack = initialViewControllers
+ initialViewControllers = []
+ isTransferring = false
+ }
+
+ if traitCollection.horizontalSizeClass == .regular {
+ if isTransferring {
+ stack.removeFirst(viewControllersToPrependInCompact.count)
+ }
+ } else {
+ stack.insert(contentsOf: viewControllersToPrependInCompact, at: 0)
+ }
+
+ _current = current
+ current.viewControllers = stack
+
+ addChild(current)
+ current.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(current.view)
+ NSLayoutConstraint.activate([
+ current.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ current.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ current.view.topAnchor.constraint(equalTo: view.topAnchor),
+ current.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ current.didMove(toParent: self)
+ }
+
+ private func makeRegularNavigationController() -> any NavigationControllerProtocol {
+ // TODO: need to figure out how to update the navigation controller if the pref changes
+ self.currentWidescreenNavigationMode = Preferences.shared.widescreenNavigationMode
+ switch Preferences.shared.widescreenNavigationMode {
+ case .stack:
+ return EnhancedNavigationViewController()
+ case .splitScreen:
+ return SplitNavigationController()
+ case .multiColumn:
+ return MultiColumnNavigationController()
+ }
+ }
+
+ private func makeCompactNavigationController() -> any NavigationControllerProtocol {
+ EnhancedNavigationViewController()
+ }
+
+ @objc private func preferencesChanged() {
+ if currentWidescreenNavigationMode != Preferences.shared.widescreenNavigationMode {
+ if let _current,
+ _current === regular {
+ regular = makeRegularNavigationController()
+ updateNavigationController()
+ } else {
+ regular = makeRegularNavigationController()
+ }
+ }
+ }
+}
+
+@available(iOS 17.0, *)
+extension AdaptableNavigationController: NavigationControllerProtocol {
+ var viewControllers: [UIViewController] {
+ get {
+ _current?.viewControllers ?? initialViewControllers
+ }
+ set {
+ if let _current {
+ _current.viewControllers = newValue
+ } else {
+ initialViewControllers = newValue
+ }
+ }
+ }
+
+ var topViewController: UIViewController? {
+ if let _current {
+ return _current.topViewController
+ } else {
+ return initialViewControllers.last
+ }
+ }
+
+ func popToRootViewController(animated: Bool) -> [UIViewController]? {
+ if let _current {
+ return _current.popToRootViewController(animated: animated)
+ } else {
+ defer { initialViewControllers = [] }
+ return initialViewControllers
+ }
+ }
+
+ func pushViewController(_ vc: UIViewController, animated: Bool) {
+ if let _current {
+ _current.pushViewController(vc, animated: animated)
+ } else {
+ initialViewControllers.append(vc)
+ }
+ }
+}
+
+@available(iOS 17.0, *)
+extension AdaptableNavigationController: BackgroundableViewController {
+ func sceneDidEnterBackground() {
+ (topViewController as? BackgroundableViewController)?.sceneDidEnterBackground()
+ }
+}
+
+@available(iOS 17.0, *)
+extension AdaptableNavigationController: StatusBarTappableViewController {
+ func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
+ (topViewController as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
+ }
+}
diff --git a/Tusker/Screens/Utilities/MultiColumnNavigationController.swift b/Tusker/Screens/Utilities/MultiColumnNavigationController.swift
index e43bd7f79..2be2af359 100644
--- a/Tusker/Screens/Utilities/MultiColumnNavigationController.swift
+++ b/Tusker/Screens/Utilities/MultiColumnNavigationController.swift
@@ -152,10 +152,10 @@ class MultiColumnNavigationController: UIViewController {
let column = stackView.arrangedSubviews[columnIndex]
let columnFrame = column.convert(column.bounds, to: scrollView)
let offset: CGFloat
- if columnFrame.maxX < scrollView.bounds.width - scrollView.adjustedTrailingContentInset {
+ if columnFrame.maxX <= view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right - scrollView.adjustedTrailingContentInset {
offset = -scrollView.adjustedLeadingContentInset
} else {
- offset = columnFrame.minX + scrollView.adjustedLeadingContentInset - (scrollView.bounds.width - columnFrame.width)
+ offset = columnFrame.maxX - scrollView.bounds.width + scrollView.adjustedTrailingContentInset
}
scrollView.setContentOffset(CGPoint(x: offset, y: -scrollView.adjustedContentInset.top), animated: animated)
}
@@ -185,6 +185,11 @@ class MultiColumnNavigationController: UIViewController {
}
animator.startAnimation()
}
+
+ // blergh, overriding private method on UIViewController
+ @objc func _shouldOverlayTabBar() -> Bool {
+ false
+ }
}
extension MultiColumnNavigationController: NavigationControllerProtocol {
diff --git a/Tusker/Screens/Utilities/SplitNavigationController.swift b/Tusker/Screens/Utilities/SplitNavigationController.swift
index 2e0cf2bb6..28200572e 100644
--- a/Tusker/Screens/Utilities/SplitNavigationController.swift
+++ b/Tusker/Screens/Utilities/SplitNavigationController.swift
@@ -87,7 +87,7 @@ class SplitNavigationController: UIViewController {
NSLayoutConstraint.activate([
rootNav.view.topAnchor.constraint(equalTo: view.topAnchor),
rootNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- rootNav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ rootNav.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
separatorView.topAnchor.constraint(equalTo: view.topAnchor),
separatorView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
@@ -196,13 +196,13 @@ class SplitNavigationController: UIViewController {
NSLayoutConstraint.deactivate(constraints)
if visible {
constraints = [
- rootNav.view.trailingAnchor.constraint(equalTo: view.centerXAnchor),
- secondaryNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ rootNav.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
+ secondaryNav.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
]
} else {
constraints = [
- rootNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- secondaryNav.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5),
+ rootNav.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ secondaryNav.view.widthAnchor.constraint(equalTo: rootNav.view.widthAnchor),
]
}
NSLayoutConstraint.activate(constraints)
@@ -241,13 +241,19 @@ class SplitNavigationController: UIViewController {
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) {
self.isLayingOutForAnimation = true
- self.setSecondaryVisible(false)
+ NSLayoutConstraint.deactivate(self.constraints)
+ self.constraints = [
+ self.rootNav.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: self.rootNav.view.bounds.minX),
+ self.rootNav.view.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor),
+ self.secondaryNav.view.widthAnchor.constraint(equalToConstant: self.secondaryNav.view.bounds.width),
+ ]
+ NSLayoutConstraint.activate(self.constraints)
self.view.layoutIfNeeded()
}
animator.addCompletion { _ in
- self.secondaryNav.viewControllers = []
self.isLayingOutForAnimation = false
-// self.updateSecondaryNavVisibility()
+ self.secondaryNav.viewControllers = []
+ self.updateSecondaryNavVisibility()
}
animator.startAnimation()
} else {
diff --git a/Tusker/Shortcuts/AppShortcutItems.swift b/Tusker/Shortcuts/AppShortcutItems.swift
index 9a3a62fbb..af6b6883a 100644
--- a/Tusker/Shortcuts/AppShortcutItems.swift
+++ b/Tusker/Shortcuts/AppShortcutItems.swift
@@ -48,7 +48,7 @@ enum AppShortcutItem: String, CaseIterable {
case .showNotifications:
root.select(route: .notifications, animated: false, completion: nil)
case .composePost:
- root.compose(editing: nil, animated: false, isDucked: false)
+ root.compose(editing: nil, animated: false, isDucked: false, completion: nil)
}
}
}
diff --git a/Tusker/Shortcuts/NSUserActivity+Extensions.swift b/Tusker/Shortcuts/NSUserActivity+Extensions.swift
index 027211f3f..7a460a725 100644
--- a/Tusker/Shortcuts/NSUserActivity+Extensions.swift
+++ b/Tusker/Shortcuts/NSUserActivity+Extensions.swift
@@ -43,9 +43,9 @@ extension NSUserActivity {
}
@MainActor
- func handleResume(manager: UserActivityManager) async -> Bool {
+ func handleResume(manager: UserActivityManager) -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false }
- await type.handle(manager)(self)
+ type.handle(manager)(self)
return true
}
diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift
index 000a0793c..5903bb8ef 100644
--- a/Tusker/Shortcuts/UserActivityHandlingContext.swift
+++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift
@@ -16,103 +16,104 @@ import ComposeUI
protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
- func select(route: TuskerRoute) async
- func select(route: TuskerRoute, completion: (() -> Void)?)
- func present(_ vc: UIViewController)
-
- var topViewController: UIViewController? { get }
+ func select(route: TuskerRoute)
func popToRoot()
func push(_ vc: UIViewController)
-
+ func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void)
+
+ func present(_ vc: UIViewController)
func compose(editing draft: Draft)
func finalize(activity: NSUserActivity)
}
-extension UserActivityHandlingContext {
- func select(route: TuskerRoute) async {
- await withCheckedContinuation { continuation in
- select(route: route) {
- continuation.resume()
- }
- }
- }
-}
-
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
- let root: TuskerRootViewController
- var navigationDelegate: TuskerNavigationDelegate {
- root.getNavigationDelegate()!
+ private let root: TuskerRootViewController
+ private let navigation: TuskerNavigationSequence
+
+ init(isHandoff: Bool, root: TuskerRootViewController) {
+ self.isHandoff = isHandoff
+ self.root = root
+ self.navigation = TuskerNavigationSequence(root: root, animated: true)
}
- func select(route: TuskerRoute, completion: (() -> Void)?) {
- root.select(route: route, animated: true, completion: completion)
+ func select(route: TuskerRoute) {
+ navigation.select(route: route)
}
func present(_ vc: UIViewController) {
- navigationDelegate.present(vc, animated: true)
+ navigation.present(viewController: vc)
}
- var topViewController: UIViewController? { root.getNavigationController().topViewController }
-
func popToRoot() {
- _ = root.getNavigationController().popToRootViewController(animated: true)
+ navigation.popToRoot()
}
func push(_ vc: UIViewController) {
- navigationDelegate.show(vc, sender: nil)
+ navigation.push(viewController: vc)
}
+ func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
+ navigation.withTopViewController(block)
+ }
+
func compose(editing draft: Draft) {
- navigationDelegate.compose(editing: draft, animated: true, isDucked: true)
+ navigation.addOperation { completion in
+ root.compose(editing: draft, animated: true, isDucked: true, completion: completion)
+ }
}
func finalize(activity: NSUserActivity) {
+ navigation.run()
}
}
class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
private var state = State.initial
- let root: TuskerRootViewController
+ private let root: TuskerRootViewController
+ private let navigation: TuskerNavigationSequence
init(root: TuskerRootViewController) {
self.root = root
+ self.navigation = TuskerNavigationSequence(root: root, animated: false)
}
- var isHandoff: Bool { false }
-
- func select(route: TuskerRoute, completion: (() -> Void)?) {
- root.select(route: route, animated: false) {
- self.state = .selectedRoute
- completion?()
- }
+ var isHandoff: Bool {
+ false
}
- var topViewController: UIViewController? { root.getNavigationController().topViewController }
+ func select(route: TuskerRoute) {
+ navigation.select(route: route)
+ state = .selectedRoute
+ }
func popToRoot() {
- // unnecessary during state restoration
+ navigation.popToRoot()
}
func push(_ vc: UIViewController) {
precondition(state >= .selectedRoute)
- root.getNavigationController().pushViewController(vc, animated: false)
+ navigation.push(viewController: vc)
state = .pushed
}
+ func withTopViewController(_ block: @escaping (_ topViewController: UIViewController?, _ completion: @escaping @MainActor () -> Void) -> Void) {
+ navigation.withTopViewController(block)
+ }
+
func present(_ vc: UIViewController) {
- root.present(vc, animated: false)
+ navigation.present(viewController: vc)
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)
+ self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil)
} else {
DispatchQueue.main.async {
- self.root.compose(editing: draft, animated: true, isDucked: false)
+ self.root.compose(editing: draft, animated: true, isDucked: false, completion: nil)
}
}
state = .presented
@@ -120,10 +121,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
func finalize(activity: NSUserActivity) {
precondition(state > .initial)
+ navigation.run()
#if !os(visionOS)
if #available(iOS 16.0, *),
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
- self.root.compose(editing: duckedDraft, animated: false, isDucked: true)
+ self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil)
}
#endif
}
diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift
index 9a018447e..350f9774e 100644
--- a/Tusker/Shortcuts/UserActivityManager.swift
+++ b/Tusker/Shortcuts/UserActivityManager.swift
@@ -133,12 +133,15 @@ class UserActivityManager {
return activity
}
- func handleCheckNotifications(activity: NSUserActivity) async {
- await context.select(route: .notifications)
+ func handleCheckNotifications(activity: NSUserActivity) {
+ context.select(route: .notifications)
context.popToRoot()
- if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
- notificationsPageController.loadViewIfNeeded()
- notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
+ context.withTopViewController { topViewController, completion in
+ if let notificationsPageController = topViewController as? NotificationsPageViewController {
+ notificationsPageController.loadViewIfNeeded()
+ notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
+ }
+ completion()
}
}
@@ -204,32 +207,41 @@ class UserActivityManager {
return (timeline, positionInfo)
}
- func handleShowTimeline(activity: NSUserActivity) async {
+ func handleShowTimeline(activity: NSUserActivity) {
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
- var timelineVC: TimelineViewController?
if let pinned = PinnedTimeline(timeline: timeline),
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
- await context.select(route: .timelines)
+ context.select(route: .timelines)
context.popToRoot()
- let pageController = context.topViewController as! TimelinesPageViewController
- pageController.selectTimeline(pinned, animated: false)
- timelineVC = pageController.currentViewController as? TimelineViewController
+ context.withTopViewController { topViewController, completion in
+ let pageController = topViewController as! TimelinesPageViewController
+ pageController.selectTimeline(pinned, animated: false)
+ }
} else if case .list(let id) = timeline {
- await context.select(route: .list(id: id))
- timelineVC = context.topViewController as? TimelineViewController
+ context.select(route: .list(id: id))
} else {
- await context.select(route: .explore)
+ context.select(route: .explore)
context.popToRoot()
- timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
- context.push(timelineVC!)
+ let timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
+ context.push(timelineVC)
}
- if let timelineVC,
- let positionInfo,
+ if let positionInfo,
context.isHandoff {
- Task {
- await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID)
+ context.withTopViewController { topViewController, completion in
+ let timelineVC: TimelineViewController
+ if let topViewController = topViewController as? TimelineViewController {
+ timelineVC = topViewController
+ } else if let topViewController = topViewController as? TimelinesPageViewController {
+ timelineVC = topViewController.currentViewController as! TimelineViewController
+ } else {
+ return
+ }
+ Task {
+ await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID)
+ completion()
+ }
}
}
}
@@ -249,11 +261,11 @@ class UserActivityManager {
return activity.userInfo?["mainStatusID"] as? String
}
- func handleShowConversation(activity: NSUserActivity) async {
+ func handleShowConversation(activity: NSUserActivity) {
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return
}
- await context.select(route: .timelines)
+ context.select(route: .timelines)
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
}
@@ -274,32 +286,34 @@ class UserActivityManager {
return activity.userInfo?["query"] as? String
}
- func handleSearch(activity: NSUserActivity) async {
- await context.select(route: .explore)
+ func handleSearch(activity: NSUserActivity) {
+ context.select(route: .explore)
context.popToRoot()
- let searchController: UISearchController
- let resultsController: SearchResultsViewController
- if let explore = context.topViewController as? ExploreViewController {
- 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),
- !query.isEmpty {
- searchController.searchBar.text = query
- resultsController.performSearch(query: query)
- } else {
- searchController.searchBar.becomeFirstResponder()
+ context.withTopViewController { topViewController, completion in
+ let searchController: UISearchController
+ let resultsController: SearchResultsViewController
+ if let explore = topViewController as? ExploreViewController {
+ explore.loadViewIfNeeded()
+ explore.searchControllerStatusOnAppearance = true
+ searchController = explore.searchController
+ resultsController = explore.resultsController
+ } else if let inlineTrends = topViewController as? InlineTrendsViewController {
+ inlineTrends.loadViewIfNeeded()
+ inlineTrends.searchControllerStatusOnAppearance = true
+ searchController = inlineTrends.searchController
+ resultsController = inlineTrends.resultsController
+ } else {
+ return
+ }
+
+ if let query = Self.getSearchQuery(from: activity),
+ !query.isEmpty {
+ searchController.searchBar.text = query
+ resultsController.performSearch(query: query)
+ } else {
+ searchController.searchBar.becomeFirstResponder()
+ }
}
}
@@ -311,8 +325,8 @@ class UserActivityManager {
return activity
}
- func handleBookmarks(activity: NSUserActivity) async {
- await context.select(route: .bookmarks)
+ func handleBookmarks(activity: NSUserActivity) {
+ context.select(route: .bookmarks)
}
// MARK: - My Profile
@@ -325,8 +339,8 @@ class UserActivityManager {
return activity
}
- func handleMyProfile(activity: NSUserActivity) async {
- await context.select(route: .myProfile)
+ func handleMyProfile(activity: NSUserActivity) {
+ context.select(route: .myProfile)
}
// MARK: - Show Profile
@@ -344,11 +358,11 @@ class UserActivityManager {
return activity.userInfo?["profileID"] as? String
}
- func handleShowProfile(activity: NSUserActivity) async {
+ func handleShowProfile(activity: NSUserActivity) {
guard let accountID = Self.getProfile(from: activity) else {
return
}
- await context.select(route: .timelines)
+ context.select(route: .timelines)
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
}
@@ -361,11 +375,11 @@ class UserActivityManager {
return activity
}
- func handleShowNotification(activity: NSUserActivity) async {
+ func handleShowNotification(activity: NSUserActivity) {
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
return
}
- await context.select(route: .notifications)
+ context.select(route: .notifications)
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
}
diff --git a/Tusker/Shortcuts/UserActivityType.swift b/Tusker/Shortcuts/UserActivityType.swift
index 090cf8c24..6d7e991f8 100644
--- a/Tusker/Shortcuts/UserActivityType.swift
+++ b/Tusker/Shortcuts/UserActivityType.swift
@@ -23,7 +23,7 @@ enum UserActivityType: String {
extension UserActivityType {
@MainActor
- var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
+ var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
switch self {
case .mainScene:
fatalError("cannot handle main scene activity")
diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift
index 7837b1f7f..ca1e462d5 100644
--- a/Tusker/TuskerNavigationDelegate.swift
+++ b/Tusker/TuskerNavigationDelegate.swift
@@ -96,7 +96,7 @@ extension TuskerNavigationDelegate {
show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
}
- func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) {
+ func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false, completion: (() -> Void)? = nil) {
let draft = draft ?? apiController.createDraft()
let visionIdiom = UIUserInterfaceIdiom(rawValue: 6) // .vision is not available pre-iOS 17 :S
if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) {
@@ -108,16 +108,17 @@ extension TuskerNavigationDelegate {
options.preferredPresentationStyle = .prominent
#endif
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
+ completion?()
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
#if os(visionOS)
fatalError("unreachable")
#else
if #available(iOS 16.0, *),
- presentDuckable(compose, animated: animated, isDucked: isDucked) {
+ presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) {
return
} else {
- present(compose, animated: animated)
+ present(compose, animated: animated, completion: completion)
}
#endif
}
diff --git a/Tusker/Views/Attachments/GifvController.swift b/Tusker/Views/Attachments/GifvController.swift
index cc4b28492..b89bd2d51 100644
--- a/Tusker/Views/Attachments/GifvController.swift
+++ b/Tusker/Views/Attachments/GifvController.swift
@@ -61,7 +61,9 @@ class GifvController {
private func updatePresentationSizeObservation() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
- self.presentationSizeSubject.send(item.presentationSize)
+ DispatchQueue.main.async {
+ self.presentationSizeSubject.send(item.presentationSize)
+ }
})
}
diff --git a/Version.xcconfig b/Version.xcconfig
index 7bfc35c6d..abcfb85d3 100644
--- a/Version.xcconfig
+++ b/Version.xcconfig
@@ -9,8 +9,8 @@
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
-MARKETING_VERSION = 2024.3
-CURRENT_PROJECT_VERSION = 133
+MARKETING_VERSION = 2024.4
+CURRENT_PROJECT_VERSION = 136
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev