Merge branch 'develop' into non-pure-black-mode

This commit is contained in:
Shadowfacts 2023-02-06 18:15:23 -05:00
commit 4ea2dff8f1
41 changed files with 980 additions and 554 deletions

View File

@ -1,5 +1,29 @@
# Changelog
## 2023.4 (72)
Features/Improvements:
- Consolidate Trends into a single screen
- Make attachment description text selectable in gallery
- Add long press to copy usernames on profile screen
- Add Favorites screen to Explore tab
- Optimize conversation loading when opening a conversation that is already fully-loaded
- Apply Mastodon poll limits in Compose screen
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker buttons not having labels
Bugfixes:
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix status favorite/reblog accounts list not resizing on device rotation
- Fix bookmarks screen sometimes going haywire
- Fix trending statuses not being deselected upon navigating back
- Fix crash when tapping My Profile tab too early in app lifecycle
- Handle 401 errors on instance timelines properly
- Fix potential crash when showing context menu previews for status
- Fix follow request accept/reject buttons not matching accent color preference
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
- iOS 15: Fix accent colors not being disaplyed in Preferences
## 2023.4 (71)
Features/Improvements:
- Allow pinning instance public timelines to the Home tab

View File

@ -199,8 +199,10 @@ public class Client {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
}
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {

View File

@ -216,7 +216,7 @@
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */; };
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; };
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
@ -295,6 +295,10 @@
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -323,6 +327,7 @@
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
@ -624,7 +629,7 @@
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; };
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; };
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
@ -703,6 +708,10 @@
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -738,6 +747,7 @@
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -921,6 +931,7 @@
isa = PBXGroup;
children = (
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
@ -935,16 +946,19 @@
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
);
path = Explore;
sourceTree = "<group>";
};
D627944823A6AD5100D38C68 /* Bookmarks */ = {
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */ = {
isa = PBXGroup;
children = (
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
);
path = Bookmarks;
path = "Local Predicate Statuses List";
sourceTree = "<group>";
};
D627944B23A9A02400D38C68 /* Lists */ = {
@ -1007,7 +1021,6 @@
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1016,6 +1029,7 @@
D61F759729384D4200C0B37F /* Customize Timelines */,
D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */,
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */,
D641C782213DD7F0004B4513 /* Main */,
D6F6A555291F4F0C00F496A8 /* Mute */,
D641C786213DD852004B4513 /* Notifications */,
@ -1084,6 +1098,7 @@
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
);
path = Conversation;
sourceTree = "<group>";
@ -1405,7 +1420,6 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup;
children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
);
path = Search;
@ -1420,6 +1434,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1942,6 +1957,7 @@
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
@ -1952,7 +1968,7 @@
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */,
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
@ -2023,6 +2039,7 @@
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
@ -2125,6 +2142,7 @@
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
@ -2155,6 +2173,7 @@
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
@ -2170,6 +2189,7 @@
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
@ -2396,7 +2416,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2461,7 +2481,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2612,7 +2632,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2640,7 +2660,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2745,7 +2765,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2771,7 +2791,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -13,20 +13,24 @@ import os
// to make the lock semantics more clear
@available(iOS, obsoleted: 16.0)
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: LockHolder<[AnyHashable: Any]>
private let lock: any Lock<[Key: Value]>
init() {
self.lock = LockHolder(initialState: [:])
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
}
subscript(key: Key) -> Value? {
get {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict[key]
} as! Value?
}
}
set(value) {
_ = try! lock.withLock { dict in
_ = lock.withLock { dict in
dict[key] = value
}
}
@ -34,40 +38,21 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
func removeValue(forKey key: Key) -> Value? {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict.removeValue(forKey: key)
} as! Value?
}
}
func contains(key: Key) -> Bool {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict.keys.contains(key)
} as! Bool
}
}
// TODO: this should really be throws/rethrows but the stupid type-erased lock makes that not possible
func withLock<R>(_ body: @Sendable (inout [Key: Value]) -> R) -> R where R: Sendable {
return try! lock.withLock { dict in
var downcasted = dict as! [Key: Value]
defer { dict = downcasted }
return body(&downcasted)
} as! R
}
}
// this type erased struct is necessary due to a compiler bug with stored constrained existential types
// see https://github.com/apple/swift/issues/61403
// see #178
fileprivate struct LockHolder<State> {
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
init(initialState: State) {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} else {
let lock = UnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock { dict in
return try body(&dict)
}
}
}

View File

@ -81,7 +81,7 @@ class Preferences: Codable, ObservableObject {
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -132,7 +132,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideDiscover, forKey: .hideDiscover)
try container.encode(hideTrends, forKey: .hideTrends)
try container.encode(statusContentType, forKey: .statusContentType)
@ -193,7 +193,7 @@ class Preferences: Codable, ObservableObject {
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false
@Published var hideDiscover = false
@Published var hideTrends = false
// MARK: Advanced
@Published var statusContentType: StatusContentType = .plain
@ -245,7 +245,7 @@ class Preferences: Codable, ObservableObject {
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideDiscover
case hideTrends = "hideDiscover"
case statusContentType

View File

@ -82,7 +82,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
return NotificationsPageViewController(initialMode: mode, mastodonController: mastodonController)
case .search:
return SearchViewController(mastodonController: mastodonController)
return InlineTrendsViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController)

View File

@ -236,6 +236,7 @@ struct ComposeAutocompleteEmojisView: View {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
}
.accessibilityLabel(emoji.shortcode)
}
} header: {
if !section.isEmpty {
@ -271,6 +272,7 @@ struct ComposeAutocompleteEmojisView: View {
.foregroundColor(Color(UIColor.label))
}
}
.accessibilityLabel(emoji.shortcode)
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: emojis)
@ -293,6 +295,7 @@ struct ComposeAutocompleteEmojisView: View {
.aspectRatio(contentMode: .fit)
.rotationEffect(expanded ? .zero : .degrees(180))
}
.accessibilityLabel(expanded ? "Collapse" : "Expand")
.frame(width: 20, height: 20)
}

View File

@ -15,15 +15,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
@Binding var text: String
let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>?
private var didChange: ((String) -> Void)? = nil
private var didEndEditing: (() -> Void)? = nil
private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
init(text: Binding<String>, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
self.didChange = nil
@ -74,6 +76,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
} else {
uiView.text = text
}
context.coordinator.maxLength = maxLength
context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing
context.coordinator.focusNextView = focusNextView
@ -95,6 +98,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
var text: Binding<String>!
// break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState!
var maxLength: Int?
var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)?
var focusNextView: Binding<Bool>?
@ -114,6 +118,14 @@ struct ComposeEmojiTextField: UIViewRepresentable {
focusNextView?.wrappedValue = true
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let maxLength {
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
} else {
return true
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.currentInput = self
updateAutocompleteState(textField: textField)

View File

@ -20,6 +20,7 @@ struct ComposePollView: View {
@ObservedObject var draft: Draft
@ObservedObject var poll: Draft.Poll
@EnvironmentObject var mastodonController: MastodonController
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var duration: Duration
@ -31,6 +32,14 @@ struct ComposePollView: View {
self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
private var canAddOption: Bool {
if let pollConfig = mastodonController.instance?.pollsConfiguration {
return poll.options.count < pollConfig.maxOptions
} else {
return true
}
}
var body: some View {
VStack {
HStack {
@ -67,9 +76,15 @@ struct ComposePollView: View {
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: self.addOption) {
Label("Add Option", systemImage: "plus")
Label {
Text("Add Option")
} icon: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
HStack {
MenuPicker(selection: $poll.multiple, options: [
@ -155,6 +170,8 @@ struct ComposePollOption: View {
@ObservedObject var option: Draft.Poll.Option
let optionIndex: Int
@EnvironmentObject private var mastodonController: MastodonController
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
@ -173,7 +190,7 @@ struct ComposePollOption: View {
}
private var textField: some View {
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption)
return field.backgroundColor(.appBackground)
}

View File

@ -9,16 +9,6 @@
import UIKit
import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationCollectionViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController
@ -55,11 +45,15 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section)
let lastInSection = indexPath.row == rowsInSection - 1
var config = sectionConfig
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden
if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) {
config.bottomSeparatorVisibility = .hidden
} else if indexPath.row == self.collectionView.numberOfItems(inSection: indexPath.section) - 1 {
config.bottomSeparatorVisibility = .visible
} else {
config.bottomSeparatorVisibility = .hidden
}
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config
}
@ -99,7 +93,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
} else {
@ -123,45 +117,32 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendSections([.ancestors, .mainStatus])
if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .statuses)
snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
}
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .statuses)
// this will be replace with the actual node in the tree once it's loaded
let tempMainNode = ConversationNode(status: status)
let mainStatusItem = Item.status(id: mainStatusID, node: tempMainNode, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .mainStatus)
dataSource.apply(snapshot, animatingDifferences: false)
}
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
func addTree(_ tree: ConversationTree, mainStatus: StatusMO) {
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
let parentItems = parentIDs.enumerated().map { index, id in
Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true)
let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
let parentItems = tree.ancestors.enumerated().map { index, node in
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
}
snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.reloadItems([mainStatusItem])
// fetch all descendant status managed objects
let descendantIDs = context.descendants.map(\.id)
let request = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id IN %@", descendantIDs)
if let descendants = try? mastodonController.persistentContainer.viewContext.fetch(request) {
// convert array of descendant statuses into tree of sub-threads
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
}
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
self.dataSource.apply(snapshot, animatingDifferences: false) {
let item: Item
@ -171,7 +152,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
position = .centeredVertically
} else {
item = snapshot.itemIdentifiers.first {
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 {
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _, _) = $0 {
return true
} else {
return false
@ -187,54 +168,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
}
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
var statuses = statuses
var parents = [String]()
var parentID: String? = inReplyToID
while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) {
let parentStatus = statuses.remove(at: parentIndex)
parents.insert(parentStatus.id, at: 0)
parentID = parentStatus.inReplyToID
}
return parents
}
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
var descendants = descendants
func removeAllInReplyTo(id: String) -> [StatusMO] {
let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
}
var nodes: [String: ConversationNode] = [
mainStatus.id: ConversationNode(status: mainStatus)
]
var idsToCheck = [mainStatusID]
while !idsToCheck.isEmpty {
let inReplyToID = idsToCheck.removeFirst()
let nodeForID = nodes[inReplyToID]!
let inReply = removeAllInReplyTo(id: inReplyToID)
for reply in inReply {
idsToCheck.append(reply.id)
let replyNode = ConversationNode(status: reply)
nodes[reply.id] = replyNode
nodeForID.children.append(replyNode)
}
}
return nodes[mainStatusID]!.children
}
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
var childThreads = childThreads
@ -248,7 +181,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
for node in childThreads {
let section = Section.childThread(firstStatusID: node.status.id)
snapshot.appendSections([section])
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: node.status.id, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node
while true {
@ -271,7 +204,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
}
}
}
@ -280,7 +213,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers {
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else {
continue
}
@ -311,17 +244,18 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
extension ConversationCollectionViewController {
enum Section: Hashable {
case statuses
case ancestors
case mainStatus
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, state: CollapseState, prevLink: Bool, nextLink: Bool)
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)):
case let (.status(id: a, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
return a == b && aPrev == bPrev && aNext == bNext
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
@ -334,7 +268,7 @@ extension ConversationCollectionViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
hasher.combine(0)
hasher.combine(id)
hasher.combine(prevLink)
@ -355,7 +289,7 @@ extension ConversationCollectionViewController {
extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath) {
case .status(id: let id, state: _, prevLink: _, nextLink: _):
case .status(id: let id, node: _, state: _, prevLink: _, nextLink: _):
return id != mainStatusID
case .expandThread(childThreads: _, inline: _):
return true
@ -370,12 +304,24 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
break
case .loadingIndicator:
break
case .status(id: let id, state: let state, _, _):
case .status(id: let id, node: let node, state: let state, _, _):
// we can only take the fast path if the user tapped on a descendant status.
// if the current main status is C, or one of its descendants, and the user taps A, then B won't be loaded:
// A
// / \
// B C
if case .childThread(_) = dataSource.sectionIdentifier(for: indexPath.section) {
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
} else {
selected(status: id, state: state.copy())
}
case .expandThread(childThreads: let childThreads, inline: _):
if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
// todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already
let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController)
if case .status(id: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
@ -383,6 +329,34 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
}
}
// ConversationNode doesn't know about its parent, so we reconstruct that info from the data source
private func buildNewAncestors(above indexPath: IndexPath) -> [ConversationNode] {
let snapshot = dataSource.snapshot()
let currentAncestors = snapshot.itemIdentifiers(inSection: .ancestors).compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
let currentMainStatus = snapshot.itemIdentifiers(inSection: .mainStatus).compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
let parentsInCurrentSection = snapshot.itemIdentifiers(inSection: dataSource.sectionIdentifier(for: indexPath.section)!)[0..<indexPath.row].compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
return currentAncestors + currentMainStatus + parentsInCurrentSection
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}

View File

@ -0,0 +1,101 @@
//
// ConversationTree.swift
// Tusker
//
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
struct ConversationTree {
let ancestors: [ConversationNode]
let mainStatus: ConversationNode
var descendants: [ConversationNode] {
mainStatus.children
}
init(ancestors: [ConversationNode], mainStatus: ConversationNode) {
self.ancestors = ancestors
self.mainStatus = mainStatus
}
static func build(for mainStatus: StatusMO, ancestors: [StatusMO], descendants: [StatusMO]) -> ConversationTree {
let mainStatusNode = ConversationNode(status: mainStatus)
let ancestors = buildAncestorNodes(mainStatusNode: mainStatusNode, ancestors: ancestors)
buildDescendantNodes(mainStatusNode: mainStatusNode, descendants: descendants)
return ConversationTree(ancestors: ancestors, mainStatus: mainStatusNode)
}
private static func buildAncestorNodes(mainStatusNode: ConversationNode, ancestors: [StatusMO]) -> [ConversationNode] {
var statuses = ancestors
var parents = [ConversationNode]()
var parentID: String? = mainStatusNode.status.inReplyToID
while let currentParentID = parentID,
let parentIndex = statuses.firstIndex(where: { $0.id == currentParentID }) {
let parentStatus = statuses.remove(at: parentIndex)
let node = ConversationNode(status: parentStatus)
parents.insert(node, at: 0)
parentID = parentStatus.inReplyToID
}
// once the parents list is built and in-order, then we walk through and set each node's children
for (index, node) in parents.enumerated() {
if index == parents.count - 1 {
// the last parent is the direct parent of the main status
node.children = [mainStatusNode]
} else {
// otherwise, it's the parent of the status that comes immediately after it in the parents list
node.children = [parents[index + 1]]
}
}
return parents
}
// doesn't return anything, since we're modifying the main status node in-place
private static func buildDescendantNodes(mainStatusNode: ConversationNode, descendants: [StatusMO]) {
var descendants = descendants
func removeAllInReplyTo(id: String) -> [StatusMO] {
let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
}
var nodes: [String: ConversationNode] = [
mainStatusNode.status.id: mainStatusNode
]
var idsToCheck = [mainStatusNode.status.id]
while !idsToCheck.isEmpty {
let inReplyToID = idsToCheck.removeFirst()
let nodeForID = nodes[inReplyToID]!
let inReply = removeAllInReplyTo(id: inReplyToID)
for reply in inReply {
idsToCheck.append(reply.id)
let replyNode = ConversationNode(status: reply)
nodes[reply.id] = replyNode
nodeForID.children.append(replyNode)
}
}
}
}

View File

@ -77,6 +77,14 @@ class ConversationViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
}
init(preloadedTree: ConversationTree, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mode = .preloaded(preloadedTree)
self.mainStatusState = mainStatusState
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -115,12 +123,18 @@ class ConversationViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
if case .unloaded = state {
if case .preloaded(let tree) = mode {
// when everything is preloaded, we're on the fast path and want to avoid any async work
// just kicking off a MainActor task causes a delay before the content appears, even if the task doesn't suspend
mainStatusLoaded(tree.mainStatus.status)
} else {
Task { @MainActor in
await loadMainStatus()
}
}
}
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
@ -142,10 +156,21 @@ class ConversationViewController: UIViewController {
// MARK: Loading
@MainActor
private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else {
let mainStatusID: String
switch mode {
case .localID(let id):
mainStatusID = id
case .resolve(let url):
if let id = await resolveStatus(url: url) {
mainStatusID = id
} else {
return
}
case .preloaded(_):
fatalError("unreachable")
}
@MainActor
func doLoadMainStatus() async -> StatusMO? {
@ -166,7 +191,7 @@ class ConversationViewController: UIViewController {
Task {
await doLoadMainStatus()
}
await mainStatusLoaded(cached)
mainStatusLoaded(cached)
} else {
// otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium)
@ -174,17 +199,13 @@ class ConversationViewController: UIViewController {
state = .loading(indicator)
if let status = await doLoadMainStatus() {
await mainStatusLoaded(status)
mainStatusLoaded(status)
}
}
}
@MainActor
private func resolveStatusIfNecessary() async -> String? {
switch mode {
case .localID(let id):
return id
case .resolve(let url):
private func resolveStatus(url: URL) async -> String? {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
@ -204,44 +225,68 @@ class ConversationViewController: UIViewController {
return nil
}
}
}
@MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
private func mainStatusLoaded(_ mainStatus: StatusMO) {
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus)
state = .displaying(vc)
await loadContext(for: mainStatus)
if case .preloaded(let tree) = mode {
vc.addTree(tree, mainStatus: mainStatus)
} else {
Task { @MainActor in
await loadTree(for: mainStatus)
}
}
}
@MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard case .displaying(_) = state else {
private func loadTree(for mainStatus: StatusMO) async {
guard case .displaying(_) = state,
let context = await loadContext(for: mainStatus) else {
return
}
let request = Status.getContext(mainStatus.id)
do {
let (context, _) = try await mastodonController.run(request)
await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
let ancestorIDs = context.ancestors.map(\.id)
let ancestorsReq = StatusMO.fetchRequest()
ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs)
let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq)
let descendantIDs = context.descendants.map(\.id)
let descendantsReq = StatusMO.fetchRequest()
descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs)
let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq)
let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? [])
guard case .displaying(let vc) = state else {
return
}
vc.addTree(tree, mainStatus: mainStatus)
}
await vc.addContext(context, for: mainStatus)
private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
let request = Status.getContext(mainStatus.id)
do {
let (context, _) = try await mastodonController.run(request)
return context
} catch {
guard case .displaying(_) = state else {
return
return nil
}
let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus)
await self?.loadTree(for: mainStatus)
}
self.showToast(configuration: config, animated: true)
return nil
}
}
@ -341,6 +386,7 @@ extension ConversationViewController {
enum Mode {
case localID(String)
case resolve(URL)
case preloaded(ConversationTree)
}
}

View File

@ -165,9 +165,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
snapshot.appendItems([.bookmarks, .favorites], toSection: .bookmarks)
if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover {
!Preferences.shared.hideTrends {
addDiscoverSection(to: &snapshot)
}
snapshot.appendItems([.addList], toSection: .lists)
@ -186,19 +186,17 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
snapshot.insertItems([.trendingStatuses], beforeItem: .trendingTags)
snapshot.insertItems([.trendingLinks], afterItem: .trendingTags)
}
snapshot.appendItems([.trends], toSection: .discover)
}
private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot()
if mastodonController.instanceFeatures.trends,
!snapshot.sectionIdentifiers.contains(.discover) {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
addDiscoverSection(to: &snapshot)
} else if !mastodonController.instanceFeatures.trends,
snapshot.sectionIdentifiers.contains(.discover) {
snapshot.deleteSections([.discover])
}
self.dataSource.apply(snapshot)
}
@ -261,7 +259,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
@objc private func preferencesChanged() {
var snapshot = dataSource.snapshot()
let hasSection = snapshot.sectionIdentifiers.contains(.discover)
let hide = Preferences.shared.hideDiscover
let hide = Preferences.shared.hideTrends
if hasSection && hide {
snapshot.deleteSections([.discover])
} else if !hasSection && !hide {
@ -347,17 +345,11 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
case .bookmarks:
show(BookmarksViewController(mastodonController: mastodonController), sender: nil)
case .trendingStatuses:
show(TrendingStatusesViewController(mastodonController: mastodonController), sender: nil)
case .favorites:
show(FavoritesViewController(mastodonController: mastodonController), sender: nil)
case .trendingTags:
show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil)
case .trendingLinks:
show(TrendingLinksViewController(mastodonController: mastodonController), sender: nil)
case .profileDirectory:
show(ProfileDirectoryViewController(mastodonController: mastodonController), sender: nil)
case .trends:
show(TrendsViewController(mastodonController: mastodonController), sender: nil)
case let .list(list):
show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil)
@ -406,7 +398,7 @@ extension ExploreViewController {
case .bookmarks:
return nil
case .discover:
return NSLocalizedString("Discover", comment: "discover section title")
return nil
case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags:
@ -419,10 +411,8 @@ extension ExploreViewController {
enum Item: Hashable {
case bookmarks
case trendingStatuses
case trendingTags
case trendingLinks
case profileDirectory
case favorites
case trends
case list(List)
case addList
case savedHashtag(Hashtag)
@ -434,14 +424,10 @@ extension ExploreViewController {
switch self {
case .bookmarks:
return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title")
case .trendingStatuses:
return NSLocalizedString("Trending Posts", comment: "trending statuses nav item title")
case .trendingTags:
return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title")
case .trendingLinks:
return NSLocalizedString("Trending Links", comment: "trending links nav item title")
case .profileDirectory:
return NSLocalizedString("Profile Directory", comment: "profile directory nav item title")
case .favorites:
return NSLocalizedString("Favorites", comment: "favorites nav item title")
case .trends:
return NSLocalizedString("Trends", comment: "trends nav item title")
case let .list(list):
return list.title
case .addList:
@ -462,14 +448,10 @@ extension ExploreViewController {
switch self {
case .bookmarks:
name = "bookmark.fill"
case .trendingStatuses:
name = "doc.text.image"
case .trendingTags:
name = "number"
case .trendingLinks:
name = "link"
case .profileDirectory:
name = "person.2.fill"
case .favorites:
name = "star.fill"
case .trends:
name = "chart.line.uptrend.xyaxis"
case .list(_):
name = "list.bullet"
case .addList, .addSavedHashtag:
@ -488,13 +470,9 @@ extension ExploreViewController {
switch (lhs, rhs) {
case (.bookmarks, .bookmarks):
return true
case (.trendingStatuses, .trendingStatuses):
case (.favorites, .favorites):
return true
case (.trendingTags, .trendingTags):
return true
case (.trendingLinks, .trendingLinks):
return true
case (.profileDirectory, .profileDirectory):
case (.trends, .trends):
return true
case let (.list(a), .list(b)):
return a.id == b.id && a.title == b.title
@ -517,14 +495,10 @@ extension ExploreViewController {
switch self {
case .bookmarks:
hasher.combine("bookmarks")
case .trendingStatuses:
hasher.combine("trendingStatuses")
case .trendingTags:
hasher.combine("trendingTags")
case .trendingLinks:
hasher.combine("trendingLinks")
case .profileDirectory:
hasher.combine("profileDirectory")
case .favorites:
hasher.combine("favorites")
case .trends:
hasher.combine("trends")
case let .list(list):
hasher.combine("list")
hasher.combine(list.id)

View File

@ -0,0 +1,79 @@
//
// InlineTrendsViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
class InlineTrendsViewController: UIViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Explore", comment: "explore tab title")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
let trends = TrendsViewController(mastodonController: mastodonController)
trends.view.translatesAutoresizingMaskIntoConstraints = false
addChild(trends)
view.addSubview(trends.view)
NSLayoutConstraint.activate([
trends.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
trends.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
trends.view.topAnchor.constraint(equalTo: view.topAnchor),
trends.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
trends.didMove(toParent: self)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is a workaround for the issue that setting isActive on a search controller that is not visible
// does not cause it to automatically become active once it becomes visible
// see FB7814561
if let active = searchControllerStatusOnAppearance {
searchController.isActive = active
searchControllerStatusOnAppearance = nil
}
}
}

View File

@ -190,7 +190,7 @@ private struct SuggestionSourceView: View {
var body: some View {
VStack(alignment: .leading) {
HStack {
Image(uiImage: source.image)
Image(uiImage: source.image.withRenderingMode(.alwaysTemplate))
Text(source.title)
Spacer()
}

View File

@ -9,13 +9,13 @@
import UIKit
import Pachyderm
class TrendingStatusesViewController: UIViewController {
class TrendingStatusesViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
let filterer: Filterer
private var collectionView: UICollectionView {
view as! UICollectionView
var collectionView: UICollectionView! {
view as? UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -110,6 +110,8 @@ class TrendingStatusesViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if !loaded {
loaded = true
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()

View File

@ -1,36 +1,35 @@
//
// SearchViewController.swift
// TrendsViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 2/5/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SafariServices
import WebURLFoundationExtras
class SearchViewController: UIViewController, CollectionViewController {
class TrendsViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController!
let mastodonController: MastodonController
var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
private var loadTask: Task<Void, Never>?
private var isShowingTrends = false
private var shouldShowTrends: Bool {
mastodonController.instanceFeatures.trends && !Preferences.shared.hideTrends
}
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Explore", comment: "explore tab title")
title = "Trends"
}
required init?(coder: NSCoder) {
@ -54,9 +53,9 @@ class SearchViewController: UIViewController, CollectionViewController {
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.orthogonalScrollingBehavior = .groupPaging
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
]
@ -68,9 +67,9 @@ class SearchViewController: UIViewController, CollectionViewController {
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(250))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.orthogonalScrollingBehavior = .groupPaging
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
]
@ -94,53 +93,9 @@ class SearchViewController: UIViewController, CollectionViewController {
dataSource = createDataSource()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
loadTask?.cancel()
loadTask = Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot()
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is a workaround for the issue that setting isActive on a search controller that is not visible
// does not cause it to automatically become active once it becomes visible
// see FB7814561
if let active = searchControllerStatusOnAppearance {
searchController.isActive = active
searchControllerStatusOnAppearance = nil
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
@ -190,10 +145,27 @@ class SearchViewController: UIViewController, CollectionViewController {
return dataSource
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if loadTask == nil {
loadTask = Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await loadTrends()
}
}
}
}
@MainActor
private func applySnapshot() async {
guard mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover else {
private func loadTrends() async {
guard isShowingTrends != shouldShowTrends else {
return
}
isShowingTrends = shouldShowTrends
guard shouldShowTrends else {
await dataSource.apply(NSDiffableDataSourceSnapshot())
return
}
@ -246,9 +218,11 @@ class SearchViewController: UIViewController, CollectionViewController {
}
@objc private func preferencesChanged() {
if isShowingTrends != shouldShowTrends {
loadTask?.cancel()
loadTask = Task {
await applySnapshot()
await loadTrends()
}
}
}
@ -275,10 +249,9 @@ class SearchViewController: UIViewController, CollectionViewController {
self.showToast(configuration: config, animated: true)
}
}
}
extension SearchViewController {
extension TrendsViewController {
enum Section {
case trendingHashtags
case trendingLinks
@ -304,7 +277,7 @@ extension SearchViewController {
case link(Card)
case account(String, Suggestion.Source)
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool {
static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(a, _), .status(b, _)):
return a == b
@ -338,7 +311,7 @@ extension SearchViewController {
}
}
extension SearchViewController: UICollectionViewDelegate {
extension TrendsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return
@ -445,7 +418,7 @@ extension SearchViewController: UICollectionViewDelegate {
}
}
extension SearchViewController: UICollectionViewDragDelegate {
extension TrendsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return []
@ -492,17 +465,17 @@ extension SearchViewController: UICollectionViewDragDelegate {
}
}
extension SearchViewController: TuskerNavigationDelegate {
extension TrendsViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension SearchViewController: ToastableViewController {
extension TrendsViewController: ToastableViewController {
}
extension SearchViewController: MenuActionProvider {
extension TrendsViewController: MenuActionProvider {
}
extension SearchViewController: StatusCollectionViewCellDelegate {
extension TrendsViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()

View File

@ -42,6 +42,8 @@ class FastAccountSwitcherViewController: UIViewController {
view.isHidden = true
view.accessibilityViewIsModal = true
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))))
accountsStack.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))))
}
@ -67,10 +69,16 @@ class FastAccountSwitcherViewController: UIViewController {
view.isHidden = false
func completion() {
UIAccessibility.post(notification: .screenChanged, argument: accountViews.first)
}
if UIAccessibility.prefersCrossFadeTransitions {
view.alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.view.alpha = 1
} completion: { _ in
completion()
}
} else {
let totalDuration: TimeInterval = 0.5
@ -95,6 +103,8 @@ class FastAccountSwitcherViewController: UIViewController {
accountView.transform = .identity
}
}
} completion: { _ in
completion()
}
}
}
@ -114,6 +124,8 @@ class FastAccountSwitcherViewController: UIViewController {
self.view.isHidden = true
completion?()
self.view.removeFromSuperview()
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
}
@ -272,6 +284,14 @@ class FastAccountSwitcherViewController: UIViewController {
super.touchesBegan(touches, with: event)
}
override func accessibilityPerformEscape() -> Bool {
guard !view.isHidden else {
return false
}
hide()
return true
}
}
extension FastAccountSwitcherViewController {

View File

@ -117,6 +117,8 @@ class FastSwitchingAccountView: UIView {
}
updateLabelColors()
isAccessibilityElement = true
}
private func setupAccount(account: LocalData.UserAccountInfo) {
@ -134,12 +136,16 @@ class FastSwitchingAccountView: UIView {
}
}
}
accessibilityLabel = "\(account.username!)@\(account.instanceURL.host!)"
}
private func setupPlaceholder() {
usernameLabel.text = "Add Account"
instanceLabel.isHidden = true
avatarImageView.image = UIImage(systemName: "plus")
accessibilityLabel = "Add Account"
}
private func updateLabelColors() {

View File

@ -0,0 +1,27 @@
//
// BookmarksViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/15/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class BookmarksViewController: LocalPredicateStatusesViewController {
init(mastodonController: MastodonController) {
super.init(
predicate: { $0.bookmarked ?? false },
predicateTitle: "Bookmarks",
request: { Client.getBookmarks(range: $0) },
mastodonController: mastodonController
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,27 @@
//
// FavoritesViewController.swift
// Tusker
//
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FavoritesViewController: LocalPredicateStatusesViewController {
init(mastodonController: MastodonController) {
super.init(
predicate: \.favourited,
predicateTitle: "Favorites",
request: { Client.getFavourites(range: $0) },
mastodonController: mastodonController
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,20 +1,23 @@
//
// BookmarksViewController.swift
// LocalPredicateStatusesViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/15/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import CoreData
class BookmarksViewController: UIViewController, CollectionViewController, RefreshableViewController {
class LocalPredicateStatusesViewController: UIViewController, CollectionViewController, RefreshableViewController {
private static let pageSize = 40
let mastodonController: MastodonController
private let predicate: (StatusMO) -> Bool
private let predicateTitle: String
private let request: (RequestRange) -> Request<[Status]>
var collectionView: UICollectionView! {
view as? UICollectionView
@ -25,12 +28,15 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
private var newer: RequestRange?
private var older: RequestRange?
init(mastodonController: MastodonController) {
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.predicate = predicate
self.predicateTitle = predicateTitle
self.request = request
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title")
self.title = predicateTitle
}
required init?(coder: NSCoder) {
@ -51,7 +57,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
return sectionConfig
}
var config = sectionConfig
if item.hideIndicators {
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else {
@ -101,7 +107,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Bookmarks"))
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
@ -130,12 +136,12 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
state = .loadingInitial
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.bookmarks])
snapshot.appendSections([.statuses])
snapshot.appendItems([.loadingIndicator])
await apply(snapshot: snapshot, animatingDifferences: false)
do {
let req = Client.getBookmarks(range: .count(BookmarksViewController.pageSize))
let req = request(.count(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
newer = pagination?.newer
older = pagination?.older
@ -143,14 +149,14 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
await mastodonController.persistentContainer.addAll(statuses: statuses)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.bookmarks])
snapshot.appendSections([.statuses])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) })
await apply(snapshot: snapshot, animatingDifferences: true)
state = .loaded
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Bookmarks", in: self) { [weak self] toast in
let config = ToastConfiguration(from: error, with: "Error Loading \(predicateTitle)", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadInitial()
}
@ -175,7 +181,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
await apply(snapshot: snapshot, animatingDifferences: false)
do {
let req = Client.getBookmarks(range: older.withCount(BookmarksViewController.pageSize))
let req = request(older.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
self.older = pagination?.older
@ -185,12 +191,13 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) })
await apply(snapshot: snapshot, animatingDifferences: true)
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Older Bookmarks", in: self) { [weak self] toast in
let config = ToastConfiguration(from: error, with: "Error Loading Older \(predicateTitle)", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadOlder()
}
showToast(configuration: config, animated: true)
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
await apply(snapshot: snapshot, animatingDifferences: false)
}
@ -235,7 +242,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
}
var hasChanges = false
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
for case let status as StatusMO in inserted where status.bookmarked == true {
for case let status as StatusMO in inserted where predicate(status) {
prepend(item: .status(id: status.id, state: .unknown, addedLocally: true))
hasChanges = true
}
@ -244,10 +251,10 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
for case let status as StatusMO in updated {
let item = Item.status(id: status.id, state: .unknown, addedLocally: true)
let exists = snapshot.itemIdentifiers.contains(item)
if status.bookmarked == true && !exists {
if predicate(status) && !exists {
prepend(item: item)
hasChanges = true
} else if status.bookmarked == false && exists {
} else if !predicate(status) && exists {
snapshot.deleteItems([item])
hasChanges = true
}
@ -273,22 +280,21 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
state = .loadingNewer
Task {
do {
let req = Client.getBookmarks(range: newer.withCount(BookmarksViewController.pageSize))
let req = request(newer.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
self.newer = pagination?.newer
await mastodonController.persistentContainer.addAll(statuses: statuses)
var snapshot = dataSource.snapshot()
let localItems: [String: CollapseState] = Dictionary(uniqueKeysWithValues: snapshot.itemIdentifiers.compactMap({
let localItems: [String: CollapseState] = Dictionary(uniqueKeysWithValues: snapshot.itemIdentifiers.compactMap {
if case .status(id: let id, state: let state, addedLocally: true) = $0 {
return (id, state)
} else {
return nil
}
}))
})
var newItems: [Item] = []
for status in statuses {
let state: CollapseState
@ -306,8 +312,9 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
snapshot.appendItems(newItems)
}
await apply(snapshot: snapshot, animatingDifferences: true)
} catch {
let config = ToastConfiguration(from: error, with: "Error Refreshing Bookmarks", in: self) { [weak self] toast in
let config = ToastConfiguration(from: error, with: "Error Refreshing \(predicateTitle)", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
self?.refresh()
}
@ -323,15 +330,15 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
}
extension BookmarksViewController {
extension LocalPredicateStatusesViewController {
enum Section {
case bookmarks
case statuses
}
enum Item: Equatable, Hashable {
case status(id: String, state: CollapseState, addedLocally: Bool)
case loadingIndicator
var hideIndicators: Bool {
var hideSeparators: Bool {
switch self {
case .loadingIndicator:
return true
@ -363,7 +370,7 @@ extension BookmarksViewController {
}
}
extension BookmarksViewController {
extension LocalPredicateStatusesViewController {
enum State {
case unloaded
case loadingInitial
@ -373,7 +380,7 @@ extension BookmarksViewController {
}
}
extension BookmarksViewController: UICollectionViewDelegate {
extension LocalPredicateStatusesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if indexPath.section == 0,
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
@ -398,7 +405,7 @@ extension BookmarksViewController: UICollectionViewDelegate {
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
(collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
@ -406,17 +413,17 @@ extension BookmarksViewController: UICollectionViewDelegate {
}
}
extension BookmarksViewController: UICollectionViewDragDelegate {
extension LocalPredicateStatusesViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
(collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
}
}
extension BookmarksViewController: TuskerNavigationDelegate {
extension LocalPredicateStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension BookmarksViewController: StatusCollectionViewCellDelegate {
extension LocalPredicateStatusesViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
@ -426,17 +433,17 @@ extension BookmarksViewController: StatusCollectionViewCellDelegate {
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
// bookmarks aren't filtered
// filtering isn't supported here
}
}
extension BookmarksViewController: TabBarScrollableViewController {
extension LocalPredicateStatusesViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension BookmarksViewController: StatusBarTappableViewController {
extension LocalPredicateStatusesViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop

View File

@ -41,7 +41,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.explore, .bookmarks, .profileDirectory]
var items: [Item] = [.explore, .bookmarks, .favorites]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -104,7 +104,6 @@ class MainSidebarViewController: UIViewController {
select(item: .tab(.timelines), animated: false)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
mastodonController.$lists
.sink { [unowned self] in self.reloadLists($0) }
@ -166,47 +165,26 @@ class MainSidebarViewController: UIViewController {
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendSections(Section.allCases)
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
.explore,
.bookmarks,
.favorites,
.tab(.myProfile)
], toSection: .tabs)
snapshot.appendItems([
.tab(.compose)
], toSection: .compose)
if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover {
snapshot.insertSections([.discover], afterSection: .compose)
}
dataSource.apply(snapshot, animatingDifferences: false)
applyDiscoverSectionSnapshot()
reloadLists(mastodonController.lists)
updateHashtagsSection(followed: mastodonController.followedHashtags)
reloadSavedInstances()
}
private func applyDiscoverSectionSnapshot() {
var discoverSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
discoverSnapshot.append([.discoverHeader])
discoverSnapshot.append([
.profileDirectory,
], to: .discoverHeader)
dataSource.apply(discoverSnapshot, to: .discover)
}
private func ownInstanceLoaded(_ instance: Instance) {
if mastodonController.instanceFeatures.trends {
var snapshot = self.dataSource.snapshot()
if !snapshot.sectionIdentifiers.contains(.discover) {
snapshot.appendSections([.discover])
dataSource.apply(snapshot, animatingDifferences: false)
}
applyDiscoverSectionSnapshot()
}
let prevSelected = collectionView.indexPathsForSelectedItems
if let prevSelected = prevSelected?.first {
@ -289,22 +267,6 @@ class MainSidebarViewController: UIViewController {
self.dataSource.apply(instancesSnapshot, to: .savedInstances)
}
@objc private func preferencesChanged() {
var snapshot = dataSource.snapshot()
let hasSection = snapshot.sectionIdentifiers.contains(.discover)
let hide = Preferences.shared.hideDiscover
if hasSection && hide {
snapshot.deleteSections([.discover])
dataSource.apply(snapshot)
} else if !hasSection && !hide {
snapshot.insertSections([.discover], afterSection: .compose)
dataSource.apply(snapshot)
applyDiscoverSectionSnapshot()
} else {
return
}
}
private func returnToPreviousItem() {
let item = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil
@ -377,15 +339,13 @@ extension MainSidebarViewController {
enum Section: Int, Hashable, CaseIterable {
case tabs
case compose
case discover
case lists
case savedHashtags
case savedInstances
}
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case explore, bookmarks
case discoverHeader, profileDirectory
case explore, bookmarks, favorites
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -398,10 +358,8 @@ extension MainSidebarViewController {
return "Explore"
case .bookmarks:
return "Bookmarks"
case .discoverHeader:
return "Discover"
case .profileDirectory:
return "Profile Directory"
case .favorites:
return "Favorites"
case .listsHeader:
return "Lists"
case let .list(list):
@ -431,15 +389,15 @@ extension MainSidebarViewController {
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .profileDirectory:
return "person.2.fill"
case .favorites:
return "star"
case .list(_):
return "list.bullet"
case .savedHashtag(_):
return "number"
case .savedInstance(_):
return "globe"
case .discoverHeader, .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
return nil
case .addList, .addSavedHashtag, .addSavedInstance:
return "plus"
@ -448,7 +406,7 @@ extension MainSidebarViewController {
var hasChildren: Bool {
switch self {
case .discoverHeader, .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
return true
default:
return false

View File

@ -217,7 +217,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Make sure viewDidLoad is called so that the searchController/resultsController have been initialized
explore.loadViewIfNeeded()
let search = secondaryNavController.viewControllers.first as! SearchViewController
let search = secondaryNavController.viewControllers.first as! InlineTrendsViewController
// Copy the search query from the search VC to the Explore VC's search controller.
let query = search.searchController.searchBar.text ?? ""
explore.searchController.searchBar.text = query
@ -232,15 +232,15 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
tabBarViewController.select(tab: .explore)
case .bookmarks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously
// 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
let explore = exploreNav.viewControllers.first as! ExploreViewController
explore.searchControllerStatusOnAppearance = false
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
// These items are not selectable in the sidebar collection view, so this code is unreachable.
fatalError("unexpected selected sidebar item: \(sidebar.selectedItem!)")
}
@ -285,7 +285,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Search screen has special considerations, all others can be transferred directly.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .explore
let searchVC = SearchViewController(mastodonController: mastodonController)
// reuse the existing VC, if there is one
let searchVC = getOrCreateNavigationStack(item: .explore).first! as! InlineTrendsViewController
// load the view so that the search controller is accessible
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
if let exploreSearchControler = explore.searchController,
@ -303,6 +305,8 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
switch tabNavigationStack[1] {
case is BookmarksViewController:
exploreItem = .bookmarks
case is FavoritesViewController:
exploreItem = .favorites
case let listVC as ListTimelineViewController:
exploreItem = .list(listVC.list)
case let hashtagVC as HashtagTimelineViewController:
@ -313,10 +317,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
exploreItem = .explore
// these three VCs are part of the root SearchViewController, so we don't need to transfer them
skipFirst = 2
case is ProfileDirectoryViewController:
exploreItem = .profileDirectory
default:
fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
// transfer the navigation stack prepending, the existing explore VC
// if there was other stuff on the explore stack, it will get discarded
toPrepend = getOrCreateNavigationStack(item: .explore).first!
exploreItem = .explore
}
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
@ -372,18 +377,18 @@ fileprivate extension MainSidebarViewController.Item {
case let .tab(tab):
return tab.createViewController(mastodonController)
case .explore:
return SearchViewController(mastodonController: mastodonController)
return InlineTrendsViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController)
case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController)
case .favorites:
return FavoritesViewController(mastodonController: mastodonController)
case let .list(list):
return ListTimelineViewController(for: list, mastodonController: mastodonController)
case let .savedHashtag(hashtag):
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
case let .savedInstance(url):
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
return nil
}
}
@ -473,7 +478,7 @@ extension MainSplitViewController: TuskerRootViewController {
select(item: .explore)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else {
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
return
}

View File

@ -297,6 +297,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(true)
}
}
dismissAction.accessibilityLabel = "Dismiss Notification"
dismissAction.image = UIImage(systemName: "clear.fill")
let cellConfiguration = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()

View File

@ -27,6 +27,21 @@ struct AppearancePrefsView : View {
Preferences.shared.avatarStyle = $0 ? .circle : .roundRect
}
private let accentColorsAndImages: [(Preferences.AccentColor, UIImage?)] = Preferences.AccentColor.allCases.map { color in
var image: UIImage?
if let color = color.color {
if #available(iOS 16.0, *) {
image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)
} else {
image = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { context in
color.setFill()
context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: 20, height: 20))
}
}
}
return (color, image)
}
var body: some View {
List {
themeSection
@ -51,12 +66,12 @@ struct AppearancePrefsView : View {
}
Picker(selection: $preferences.accentColor, label: Text("Accent Color")) {
ForEach(Preferences.AccentColor.allCases, id: \.rawValue) { color in
ForEach(accentColorsAndImages, id: \.0.rawValue) { (color, image) in
HStack {
Text(color.name)
if let color = color.color {
if let image {
Spacer()
Image(uiImage: UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal))
Image(uiImage: image)
}
}
.tag(color)

View File

@ -17,7 +17,7 @@ struct WellnessPrefsView: View {
notificationsMode
grayscaleImages
disableInfiniteScrolling
hideDiscover
hideTrends
}
.listStyle(.insetGrouped)
.appGroupedScrollBackgroundIfAvailable()
@ -62,10 +62,10 @@ struct WellnessPrefsView: View {
.listRowBackground(Color.appGroupedCellBackground)
}
private var hideDiscover: some View {
Section(footer: Text("Do not show the Discover section (Trends, Profile Directory) of the Explore screen or sidebar.")) {
Toggle(isOn: $preferences.hideDiscover) {
Text("Hide Discover Section")
private var hideTrends: some View {
Section(footer: Text("Do not show Trends (hashtags, links, posts, suggested accounts) on the Explore screen.")) {
Toggle(isOn: $preferences.hideTrends) {
Text("Hide Trends")
}
}
.listRowBackground(Color.appGroupedCellBackground)

View File

@ -335,12 +335,14 @@ extension ProfileViewController: TabbedPageViewController {
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
guard isViewLoaded else { return }
currentViewController.tabBarScrollToTop()
}
}
extension ProfileViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
guard isViewLoaded else { return .stop }
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
}
}

View File

@ -84,8 +84,23 @@ class InstanceTimelineViewController: TimelineViewController {
// MARK: Timeline
override func handleLoadAllError(_ error: Swift.Error) async {
switch (error as? Client.Error)?.type {
case .mastodonError(422, _), .unexpectedStatus(422):
guard let error = error as? Client.Error else {
await super.handleLoadAllError(error)
return
}
let code: Int
switch error.type {
case .mastodonError(let c, _), .unexpectedStatus(let c):
code = c
default:
await super.handleLoadAllError(error)
return
}
guard code == 422 || code == 401 else {
await super.handleLoadAllError(error)
return
}
collectionView.isHidden = true
view.backgroundColor = .appBackground
@ -121,10 +136,6 @@ class InstanceTimelineViewController: TimelineViewController {
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
default:
await super.handleLoadAllError(error)
}
}
// MARK: Interaction

View File

@ -12,8 +12,6 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController
}
extension TuskerNavigationDelegate {
@ -82,16 +80,12 @@ extension TuskerNavigationDelegate {
}
}
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
return ConversationViewController(for: mainStatusID, state: state, mastodonController: apiController)
}
func selected(status statusID: String) {
self.selected(status: statusID, state: .unknown)
}
func selected(status statusID: String, state: CollapseState) {
show(conversation(mainStatusID: statusID, state: state), sender: self)
show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
}
func compose(editing draft: Draft, animated: Bool = true) {
@ -119,15 +113,11 @@ extension TuskerNavigationDelegate {
compose(editing: draft, animated: animated)
}
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
func showLoadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) {
let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description)
vc.animationSourceView = sourceView
vc.transitioningDelegate = self
return vc
}
func showLoadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) {
present(loadingLargeImage(url: url, cache: cache, description: description, animatingFrom: sourceView), animated: true)
present(vc, animated: true)
}
func gallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) -> GalleryViewController {
@ -183,16 +173,6 @@ extension TuskerNavigationDelegate {
present(vc, animated: true)
}
func showFollowedByList(accountIDs: [String]) {
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: apiController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
show(vc, sender: self)
}
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: CollapseState, accountIDs: [String]?) -> StatusActionAccountListViewController {
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
}
}
enum PopoverSource {

View File

@ -0,0 +1,50 @@
//
// CopyableLable.swift
// Tusker
//
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class CopyableLable: UILabel {
private var _editMenuInteraction: Any!
@available(iOS 16.0, *)
private var editMenuInteraction: UIEditMenuInteraction {
get { _editMenuInteraction as! UIEditMenuInteraction }
set { _editMenuInteraction = newValue }
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
if #available(iOS 16.0, *) {
editMenuInteraction = UIEditMenuInteraction(delegate: nil)
addInteraction(editMenuInteraction)
isUserInteractionEnabled = true
addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed)))
}
}
override func copy(_ sender: Any?) {
UIPasteboard.general.string = text
}
@available(iOS 16.0, *)
@objc private func longPressed(_ recognizer: UILongPressGestureRecognizer) {
if recognizer.state == .began {
editMenuInteraction.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: bounds.midX, y: bounds.midY)))
}
}
}

View File

@ -232,6 +232,31 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = ""
switch group.kind {
case .favourite:
str += "Favorited by "
case .reblog:
str += "Reblogged by "
default:
return nil
}
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric))), "
str += statusContentLabel.text ?? ""
return str
}
set {}
}
}
extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
@ -248,7 +273,7 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
default:
fatalError()
}
let vc = delegate.statusActionAccountList(action: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs)
let vc = StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
vc.showInacurateCountWarning = false
delegate.show(vc)
}
@ -257,9 +282,6 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return (content: {
guard let delegate = self.delegate else {
return nil
}
let notifications = self.group.notifications
let accountIDs = notifications.map { $0.account.id }
let action: StatusActionAccountListViewController.ActionType
@ -271,7 +293,7 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
default:
fatalError()
}
let vc = delegate.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
let vc = StatusActionAccountListViewController(actionType: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
vc.showInacurateCountWarning = false
return vc
}, actions: {

View File

@ -196,6 +196,22 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = "Followed by "
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric)))"
return str
}
set {}
}
}
extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
@ -207,7 +223,9 @@ extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
case 1:
delegate?.selected(account: accountIDs.first!)
default:
delegate?.showFollowedByList(accountIDs: accountIDs)
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
delegate?.show(vc)
}
}
}

View File

@ -145,6 +145,29 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
self.stackView.addArrangedSubview(label)
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Follow requested by "
str += notification.account.displayNameWithoutCustomEmoji
str += ", \(notification.createdAt.formatted(.relative(presentation: .numeric)))"
return str
}
set {}
}
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get {
return [
UIAccessibilityCustomAction(name: "Accept Request", target: self, selector: #selector(acceptButtonPressed)),
UIAccessibilityCustomAction(name: "Reject Request", target: self, selector: #selector(acceptButtonPressed)),
]
}
set {}
}
// MARK: - Interaction
@IBAction func rejectButtonPressed() {

View File

@ -55,7 +55,7 @@
<rect key="frame" x="0.0" y="0.0" width="115" height="26.5"/>
<accessibility key="accessibilityConfiguration" label="Accept Request"/>
<state key="normal" title=" Accept" image="checkmark.circle.fill" catalog="system">
<color key="titleColor" systemColor="systemBlueColor"/>
<color key="titleColor" systemColor="tintColor"/>
</state>
<connections>
<action selector="acceptButtonPressed" destination="Pcu-ap-Xqf" eventType="touchUpInside" id="hGw-3d-RNi"/>
@ -64,7 +64,7 @@
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="7MW-rY-m5l">
<rect key="frame" x="115" y="0.0" width="115" height="26.5"/>
<state key="normal" title=" Reject" image="xmark.circle.fill" catalog="system">
<color key="titleColor" systemColor="systemBlueColor"/>
<color key="titleColor" systemColor="tintColor"/>
</state>
<connections>
<action selector="rejectButtonPressed" destination="Pcu-ap-Xqf" eventType="touchUpInside" id="EP6-Bg-3nC"/>
@ -111,7 +111,7 @@
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBlueColor">
<systemColor name="tintColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>

View File

@ -103,6 +103,25 @@ class PollFinishedTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Poll from "
str += notification.account.displayNameWithoutCustomEmoji
str += " finished "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
if let poll = notification.status?.poll,
poll.options.contains(where: { ($0.votesCount ?? 0) > 0 }) {
let winner = poll.options.max(by: { ($0.votesCount ?? 0) < ($1.votesCount ?? 0) })!
str += ", winning option: \(winner.title)"
}
return str
}
set {}
}
}
extension PollFinishedTableViewCell: SelectableTableViewCell {
@ -111,8 +130,7 @@ extension PollFinishedTableViewCell: SelectableTableViewCell {
let status = notification?.status else {
return
}
let vc = delegate.conversation(mainStatusID: status.id, state: .unknown)
delegate.show(vc)
delegate.selected(status: status.id)
}
}
@ -124,7 +142,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
return nil
}
return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown)
ConversationViewController(for: statusID, state: .unknown, mastodonController: delegate.apiController)
}, actions: {
delegate.actionsForStatus(status, source: .view(self))
})

View File

@ -95,6 +95,22 @@ class StatusUpdatedNotificationTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil
}
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Post from "
str += notification.account.displayNameWithoutCustomEmoji
str += " edited "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
str += ", "
str += contentLabel.text ?? ""
return str
}
set {}
}
}
extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {
@ -103,8 +119,7 @@ extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {
let status = notification?.status else {
return
}
let vc = delegate.conversation(mainStatusID: status.id, state: .unknown)
delegate.show(vc)
delegate.selected(status: status.id)
}
}
@ -116,7 +131,7 @@ extension StatusUpdatedNotificationTableViewCell: MenuPreviewProvider {
return nil
}
return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown)
ConversationViewController(for: statusID, state: .unknown, mastodonController: delegate.apiController)
}, actions: {
delegate.actionsForStatus(status, source: .view(self))
})

View File

@ -110,7 +110,7 @@
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
<rect key="frame" x="144" y="235" width="103.5" height="23"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL" customClass="CopyableLable" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>

View File

@ -416,7 +416,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
guard let delegate else {
return
}
let vc = delegate.statusActionAccountList(action: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil)
let vc = StatusActionAccountListViewController(actionType: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil, mastodonController: mastodonController)
// TODO: only show warning if the instance isn't the logged in one
vc.showInacurateCountWarning = true
delegate.show(vc)
@ -426,7 +426,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
guard let delegate else {
return
}
let vc = delegate.statusActionAccountList(action: .reblog, statusID: statusID, statusState: statusState.copy(), accountIDs: nil)
let vc = StatusActionAccountListViewController(actionType: .favorite, statusID: statusID, statusState: statusState.copy(), accountIDs: nil, mastodonController: mastodonController)
vc.showInacurateCountWarning = true
delegate.show(vc)
}

View File

@ -772,7 +772,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
return UIContextMenuConfiguration {
ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
guard let delegate = self.delegate else {
return nil
}
return UIMenu(children: delegate.actionsForStatus(status, source: .view(self)))
}
}

View File

@ -56,37 +56,7 @@ extension ToastConfiguration {
viewController.present(reporter, animated: true)
}
// TODO: this is a bizarre place to do this, but code path covers basically all errors
switch error.type {
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_, _):
let event = Event(error: error)
event.message = SentryMessage(formatted: "\(title): \(error)")
event.tags = [
"request_method": error.requestMethod.name,
"request_endpoint": error.requestEndpoint.description,
]
switch error.type {
case .invalidRequest:
event.tags!["error_type"] = "invalid_request"
case .invalidResponse:
event.tags!["error_type"] = "invalid_response"
case .invalidModel(let error):
event.tags!["error_type"] = "invalid_model"
event.extra = [
"underlying_error": String(describing: error)
]
case .mastodonError(let code, let error):
event.tags!["error_type"] = "mastodon_error"
event.tags!["response_code"] = "\(code)"
event.extra = [
"underlying_error": String(describing: error)
]
default:
break
}
SentrySDK.capture(event: event)
default:
break
}
captureError(error, title: title)
} else {
self.subtitle = error.localizedDescription
self.systemImageName = "exclamationmark.triangle"
@ -116,3 +86,39 @@ fileprivate extension Pachyderm.Client.Error {
}
}
}
private func captureError(_ error: Client.Error, title: String) {
let event = Event(error: error)
event.message = SentryMessage(formatted: "\(title): \(error)")
event.tags = [
"request_method": error.requestMethod.name,
"request_endpoint": error.requestEndpoint.description,
]
switch error.type {
case .invalidRequest:
event.tags!["error_type"] = "invalid_request"
case .invalidResponse:
event.tags!["error_type"] = "invalid_response"
case .invalidModel(let error):
event.tags!["error_type"] = "invalid_model"
event.extra = [
"underlying_error": String(describing: error)
]
case .mastodonError(let code, let error):
event.tags!["error_type"] = "mastodon_error"
event.tags!["response_code"] = "\(code)"
event.extra = [
"underlying_error": String(describing: error)
]
case .unexpectedStatus(let code):
event.tags!["error_type"] = "unexpected_status"
event.tags!["response_code"] = "\(code)"
default:
return
}
if let code = event.tags!["response_code"],
code == "401" || code == "403" {
return
}
SentrySDK.capture(event: event)
}