diff --git a/CHANGELOG.md b/CHANGELOG.md index d32b9628..bbf43897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index b7a0a932..57422227 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -199,8 +199,10 @@ public class Client { return Request(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]> { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7f1dc1a5..3b947d25 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; - D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; + D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = ""; }; D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = ""; }; D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; @@ -703,6 +708,10 @@ D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; + D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; + D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; + D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = ""; }; + D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = ""; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = ""; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; @@ -738,6 +747,7 @@ D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = ""; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; + D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = ""; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = ""; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; @@ -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 = ""; }; - 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 = ""; }; 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 = ""; @@ -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; diff --git a/Tusker/MultiThreadDictionary.swift b/Tusker/MultiThreadDictionary.swift index 26392bb5..2b0147fa 100644 --- a/Tusker/MultiThreadDictionary.swift +++ b/Tusker/MultiThreadDictionary.swift @@ -13,20 +13,24 @@ import os // to make the lock semantics more clear @available(iOS, obsoleted: 16.0) class MultiThreadDictionary { - 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 { /// 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(_ 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 { - 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(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable { + return try lock.withLock { dict in + return try body(&dict) } } } diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index 78da9d3a..693c7ab0 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -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 diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index c4b56e13..156f3b24 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -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) diff --git a/Tusker/Screens/Compose/ComposeAutocompleteView.swift b/Tusker/Screens/Compose/ComposeAutocompleteView.swift index 05d5ee6e..94a5a902 100644 --- a/Tusker/Screens/Compose/ComposeAutocompleteView.swift +++ b/Tusker/Screens/Compose/ComposeAutocompleteView.swift @@ -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) } diff --git a/Tusker/Screens/Compose/ComposeEmojiTextField.swift b/Tusker/Screens/Compose/ComposeEmojiTextField.swift index add3b4d1..cfaceb8d 100644 --- a/Tusker/Screens/Compose/ComposeEmojiTextField.swift +++ b/Tusker/Screens/Compose/ComposeEmojiTextField.swift @@ -15,15 +15,17 @@ struct ComposeEmojiTextField: UIViewRepresentable { @Binding var text: String let placeholder: String + let maxLength: Int? let becomeFirstResponder: Binding? let focusNextView: Binding? private var didChange: ((String) -> Void)? = nil private var didEndEditing: (() -> Void)? = nil private var backgroundColor: UIColor? = nil - init(text: Binding, placeholder: String, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = nil) { + init(text: Binding, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding? = nil, focusNextView: Binding? = 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! // break retained cycle through ComposeUIState.currentInput unowned var uiState: ComposeUIState! + var maxLength: Int? var didChange: ((String) -> Void)? var didEndEditing: (() -> Void)? var focusNextView: Binding? @@ -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) diff --git a/Tusker/Screens/Compose/ComposePollView.swift b/Tusker/Screens/Compose/ComposePollView.swift index 07328877..6fb187b6 100644 --- a/Tusker/Screens/Compose/ComposePollView.swift +++ b/Tusker/Screens/Compose/ComposePollView.swift @@ -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 @@ -30,6 +31,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 { @@ -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) } diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift index a05a3bcd..519caa24 100644 --- a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift +++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift @@ -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() - 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) - } + + // convert sub-threads into items for section and add to 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) { 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, _, _): - selected(status: id, state: state.copy()) + 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.. UIContextMenuConfiguration? { return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() } diff --git a/Tusker/Screens/Conversation/ConversationTree.swift b/Tusker/Screens/Conversation/ConversationTree.swift new file mode 100644 index 00000000..19c47ba3 --- /dev/null +++ b/Tusker/Screens/Conversation/ConversationTree.swift @@ -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) + } + } + } +} diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 83595181..e1f3c4c8 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -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,9 +123,15 @@ class ConversationViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - Task { - if case .unloaded = state { - await loadMainStatus() + 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() + } } } } @@ -142,9 +156,20 @@ class ConversationViewController: UIViewController { // MARK: Loading + @MainActor private func loadMainStatus() async { - guard let mainStatusID = await resolveStatusIfNecessary() else { - return + 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 @@ -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,74 +199,94 @@ 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): - let indicator = UIActivityIndicatorView(style: .medium) - indicator.startAnimating() - state = .loading(indicator) - - let url = WebURL(url)!.serialized(excludingFragment: true) - let request = Client.search(query: url, types: [.statuses], resolve: true) - do { - let (results, _) = try await mastodonController.run(request) - guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else { - throw UnableToResolveError() - } - _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) - mode = .localID(status.id) - return status.id - } catch { - state = .unableToResolve(error) - return nil + private func resolveStatus(url: URL) async -> String? { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + state = .loading(indicator) + + let url = WebURL(url)!.serialized(excludingFragment: true) + let request = Client.search(query: url, types: [.statuses], resolve: true) + do { + let (results, _) = try await mastodonController.run(request) + guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else { + throw UnableToResolveError() } + _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) + mode = .localID(status.id) + return status.id + } catch { + state = .unableToResolve(error) + 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 } + 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) + } + + private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? { let request = Status.getContext(mainStatus.id) do { let (context, _) = try await mastodonController.run(request) - guard case .displaying(let vc) = state else { - return - } - - await vc.addContext(context, for: mainStatus) + 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) } } diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 506885ca..1d23eb31 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -165,9 +165,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect private func applyInitialSnapshot() { var snapshot = NSDiffableDataSourceSnapshot() 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) { 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) diff --git a/Tusker/Screens/Explore/InlineTrendsViewController.swift b/Tusker/Screens/Explore/InlineTrendsViewController.swift new file mode 100644 index 00000000..819b1454 --- /dev/null +++ b/Tusker/Screens/Explore/InlineTrendsViewController.swift @@ -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 + } + } + +} diff --git a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift index dfa0ca60..c880f865 100644 --- a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift @@ -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() } diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 86a572a1..e3a6201e 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -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! @@ -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() diff --git a/Tusker/Screens/Search/SearchViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift similarity index 85% rename from Tusker/Screens/Search/SearchViewController.swift rename to Tusker/Screens/Explore/TrendsViewController.swift index e66b2892..fc976107 100644 --- a/Tusker/Screens/Search/SearchViewController.swift +++ b/Tusker/Screens/Explore/TrendsViewController.swift @@ -1,42 +1,41 @@ // -// 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 { - - weak var mastodonController: MastodonController! +class TrendsViewController: UIViewController, CollectionViewController { + + let mastodonController: MastodonController var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! - var resultsController: SearchResultsViewController! - var searchController: UISearchController! - - var searchControllerStatusOnAppearance: Bool? = nil - private var loadTask: Task? + 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) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() @@ -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 { let sectionHeaderCell = UICollectionView.SupplementaryRegistration(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() { - loadTask?.cancel() - loadTask = Task { - await applySnapshot() + if isShowingTrends != shouldShowTrends { + loadTask?.cancel() + loadTask = Task { + 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() diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift index aa4ca4cd..408e10ce 100644 --- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift +++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift @@ -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) } } @@ -271,6 +283,14 @@ class FastAccountSwitcherViewController: UIViewController { super.touchesBegan(touches, with: event) } + + override func accessibilityPerformEscape() -> Bool { + guard !view.isHidden else { + return false + } + hide() + return true + } } diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index 3f1733c2..e5930cf7 100644 --- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift +++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift @@ -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() { diff --git a/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift b/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift new file mode 100644 index 00000000..b6701f7c --- /dev/null +++ b/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift @@ -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") + } + +} diff --git a/Tusker/Screens/Local Predicate Statuses List/FavoritesViewController.swift b/Tusker/Screens/Local Predicate Statuses List/FavoritesViewController.swift new file mode 100644 index 00000000..b257bebb --- /dev/null +++ b/Tusker/Screens/Local Predicate Statuses List/FavoritesViewController.swift @@ -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") + } + +} diff --git a/Tusker/Screens/Bookmarks/BookmarksViewController.swift b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift similarity index 85% rename from Tusker/Screens/Bookmarks/BookmarksViewController.swift rename to Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift index 301c8e01..665af8d4 100644 --- a/Tusker/Screens/Bookmarks/BookmarksViewController.swift +++ b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift @@ -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() - 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() - 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 { - 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 diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index b87b7e44..b6d7761f 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -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() - 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() - 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 diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 30329717..fde39326 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -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 } diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index aa867b55..ea750ccb 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -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() diff --git a/Tusker/Screens/Preferences/AppearancePrefsView.swift b/Tusker/Screens/Preferences/AppearancePrefsView.swift index 3cb22c6e..2fbd7a9f 100644 --- a/Tusker/Screens/Preferences/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/AppearancePrefsView.swift @@ -26,6 +26,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 { @@ -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) diff --git a/Tusker/Screens/Preferences/WellnessPrefsView.swift b/Tusker/Screens/Preferences/WellnessPrefsView.swift index 5b232d2b..03f97f00 100644 --- a/Tusker/Screens/Preferences/WellnessPrefsView.swift +++ b/Tusker/Screens/Preferences/WellnessPrefsView.swift @@ -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) diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index e4df090e..82f637b0 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -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) } } diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 18d6068e..659018f3 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -84,47 +84,58 @@ class InstanceTimelineViewController: TimelineViewController { // MARK: Timeline override func handleLoadAllError(_ error: Swift.Error) async { - switch (error as? Client.Error)?.type { - case .mastodonError(422, _), .unexpectedStatus(422): - collectionView.isHidden = true - view.backgroundColor = .appBackground - - let image = UIImageView(image: UIImage(systemName: "lock.fill")) - image.tintColor = .secondaryLabel - image.contentMode = .scaleAspectFit - - let title = UILabel() - title.textColor = .secondaryLabel - title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! - title.adjustsFontForContentSizeCategory = true - title.numberOfLines = 0 - title.textAlignment = .center - title.text = "This instance requires an account to view." - - let stack = UIStackView(arrangedSubviews: [ - image, - title, - ]) - stack.axis = .vertical - stack.alignment = .center - stack.spacing = 8 - stack.isAccessibilityElement = true - stack.accessibilityLabel = title.text! - - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - NSLayoutConstraint.activate([ - image.widthAnchor.constraint(equalToConstant: 64), - image.heightAnchor.constraint(equalToConstant: 64), - - stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1), - view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1), - stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - ]) - + 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 + + let image = UIImageView(image: UIImage(systemName: "lock.fill")) + image.tintColor = .secondaryLabel + image.contentMode = .scaleAspectFit + + let title = UILabel() + title.textColor = .secondaryLabel + title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! + title.adjustsFontForContentSizeCategory = true + title.numberOfLines = 0 + title.textAlignment = .center + title.text = "This instance requires an account to view." + + let stack = UIStackView(arrangedSubviews: [ + image, + title, + ]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 8 + stack.isAccessibilityElement = true + stack.accessibilityLabel = title.text! + + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + image.widthAnchor.constraint(equalToConstant: 64), + image.heightAnchor.constraint(equalToConstant: 64), + + stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1), + view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1), + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + ]) } // MARK: Interaction diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 61b93c3e..1c35e76a 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -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 { diff --git a/Tusker/Views/CopyableLable.swift b/Tusker/Views/CopyableLable.swift new file mode 100644 index 00000000..23d070e1 --- /dev/null +++ b/Tusker/Views/CopyableLable.swift @@ -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))) + } + } + +} diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift index 9c278922..ac1eb768 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift @@ -231,6 +231,31 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { updateTimestampWorkItem?.cancel() 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 {} + } } @@ -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: { diff --git a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift index 74bcbffc..ad8fec61 100644 --- a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift @@ -195,6 +195,22 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { updateTimestampWorkItem?.cancel() 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 {} + } } @@ -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) } } } diff --git a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift index 629de403..01485aaf 100644 --- a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift @@ -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() { diff --git a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.xib b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.xib index ca2e988c..ffe57b35 100644 --- a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.xib +++ b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.xib @@ -55,7 +55,7 @@ - + @@ -64,7 +64,7 @@