Compare commits

...

30 Commits

Author SHA1 Message Date
Shadowfacts afed69e43e Bump build number and update changelog 2023-02-05 19:50:21 -05:00
Shadowfacts b2096f22c3 Rename Hide Discover Section pref to Hide Trends 2023-02-05 14:43:04 -05:00
Shadowfacts 14c456df22 Tweak trends orthogonal scroll behavior 2023-02-05 14:41:10 -05:00
Shadowfacts 3f34357692 Fix discover section sometimes appearing on non-Mastodon instances 2023-02-05 14:36:09 -05:00
Shadowfacts 429dcefa88 Use consolidated trends screen on iPhone 2023-02-05 14:34:01 -05:00
Shadowfacts d1a35620c9 Remove profile directory
The code remains for now, in case it needs to return
2023-02-05 14:27:26 -05:00
Shadowfacts ce741d6e1f Extract trends to separate VC 2023-02-05 14:23:29 -05:00
Shadowfacts 5a82851fe9 Fix custom emoji picker buttons not having accessibility labels
Closes #286
2023-02-05 14:00:08 -05:00
Shadowfacts 92ff900bc0 Improve VoiceOver labels for notifications
Closes #350
2023-02-05 13:56:48 -05:00
Shadowfacts 2a1deb8d7d Fix follow request accept/reject buttons not matching accent color 2023-02-05 13:55:31 -05:00
Shadowfacts 38eea44a8b VoiceOver improvements on fast account switcher
Closes #310
2023-02-05 13:33:42 -05:00
Shadowfacts 2d45fbbd91 Apply Mastodon poll limits in Compose view 2023-02-05 12:43:51 -05:00
Shadowfacts 32382c4783 Fix crash when previewing status cell that doesn't have delegate
Not sure how this is possible, but w/e
2023-02-05 11:27:03 -05:00
Shadowfacts 521c46c0be Don't capture certain error types 2023-02-05 11:23:10 -05:00
Shadowfacts c114749519 Handle 401 errors on instance timelines 2023-02-05 11:18:23 -05:00
Shadowfacts 825424cfba Fix crash when tapping My Profile tab before view is loaded
Closes #352
2023-02-05 11:09:08 -05:00
Shadowfacts 985eb24e88 Remove workaround for compiler bug breaking constrained existential types on iOS 15 release builds
Closes #178
2023-02-05 11:04:11 -05:00
Shadowfacts 7cadcf1e86 Reuse conversation tree where possible when selecting a status in a conversation 2023-02-04 15:15:41 -05:00
Shadowfacts a314521b96 Extract out conversation tree-building code 2023-02-04 13:49:20 -05:00
Shadowfacts ab3bad0e16 Fix trending statuses not being deselected on navigation back 2023-02-04 13:48:24 -05:00
Shadowfacts ec75906bc1 Add favorites screen
Closes #327
2023-02-04 13:21:58 -05:00
Shadowfacts 137a537f68 Extract loading and local updating handling code from bookmarks VC into separate VC 2023-02-04 13:14:08 -05:00
Shadowfacts 91123fd24a Make username label on profile copyable 2023-02-04 11:10:01 -05:00
Shadowfacts 597dd56032 Fix crash on split VC collapse/expand while in explore tab
Also fix iPad explore VC resetting when leaving/reopening the app

Closes #351
2023-02-03 18:30:37 -05:00
Shadowfacts 37847a2f9f Fix accent color circles not showing on iOS 15 2023-02-02 23:36:47 -05:00
Shadowfacts af8a9faaeb Cleanup PreferencesView 2023-02-02 23:14:19 -05:00
Shadowfacts 76268e7a14 Make attachment description selectable in gallery 2023-01-31 14:17:59 -05:00
Shadowfacts 29596180a1 Using async/await for ImageCache implementation 2023-01-31 09:56:13 -05:00
Shadowfacts ebfd8b3efd Fix bookmarks VC sometimes going haywire 2023-01-30 10:07:34 -05:00
Shadowfacts 509acbde19 Fix status action account list VC not resizing on rotation 2023-01-29 16:02:47 -05:00
43 changed files with 1121 additions and 652 deletions

View File

@ -1,5 +1,29 @@
# Changelog # 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) ## 2023.4 (71)
Features/Improvements: Features/Improvements:
- Allow pinning instance public timelines to the Home tab - Allow pinning instance public timelines to the Home tab

View File

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

View File

@ -216,7 +216,7 @@
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; }; D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; }; D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.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 */; }; D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; };
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; }; D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.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 */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.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 */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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 */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -321,6 +325,7 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; }; D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; }; D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
@ -622,7 +627,7 @@
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; }; D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; }; D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; };
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; }; D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; };
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; }; D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
@ -701,6 +706,10 @@
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -734,6 +743,7 @@
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; }; D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; }; D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; }; D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -917,6 +927,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */, D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */, D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */, D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */, D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
@ -931,16 +942,19 @@
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */, D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */, D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */, D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
); );
path = Explore; path = Explore;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D627944823A6AD5100D38C68 /* Bookmarks */ = { D627944823A6AD5100D38C68 /* Local Predicate Statuses List */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */, D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
); );
path = Bookmarks; path = "Local Predicate Statuses List";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D627944B23A9A02400D38C68 /* Lists */ = { D627944B23A9A02400D38C68 /* Lists */ = {
@ -1003,7 +1017,6 @@
D6A3BC822321F69400FD64D5 /* Account List */, D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */, D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */, 0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */, D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */, D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */, D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1012,6 +1025,7 @@
D61F759729384D4200C0B37F /* Customize Timelines */, D61F759729384D4200C0B37F /* Customize Timelines */,
D641C788213DD86D004B4513 /* Large Image */, D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */, D627944B23A9A02400D38C68 /* Lists */,
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */,
D641C782213DD7F0004B4513 /* Main */, D641C782213DD7F0004B4513 /* Main */,
D6F6A555291F4F0C00F496A8 /* Mute */, D6F6A555291F4F0C00F496A8 /* Mute */,
D641C786213DD852004B4513 /* Notifications */, D641C786213DD852004B4513 /* Notifications */,
@ -1080,6 +1094,7 @@
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */, D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */, D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */, D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
); );
path = Conversation; path = Conversation;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1399,7 +1414,6 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = { D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
); );
path = Search; path = Search;
@ -1414,6 +1428,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1936,6 +1951,7 @@
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */, D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
@ -1946,7 +1962,7 @@
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */, D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */, D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */, D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */,
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */, D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */, D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
@ -2015,6 +2031,7 @@
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */, D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */, D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
@ -2117,6 +2134,7 @@
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */, D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
@ -2147,6 +2165,7 @@
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */, D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */, D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
@ -2162,6 +2181,7 @@
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */, D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */, D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */, D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */, D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */, D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */, D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
@ -2388,7 +2408,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2453,7 +2473,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2604,7 +2624,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2632,7 +2652,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2737,7 +2757,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2763,7 +2783,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71; CURRENT_PROJECT_VERSION = 72;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -32,46 +32,38 @@ class ImageCache {
} }
func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? { func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
let key = url.absoluteString
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion {
wrappedCompletion = { (data, image) in
if let image {
if !loadOriginal,
let size = self.desiredPixelSize {
image.prepareThumbnail(of: size) {
completion(data, $0)
}
} else {
image.prepareForDisplay {
completion(data, $0)
}
}
} else {
completion(data, image)
}
}
} else {
wrappedCompletion = nil
}
if !ImageCache.disableCaching, if !ImageCache.disableCaching,
let entry = try? cache.get(key, loadOriginal: loadOriginal) { let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
wrappedCompletion?(entry.data, entry.image) completion?(entry.data, entry.image)
return nil return nil
} else { } else {
let task = dataTask(url: url, completion: wrappedCompletion) return Task.detached(priority: .userInitiated) {
task.resume() let result = await self.fetch(url: url)
return task switch result {
case .data(let data):
completion?(data, nil)
case .dataAndImage(let data, let image):
completion?(data, image)
case .none:
completion?(nil, nil)
}
}
} }
} }
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) { func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
// todo: this should integrate with the task cancellation mechanism somehow if !ImageCache.disableCaching,
return await withCheckedContinuation { continuation in let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
_ = get(url, loadOriginal: loadOriginal) { data, image in return (entry.data, entry.image)
continuation.resume(returning: (data, image)) } else {
let result = await self.fetch(url: url)
switch result {
case .data(let data):
return (data, nil)
case .dataAndImage(let data, let image):
return (data, image)
case .none:
return (nil, nil)
} }
} }
} }
@ -81,21 +73,28 @@ class ImageCache {
guard !ImageCache.disableCaching else { return } guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false) { if !((try? cache.has(url.absoluteString)) ?? false) {
let task = dataTask(url: url, completion: nil) Task.detached(priority: .medium) {
task.resume() _ = await self.fetch(url: url)
}
} }
} }
private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask { private func fetch(url: URL) async -> FetchResult {
return URLSession.shared.dataTask(with: url) { data, response, error in guard let (data, _) = try? await URLSession.shared.data(from: url) else {
guard error == nil, return .none
let data else {
return
} }
let image = UIImage(data: data) guard let image = UIImage(data: data) else {
try? self.cache.set(url.absoluteString, data: data, image: image) try? cache.set(url.absoluteString, data: data, image: nil)
completion?(data, image) return .data(data)
} }
let preparedImage: UIImage?
if let desiredPixelSize {
preparedImage = await image.byPreparingThumbnail(ofSize: desiredPixelSize)
} else {
preparedImage = await image.byPreparingForDisplay()
}
try? cache.set(url.absoluteString, data: data, image: preparedImage ?? image)
return .dataAndImage(data, preparedImage ?? image)
} }
func getData(_ url: URL) -> Data? { func getData(_ url: URL) -> Data? {
@ -114,6 +113,12 @@ class ImageCache {
return cache.disk?.getSizeInBytes() return cache.disk?.getSizeInBytes()
} }
typealias Request = URLSessionDataTask typealias Request = Task<Void, Never>
enum FetchResult {
case data(Data)
case dataAndImage(Data, UIImage)
case none
}
} }

View File

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

View File

@ -80,7 +80,7 @@ class Preferences: Codable, ObservableObject {
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? 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) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -130,7 +130,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages) try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling) try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideDiscover, forKey: .hideDiscover) try container.encode(hideTrends, forKey: .hideTrends)
try container.encode(statusContentType, forKey: .statusContentType) try container.encode(statusContentType, forKey: .statusContentType)
@ -190,7 +190,7 @@ class Preferences: Codable, ObservableObject {
@Published var defaultNotificationsMode = NotificationsMode.allNotifications @Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false @Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false @Published var disableInfiniteScrolling = false
@Published var hideDiscover = false @Published var hideTrends = false
// MARK: Advanced // MARK: Advanced
@Published var statusContentType: StatusContentType = .plain @Published var statusContentType: StatusContentType = .plain
@ -241,7 +241,7 @@ class Preferences: Codable, ObservableObject {
case defaultNotificationsType case defaultNotificationsType
case grayscaleImages case grayscaleImages
case disableInfiniteScrolling case disableInfiniteScrolling
case hideDiscover case hideTrends = "hideDiscover"
case statusContentType case statusContentType

View File

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

View File

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

View File

@ -15,15 +15,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
@Binding var text: String @Binding var text: String
let placeholder: String let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>? let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>? let focusNextView: Binding<Bool>?
private var didChange: ((String) -> Void)? = nil private var didChange: ((String) -> Void)? = nil
private var didEndEditing: (() -> Void)? = nil private var didEndEditing: (() -> Void)? = nil
private var backgroundColor: UIColor? = nil private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) { init(text: Binding<String>, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
self._text = text self._text = text
self.placeholder = placeholder self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView self.focusNextView = focusNextView
self.didChange = nil self.didChange = nil
@ -74,6 +76,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
} else { } else {
uiView.text = text uiView.text = text
} }
context.coordinator.maxLength = maxLength
context.coordinator.didChange = didChange context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing context.coordinator.didEndEditing = didEndEditing
context.coordinator.focusNextView = focusNextView context.coordinator.focusNextView = focusNextView
@ -95,6 +98,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
var text: Binding<String>! var text: Binding<String>!
// break retained cycle through ComposeUIState.currentInput // break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState! unowned var uiState: ComposeUIState!
var maxLength: Int?
var didChange: ((String) -> Void)? var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)? var didEndEditing: (() -> Void)?
var focusNextView: Binding<Bool>? var focusNextView: Binding<Bool>?
@ -114,6 +118,14 @@ struct ComposeEmojiTextField: UIViewRepresentable {
focusNextView?.wrappedValue = true 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) { func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.currentInput = self uiState.currentInput = self
updateAutocompleteState(textField: textField) updateAutocompleteState(textField: textField)

View File

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

View File

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

View File

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

View File

@ -77,6 +77,14 @@ class ConversationViewController: UIViewController {
super.init(nibName: nil, bundle: nil) 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) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@ -115,12 +123,18 @@ class ConversationViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task {
if case .unloaded = state { 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() await loadMainStatus()
} }
} }
} }
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
@ -142,10 +156,21 @@ class ConversationViewController: UIViewController {
// MARK: Loading // MARK: Loading
@MainActor
private func loadMainStatus() async { private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else { let mainStatusID: String
switch mode {
case .localID(let id):
mainStatusID = id
case .resolve(let url):
if let id = await resolveStatus(url: url) {
mainStatusID = id
} else {
return return
} }
case .preloaded(_):
fatalError("unreachable")
}
@MainActor @MainActor
func doLoadMainStatus() async -> StatusMO? { func doLoadMainStatus() async -> StatusMO? {
@ -166,7 +191,7 @@ class ConversationViewController: UIViewController {
Task { Task {
await doLoadMainStatus() await doLoadMainStatus()
} }
await mainStatusLoaded(cached) mainStatusLoaded(cached)
} else { } else {
// otherwise, show a loading indicator while loading the main status // otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium) let indicator = UIActivityIndicatorView(style: .medium)
@ -174,17 +199,13 @@ class ConversationViewController: UIViewController {
state = .loading(indicator) state = .loading(indicator)
if let status = await doLoadMainStatus() { if let status = await doLoadMainStatus() {
await mainStatusLoaded(status) mainStatusLoaded(status)
} }
} }
} }
@MainActor @MainActor
private func resolveStatusIfNecessary() async -> String? { private func resolveStatus(url: URL) async -> String? {
switch mode {
case .localID(let id):
return id
case .resolve(let url):
let indicator = UIActivityIndicatorView(style: .medium) let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating() indicator.startAnimating()
state = .loading(indicator) state = .loading(indicator)
@ -204,44 +225,68 @@ class ConversationViewController: UIViewController {
return nil return nil
} }
} }
}
@MainActor private func mainStatusLoaded(_ mainStatus: StatusMO) {
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController) let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus) vc.addMainStatus(mainStatus)
state = .displaying(vc) 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 @MainActor
private func loadContext(for mainStatus: StatusMO) async { private func loadTree(for mainStatus: StatusMO) async {
guard case .displaying(_) = state else { guard case .displaying(_) = state,
let context = await loadContext(for: mainStatus) else {
return return
} }
let request = Status.getContext(mainStatus.id) await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
do {
let (context, _) = try await mastodonController.run(request) 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 { guard case .displaying(let vc) = state else {
return return
} }
vc.addTree(tree, mainStatus: mainStatus)
}
await vc.addContext(context, for: mainStatus) private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
let request = Status.getContext(mainStatus.id)
do {
let (context, _) = try await mastodonController.run(request)
return context
} catch { } catch {
guard case .displaying(_) = state else { guard case .displaying(_) = state else {
return return nil
} }
let error = error as! Client.Error let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus) await self?.loadTree(for: mainStatus)
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
return nil
} }
} }
@ -341,6 +386,7 @@ extension ConversationViewController {
enum Mode { enum Mode {
case localID(String) case localID(String)
case resolve(URL) case resolve(URL)
case preloaded(ConversationTree)
} }
} }

View File

@ -154,9 +154,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
private func applyInitialSnapshot() { private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks) snapshot.appendItems([.bookmarks, .favorites], toSection: .bookmarks)
if mastodonController.instanceFeatures.trends, if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover { !Preferences.shared.hideTrends {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems([.addList], toSection: .lists)
@ -175,19 +175,17 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) { private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.insertSections([.discover], afterSection: .bookmarks) snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover) snapshot.appendItems([.trends], toSection: .discover)
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
snapshot.insertItems([.trendingStatuses], beforeItem: .trendingTags)
snapshot.insertItems([.trendingLinks], afterItem: .trendingTags)
}
} }
private func ownInstanceLoaded(_ instance: Instance) { private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if mastodonController.instanceFeatures.trends, if mastodonController.instanceFeatures.trends,
!snapshot.sectionIdentifiers.contains(.discover) { !snapshot.sectionIdentifiers.contains(.discover) {
snapshot.insertSections([.discover], afterSection: .bookmarks) addDiscoverSection(to: &snapshot)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover) } else if !mastodonController.instanceFeatures.trends,
snapshot.sectionIdentifiers.contains(.discover) {
snapshot.deleteSections([.discover])
} }
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
} }
@ -250,7 +248,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
let hasSection = snapshot.sectionIdentifiers.contains(.discover) let hasSection = snapshot.sectionIdentifiers.contains(.discover)
let hide = Preferences.shared.hideDiscover let hide = Preferences.shared.hideTrends
if hasSection && hide { if hasSection && hide {
snapshot.deleteSections([.discover]) snapshot.deleteSections([.discover])
} else if !hasSection && !hide { } else if !hasSection && !hide {
@ -336,17 +334,11 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
case .bookmarks: case .bookmarks:
show(BookmarksViewController(mastodonController: mastodonController), sender: nil) show(BookmarksViewController(mastodonController: mastodonController), sender: nil)
case .trendingStatuses: case .favorites:
show(TrendingStatusesViewController(mastodonController: mastodonController), sender: nil) show(FavoritesViewController(mastodonController: mastodonController), sender: nil)
case .trendingTags: case .trends:
show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil) show(TrendsViewController(mastodonController: mastodonController), sender: nil)
case .trendingLinks:
show(TrendingLinksViewController(mastodonController: mastodonController), sender: nil)
case .profileDirectory:
show(ProfileDirectoryViewController(mastodonController: mastodonController), sender: nil)
case let .list(list): case let .list(list):
show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil) show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil)
@ -395,7 +387,7 @@ extension ExploreViewController {
case .bookmarks: case .bookmarks:
return nil return nil
case .discover: case .discover:
return NSLocalizedString("Discover", comment: "discover section title") return nil
case .lists: case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title") return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags: case .savedHashtags:
@ -408,10 +400,8 @@ extension ExploreViewController {
enum Item: Hashable { enum Item: Hashable {
case bookmarks case bookmarks
case trendingStatuses case favorites
case trendingTags case trends
case trendingLinks
case profileDirectory
case list(List) case list(List)
case addList case addList
case savedHashtag(Hashtag) case savedHashtag(Hashtag)
@ -423,14 +413,10 @@ extension ExploreViewController {
switch self { switch self {
case .bookmarks: case .bookmarks:
return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title") return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title")
case .trendingStatuses: case .favorites:
return NSLocalizedString("Trending Posts", comment: "trending statuses nav item title") return NSLocalizedString("Favorites", comment: "favorites nav item title")
case .trendingTags: case .trends:
return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title") return NSLocalizedString("Trends", comment: "trends 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 let .list(list): case let .list(list):
return list.title return list.title
case .addList: case .addList:
@ -451,14 +437,10 @@ extension ExploreViewController {
switch self { switch self {
case .bookmarks: case .bookmarks:
name = "bookmark.fill" name = "bookmark.fill"
case .trendingStatuses: case .favorites:
name = "doc.text.image" name = "star.fill"
case .trendingTags: case .trends:
name = "number" name = "chart.line.uptrend.xyaxis"
case .trendingLinks:
name = "link"
case .profileDirectory:
name = "person.2.fill"
case .list(_): case .list(_):
name = "list.bullet" name = "list.bullet"
case .addList, .addSavedHashtag: case .addList, .addSavedHashtag:
@ -477,13 +459,9 @@ extension ExploreViewController {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.bookmarks, .bookmarks): case (.bookmarks, .bookmarks):
return true return true
case (.trendingStatuses, .trendingStatuses): case (.favorites, .favorites):
return true return true
case (.trendingTags, .trendingTags): case (.trends, .trends):
return true
case (.trendingLinks, .trendingLinks):
return true
case (.profileDirectory, .profileDirectory):
return true return true
case let (.list(a), .list(b)): case let (.list(a), .list(b)):
return a.id == b.id && a.title == b.title return a.id == b.id && a.title == b.title
@ -506,14 +484,10 @@ extension ExploreViewController {
switch self { switch self {
case .bookmarks: case .bookmarks:
hasher.combine("bookmarks") hasher.combine("bookmarks")
case .trendingStatuses: case .favorites:
hasher.combine("trendingStatuses") hasher.combine("favorites")
case .trendingTags: case .trends:
hasher.combine("trendingTags") hasher.combine("trends")
case .trendingLinks:
hasher.combine("trendingLinks")
case .profileDirectory:
hasher.combine("profileDirectory")
case let .list(list): case let .list(list):
hasher.combine("list") hasher.combine("list")
hasher.combine(list.id) hasher.combine(list.id)

View File

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

View File

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

View File

@ -1,36 +1,35 @@
// //
// SearchViewController.swift // TrendsViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 6/24/20. // Created by Shadowfacts on 2/5/23.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2023 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import Pachyderm import Pachyderm
import SafariServices import SafariServices
import WebURLFoundationExtras
class SearchViewController: UIViewController, CollectionViewController { class TrendsViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController! let mastodonController: MastodonController
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var isShowingTrends = false
private var shouldShowTrends: Bool {
mastodonController.instanceFeatures.trends && !Preferences.shared.hideTrends
}
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Explore", comment: "explore tab title") title = "Trends"
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -53,9 +52,9 @@ class SearchViewController: UIViewController, CollectionViewController {
let item = NSCollectionLayoutItem(layoutSize: itemSize) let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280)) let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 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) let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary section.orthogonalScrollingBehavior = .groupPaging
section.boundarySupplementaryItems = [ section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
] ]
@ -67,9 +66,9 @@ class SearchViewController: UIViewController, CollectionViewController {
let item = NSCollectionLayoutItem(layoutSize: itemSize) let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(250)) let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(350), heightDimension: .absolute(250))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) 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) let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary section.orthogonalScrollingBehavior = .groupPaging
section.boundarySupplementaryItems = [ section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading) NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
] ]
@ -92,53 +91,9 @@ class SearchViewController: UIViewController, CollectionViewController {
dataSource = createDataSource() 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) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
loadTask?.cancel()
loadTask = Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot()
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// this is a workaround for the issue that setting isActive on a search controller that is not visible
// does not cause it to automatically become active once it becomes visible
// see FB7814561
if let active = searchControllerStatusOnAppearance {
searchController.isActive = active
searchControllerStatusOnAppearance = nil
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
@ -188,10 +143,27 @@ class SearchViewController: UIViewController, CollectionViewController {
return dataSource 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 @MainActor
private func applySnapshot() async { private func loadTrends() async {
guard mastodonController.instanceFeatures.trends, guard isShowingTrends != shouldShowTrends else {
!Preferences.shared.hideDiscover else { return
}
isShowingTrends = shouldShowTrends
guard shouldShowTrends else {
await dataSource.apply(NSDiffableDataSourceSnapshot()) await dataSource.apply(NSDiffableDataSourceSnapshot())
return return
} }
@ -244,9 +216,11 @@ class SearchViewController: UIViewController, CollectionViewController {
} }
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
if isShowingTrends != shouldShowTrends {
loadTask?.cancel() loadTask?.cancel()
loadTask = Task { loadTask = Task {
await applySnapshot() await loadTrends()
}
} }
} }
@ -273,10 +247,9 @@ class SearchViewController: UIViewController, CollectionViewController {
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
} }
} }
extension SearchViewController { extension TrendsViewController {
enum Section { enum Section {
case trendingHashtags case trendingHashtags
case trendingLinks case trendingLinks
@ -302,7 +275,7 @@ extension SearchViewController {
case link(Card) case link(Card)
case account(String, Suggestion.Source) case account(String, Suggestion.Source)
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool { static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(a, _), .status(b, _)): case let (.status(a, _), .status(b, _)):
return a == b return a == b
@ -336,7 +309,7 @@ extension SearchViewController {
} }
} }
extension SearchViewController: UICollectionViewDelegate { extension TrendsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { guard let item = dataSource.itemIdentifier(for: indexPath) else {
return return
@ -443,7 +416,7 @@ extension SearchViewController: UICollectionViewDelegate {
} }
} }
extension SearchViewController: UICollectionViewDragDelegate { extension TrendsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath) else { guard let item = dataSource.itemIdentifier(for: indexPath) else {
return [] return []
@ -490,17 +463,17 @@ extension SearchViewController: UICollectionViewDragDelegate {
} }
} }
extension SearchViewController: TuskerNavigationDelegate { extension TrendsViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController } 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)?) { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) { if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()

View File

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

View File

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

View File

@ -28,7 +28,7 @@
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a"> <view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/> <rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
</view> </view>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh">
<rect key="frame" x="0.0" y="517" width="375" height="150"/> <rect key="frame" x="0.0" y="517" width="375" height="150"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/> <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/>
<constraints> <constraints>

View File

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

View File

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

View File

@ -1,20 +1,23 @@
// //
// BookmarksViewController.swift // LocalPredicateStatusesViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 12/15/19. // Created by Shadowfacts on 2/4/23.
// Copyright © 2019 Shadowfacts. All rights reserved. // Copyright © 2023 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import Pachyderm import Pachyderm
import CoreData import CoreData
class BookmarksViewController: UIViewController, CollectionViewController, RefreshableViewController { class LocalPredicateStatusesViewController: UIViewController, CollectionViewController, RefreshableViewController {
private static let pageSize = 40 private static let pageSize = 40
let mastodonController: MastodonController let mastodonController: MastodonController
private let predicate: (StatusMO) -> Bool
private let predicateTitle: String
private let request: (RequestRange) -> Request<[Status]>
var collectionView: UICollectionView! { var collectionView: UICollectionView! {
view as? UICollectionView view as? UICollectionView
@ -25,12 +28,15 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
private var newer: RequestRange? private var newer: RequestRange?
private var older: 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.mastodonController = mastodonController
self.predicate = predicate
self.predicateTitle = predicateTitle
self.request = request
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title") self.title = predicateTitle
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -50,7 +56,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
return sectionConfig return sectionConfig
} }
var config = sectionConfig var config = sectionConfig
if item.hideIndicators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else { } else {
@ -100,7 +106,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif #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(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
@ -129,12 +135,12 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
state = .loadingInitial state = .loadingInitial
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.bookmarks]) snapshot.appendSections([.statuses])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await apply(snapshot: snapshot, animatingDifferences: false) await apply(snapshot: snapshot, animatingDifferences: false)
do { do {
let req = Client.getBookmarks(range: .count(BookmarksViewController.pageSize)) let req = request(.count(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req) let (statuses, pagination) = try await mastodonController.run(req)
newer = pagination?.newer newer = pagination?.newer
older = pagination?.older older = pagination?.older
@ -142,14 +148,14 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.bookmarks]) snapshot.appendSections([.statuses])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) }) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) })
await apply(snapshot: snapshot, animatingDifferences: true) await apply(snapshot: snapshot, animatingDifferences: true)
state = .loaded state = .loaded
} catch { } 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) toast.dismissToast(animated: true)
await self?.loadInitial() await self?.loadInitial()
} }
@ -174,7 +180,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
await apply(snapshot: snapshot, animatingDifferences: false) await apply(snapshot: snapshot, animatingDifferences: false)
do { 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) let (statuses, pagination) = try await mastodonController.run(req)
self.older = pagination?.older self.older = pagination?.older
@ -184,12 +190,13 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) }) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, addedLocally: false) })
await apply(snapshot: snapshot, animatingDifferences: true) await apply(snapshot: snapshot, animatingDifferences: true)
} catch { } 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) toast.dismissToast(animated: true)
await self?.loadOlder() await self?.loadOlder()
} }
showToast(configuration: config, animated: true) showToast(configuration: config, animated: true)
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator]) snapshot.deleteItems([.loadingIndicator])
await apply(snapshot: snapshot, animatingDifferences: false) await apply(snapshot: snapshot, animatingDifferences: false)
} }
@ -219,6 +226,11 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
} }
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
// only perform local updates while the vc is idle
// otherwise loading the bookmarks ends up inserting them out of order
guard case .loaded = state else {
return
}
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
func prepend(item: Item) { func prepend(item: Item) {
if let first = snapshot.itemIdentifiers.first { if let first = snapshot.itemIdentifiers.first {
@ -229,7 +241,7 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
} }
var hasChanges = false var hasChanges = false
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> { if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
for case let status as StatusMO in inserted where status.bookmarked == true { for case let status as StatusMO in inserted where predicate(status) {
prepend(item: .status(id: status.id, state: .unknown, addedLocally: true)) prepend(item: .status(id: status.id, state: .unknown, addedLocally: true))
hasChanges = true hasChanges = true
} }
@ -237,11 +249,11 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
if let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> { if let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> {
for case let status as StatusMO in updated { for case let status as StatusMO in updated {
let item = Item.status(id: status.id, state: .unknown, addedLocally: true) let item = Item.status(id: status.id, state: .unknown, addedLocally: true)
var exists = snapshot.itemIdentifiers.contains(item) let exists = snapshot.itemIdentifiers.contains(item)
if status.bookmarked == true && !exists { if predicate(status) && !exists {
prepend(item: item) prepend(item: item)
hasChanges = true hasChanges = true
} else if status.bookmarked == false && exists { } else if !predicate(status) && exists {
snapshot.deleteItems([item]) snapshot.deleteItems([item])
hasChanges = true hasChanges = true
} }
@ -267,22 +279,21 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
state = .loadingNewer state = .loadingNewer
Task { Task {
do { 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) let (statuses, pagination) = try await mastodonController.run(req)
self.newer = pagination?.newer self.newer = pagination?.newer
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
var snapshot = dataSource.snapshot() 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 { if case .status(id: let id, state: let state, addedLocally: true) = $0 {
return (id, state) return (id, state)
} else { } else {
return nil return nil
} }
})) })
var newItems: [Item] = [] var newItems: [Item] = []
for status in statuses { for status in statuses {
let state: CollapseState let state: CollapseState
@ -300,8 +311,9 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
snapshot.appendItems(newItems) snapshot.appendItems(newItems)
} }
await apply(snapshot: snapshot, animatingDifferences: true) await apply(snapshot: snapshot, animatingDifferences: true)
} catch { } 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) toast.dismissToast(animated: true)
self?.refresh() self?.refresh()
} }
@ -317,15 +329,15 @@ class BookmarksViewController: UIViewController, CollectionViewController, Refre
} }
extension BookmarksViewController { extension LocalPredicateStatusesViewController {
enum Section { enum Section {
case bookmarks case statuses
} }
enum Item: Equatable, Hashable { enum Item: Equatable, Hashable {
case status(id: String, state: CollapseState, addedLocally: Bool) case status(id: String, state: CollapseState, addedLocally: Bool)
case loadingIndicator case loadingIndicator
var hideIndicators: Bool { var hideSeparators: Bool {
switch self { switch self {
case .loadingIndicator: case .loadingIndicator:
return true return true
@ -357,7 +369,7 @@ extension BookmarksViewController {
} }
} }
extension BookmarksViewController { extension LocalPredicateStatusesViewController {
enum State { enum State {
case unloaded case unloaded
case loadingInitial case loadingInitial
@ -367,7 +379,7 @@ extension BookmarksViewController {
} }
} }
extension BookmarksViewController: UICollectionViewDelegate { extension LocalPredicateStatusesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if indexPath.section == 0, if indexPath.section == 0,
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
@ -392,7 +404,7 @@ extension BookmarksViewController: UICollectionViewDelegate {
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { 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) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
@ -400,17 +412,17 @@ extension BookmarksViewController: UICollectionViewDelegate {
} }
} }
extension BookmarksViewController: UICollectionViewDragDelegate { extension LocalPredicateStatusesViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { 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 } var apiController: MastodonController! { mastodonController }
} }
extension BookmarksViewController: StatusCollectionViewCellDelegate { extension LocalPredicateStatusesViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) { if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
@ -420,17 +432,17 @@ extension BookmarksViewController: StatusCollectionViewCellDelegate {
} }
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
// bookmarks aren't filtered // filtering isn't supported here
} }
} }
extension BookmarksViewController: TabBarScrollableViewController { extension LocalPredicateStatusesViewController: TabBarScrollableViewController {
func tabBarScrollToTop() { func tabBarScrollToTop() {
collectionView.scrollToTop() collectionView.scrollToTop()
} }
} }
extension BookmarksViewController: StatusBarTappableViewController { extension LocalPredicateStatusesViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop() collectionView.scrollToTop()
return .stop return .stop

View File

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

View File

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

View File

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

View File

@ -29,6 +29,21 @@ struct AppearancePrefsView : View {
Preferences.shared.avatarStyle = $0 ? .circle : .roundRect 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 { var body: some View {
List { List {
themeSection themeSection
@ -48,12 +63,12 @@ struct AppearancePrefsView : View {
} }
Picker(selection: accentColor, label: Text("Accent Color")) { Picker(selection: accentColor, label: Text("Accent Color")) {
ForEach(Preferences.AccentColor.allCases, id: \.rawValue) { color in ForEach(accentColorsAndImages, id: \.0.rawValue) { (color, image) in
HStack { HStack {
Text(color.name) Text(color.name)
if let color = color.color { if let image {
Spacer() Spacer()
Image(uiImage: UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal)) Image(uiImage: image)
} }
} }
.tag(color) .tag(color)

View File

@ -6,11 +6,11 @@
// //
import SwiftUI import SwiftUI
import TTTKit
struct PreferencesView: View { struct PreferencesView: View {
let mastodonController: MastodonController let mastodonController: MastodonController
@ObservedObject var localData = LocalData.shared
@ObservedObject private var localData = LocalData.shared
@State private var showingLogoutConfirmation = false @State private var showingLogoutConfirmation = false
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
@ -18,10 +18,18 @@ struct PreferencesView: View {
} }
var body: some View { var body: some View {
// workaround: the navigation view is provided by MyProfileTableViewController so that it can inject the Done button
// NavigationView {
List { List {
Section(header: Text("Accounts")) { accountsSection
preferencesSection
aboutSection
}
.listStyle(.insetGrouped)
.navigationBarTitle("Preferences")
.navigationBarTitleDisplayMode(.inline)
}
private var accountsSection: some View {
Section {
ForEach(localData.accounts, id: \.accessToken) { (account) in ForEach(localData.accounts, id: \.accessToken) { (account) in
Button(action: { Button(action: {
NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account]) NotificationCenter.default.post(name: .activateAccount, object: nil, userInfo: ["account": account])
@ -66,7 +74,6 @@ struct PreferencesView: View {
}) { }) {
Text("Add Account...") Text("Add Account...")
} }
if localData.getMostRecentAccount() != nil {
Button(action: { Button(action: {
self.showingLogoutConfirmation = true self.showingLogoutConfirmation = true
}) { }) {
@ -74,9 +81,12 @@ struct PreferencesView: View {
}.alert(isPresented: $showingLogoutConfirmation) { }.alert(isPresented: $showingLogoutConfirmation) {
Alert(title: Text("Are you sure you want to logout?"), message: nil, primaryButton: .destructive(Text("Logout"), action: self.logoutPressed), secondaryButton: .cancel()) Alert(title: Text("Are you sure you want to logout?"), message: nil, primaryButton: .destructive(Text("Logout"), action: self.logoutPressed), secondaryButton: .cancel())
} }
} header: {
Text("Accounts")
} }
} }
private var preferencesSection: some View {
Section { Section {
NavigationLink(destination: AppearancePrefsView()) { NavigationLink(destination: AppearancePrefsView()) {
Text("Appearance") Text("Appearance")
@ -97,7 +107,9 @@ struct PreferencesView: View {
Text("Advanced") Text("Advanced")
} }
} }
}
private var aboutSection: some View {
Section { Section {
NavigationLink("About") { NavigationLink("About") {
AboutView() AboutView()
@ -110,10 +122,6 @@ struct PreferencesView: View {
} }
} }
} }
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Preferences"), displayMode: .inline)
// }
}
func logoutPressed() { func logoutPressed() {
NotificationCenter.default.post(name: .userLoggedOut, object: nil) NotificationCenter.default.post(name: .userLoggedOut, object: nil)

View File

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

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import SwiftUI
class MyProfileViewController: ProfileViewController { class MyProfileViewController: ProfileViewController {

View File

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

View File

@ -48,6 +48,7 @@ class StatusActionAccountListViewController: UIViewController {
indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
]) ])
case .displaying(let vc): case .displaying(let vc):
vc.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(vc) embedChild(vc)
case .notFound: case .notFound:
showStatusNotFound() showStatusNotFound()

View File

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

View File

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

View File

@ -222,6 +222,31 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = ""
switch group.kind {
case .favourite:
str += "Favorited by "
case .reblog:
str += "Reblogged by "
default:
return nil
}
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric))), "
str += statusContentLabel.text ?? ""
return str
}
set {}
}
} }
extension ActionNotificationGroupTableViewCell: SelectableTableViewCell { extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {

View File

@ -186,6 +186,22 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
var str = "Followed by "
str += first.account.displayNameWithoutCustomEmoji
if group.notifications.count > 1 {
str += " and \(group.notifications.count - 1) more"
}
str += ", \(first.createdAt.formatted(.relative(presentation: .numeric)))"
return str
}
set {}
}
} }
extension FollowNotificationGroupTableViewCell: SelectableTableViewCell { extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {

View File

@ -135,6 +135,29 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
self.stackView.addArrangedSubview(label) 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 // MARK: - Interaction
@IBAction func rejectButtonPressed() { @IBAction func rejectButtonPressed() {

View File

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

View File

@ -93,6 +93,25 @@ class PollFinishedTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Poll from "
str += notification.account.displayNameWithoutCustomEmoji
str += " finished "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
if let poll = notification.status?.poll,
poll.options.contains(where: { ($0.votesCount ?? 0) > 0 }) {
let winner = poll.options.max(by: { ($0.votesCount ?? 0) < ($1.votesCount ?? 0) })!
str += ", winning option: \(winner.title)"
}
return str
}
set {}
}
} }
extension PollFinishedTableViewCell: SelectableTableViewCell { extension PollFinishedTableViewCell: SelectableTableViewCell {

View File

@ -85,6 +85,22 @@ class StatusUpdatedNotificationTableViewCell: UITableViewCell {
updateTimestampWorkItem = nil updateTimestampWorkItem = nil
} }
// MARK: Accessibility
override var accessibilityLabel: String? {
get {
guard let notification else { return nil }
var str = "Post from "
str += notification.account.displayNameWithoutCustomEmoji
str += " edited "
str += notification.createdAt.formatted(.relative(presentation: .numeric))
str += ", "
str += contentLabel.text ?? ""
return str
}
set {}
}
} }
extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell { extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {

View File

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

View File

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

View File

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