Compare commits

..

No commits in common. "afed69e43ea03ccd32de6b4fc03076f7eeb6be60" and "474064669db8f9172c566a77907b886f4d3e3f80" have entirely different histories.

43 changed files with 653 additions and 1122 deletions

View File

@ -1,29 +1,5 @@
# 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,10 +199,8 @@ 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(range: RequestRange = .default) -> Request<[Status]> { public static func getFavourites() -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites") return 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 /* InlineTrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */; }; D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.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,10 +295,6 @@
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 */; };
@ -325,7 +321,6 @@
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 */; };
@ -627,7 +622,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 /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; }; D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.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>"; };
@ -706,10 +701,6 @@
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>"; };
@ -743,7 +734,6 @@
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>"; };
@ -927,7 +917,6 @@
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 */,
@ -942,19 +931,16 @@
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 /* Local Predicate Statuses List */ = { D627944823A6AD5100D38C68 /* Bookmarks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */, D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
); );
path = "Local Predicate Statuses List"; path = Bookmarks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D627944B23A9A02400D38C68 /* Lists */ = { D627944B23A9A02400D38C68 /* Lists */ = {
@ -1017,6 +1003,7 @@
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 */,
@ -1025,7 +1012,6 @@
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 */,
@ -1094,7 +1080,6 @@
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>";
@ -1414,6 +1399,7 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = { D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
); );
path = Search; path = Search;
@ -1428,7 +1414,6 @@
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 */,
@ -1951,7 +1936,6 @@
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 */,
@ -1962,7 +1946,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 /* InlineTrendsViewController.swift in Sources */, D68E525D24A3E8F00054355A /* SearchViewController.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 */,
@ -2031,7 +2015,6 @@
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 */,
@ -2134,7 +2117,6 @@
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 */,
@ -2165,7 +2147,6 @@
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 */,
@ -2181,7 +2162,6 @@
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 */,
@ -2408,7 +2388,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 = 72; CURRENT_PROJECT_VERSION = 71;
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;
@ -2473,7 +2453,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 = 72; CURRENT_PROJECT_VERSION = 71;
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;
@ -2624,7 +2604,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 = 72; CURRENT_PROJECT_VERSION = 71;
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;
@ -2652,7 +2632,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 = 72; CURRENT_PROJECT_VERSION = 71;
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;
@ -2757,7 +2737,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 = 72; CURRENT_PROJECT_VERSION = 71;
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;
@ -2783,7 +2763,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 = 72; CURRENT_PROJECT_VERSION = 71;
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,38 +32,46 @@ 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(url.absoluteString, loadOriginal: loadOriginal) { let entry = try? cache.get(key, loadOriginal: loadOriginal) {
completion?(entry.data, entry.image) wrappedCompletion?(entry.data, entry.image)
return nil return nil
} else { } else {
return Task.detached(priority: .userInitiated) { let task = dataTask(url: url, completion: wrappedCompletion)
let result = await self.fetch(url: url) task.resume()
switch result { return task
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?) {
if !ImageCache.disableCaching, // todo: this should integrate with the task cancellation mechanism somehow
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) { return await withCheckedContinuation { continuation in
return (entry.data, entry.image) _ = get(url, loadOriginal: loadOriginal) { data, image in
} else { continuation.resume(returning: (data, image))
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)
} }
} }
} }
@ -73,28 +81,21 @@ 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) {
Task.detached(priority: .medium) { let task = dataTask(url: url, completion: nil)
_ = await self.fetch(url: url) task.resume()
}
} }
} }
private func fetch(url: URL) async -> FetchResult { private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask {
guard let (data, _) = try? await URLSession.shared.data(from: url) else { return URLSession.shared.dataTask(with: url) { data, response, error in
return .none guard error == nil,
let data else {
return
} }
guard let image = UIImage(data: data) else { let image = UIImage(data: data)
try? cache.set(url.absoluteString, data: data, image: nil) try? self.cache.set(url.absoluteString, data: data, image: image)
return .data(data) completion?(data, image)
} }
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? {
@ -113,12 +114,6 @@ class ImageCache {
return cache.disk?.getSizeInBytes() return cache.disk?.getSizeInBytes()
} }
typealias Request = Task<Void, Never> typealias Request = URLSessionDataTask
enum FetchResult {
case data(Data)
case dataAndImage(Data, UIImage)
case none
}
} }

View File

@ -13,24 +13,20 @@ 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: any Lock<[Key: Value]> private let lock: LockHolder<[AnyHashable: Any]>
init() { init() {
if #available(iOS 16.0, *) { self.lock = LockHolder(initialState: [:])
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
} }
subscript(key: Key) -> Value? { subscript(key: Key) -> Value? {
get { get {
return lock.withLock { dict in return try! lock.withLock { dict in
dict[key] dict[key]
} } as! Value?
} }
set(value) { set(value) {
_ = lock.withLock { dict in _ = try! lock.withLock { dict in
dict[key] = value dict[key] = value
} }
} }
@ -38,21 +34,40 @@ 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 lock.withLock { dict in return try! 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 lock.withLock { dict in return try! 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]) throws -> R) rethrows -> R where R: Sendable { func withLock<R>(_ body: @Sendable (inout [Key: Value]) -> R) -> R where R: Sendable {
return try lock.withLock { dict in return try! lock.withLock { dict in
return try body(&dict) var downcasted = dict as! [Key: Value]
defer { dict = downcasted }
return body(&downcasted)
} as! R
}
}
// this type erased struct is necessary due to a compiler bug with stored constrained existential types
// see https://github.com/apple/swift/issues/61403
// see #178
fileprivate struct LockHolder<State> {
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
init(initialState: State) {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} else {
let lock = UnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} }
} }
} }

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.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? 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(hideTrends, forKey: .hideTrends) try container.encode(hideDiscover, forKey: .hideDiscover)
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 hideTrends = false @Published var hideDiscover = 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 hideTrends = "hideDiscover" case 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 InlineTrendsViewController(mastodonController: mastodonController) return SearchViewController(mastodonController: mastodonController)
case .bookmarks: case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController) return BookmarksViewController(mastodonController: mastodonController)

View File

@ -1,23 +1,20 @@
// //
// LocalPredicateStatusesViewController.swift // BookmarksViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 2/4/23. // Created by Shadowfacts on 12/15/19.
// Copyright © 2023 Shadowfacts. All rights reserved. // Copyright © 2019 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import Pachyderm import Pachyderm
import CoreData import CoreData
class LocalPredicateStatusesViewController: UIViewController, CollectionViewController, RefreshableViewController { class BookmarksViewController: 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
@ -28,15 +25,12 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) { init(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)
self.title = predicateTitle title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -56,7 +50,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
return sectionConfig return sectionConfig
} }
var config = sectionConfig var config = sectionConfig
if item.hideSeparators { if item.hideIndicators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else { } else {
@ -106,7 +100,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
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 \(predicateTitle)")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Bookmarks"))
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)
@ -135,12 +129,12 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
state = .loadingInitial state = .loadingInitial
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.bookmarks])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
await apply(snapshot: snapshot, animatingDifferences: false) await apply(snapshot: snapshot, animatingDifferences: false)
do { do {
let req = request(.count(Self.pageSize)) let req = Client.getBookmarks(range: .count(BookmarksViewController.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
@ -148,14 +142,14 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.bookmarks])
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 \(predicateTitle)", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Bookmarks", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self?.loadInitial() await self?.loadInitial()
} }
@ -180,7 +174,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
await apply(snapshot: snapshot, animatingDifferences: false) await apply(snapshot: snapshot, animatingDifferences: false)
do { do {
let req = request(older.withCount(Self.pageSize)) let req = Client.getBookmarks(range: older.withCount(BookmarksViewController.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
@ -190,13 +184,12 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
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 \(predicateTitle)", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Older Bookmarks", 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)
} }
@ -226,11 +219,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
} }
@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 {
@ -241,7 +229,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
} }
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 predicate(status) { for case let status as StatusMO in inserted where status.bookmarked == true {
prepend(item: .status(id: status.id, state: .unknown, addedLocally: true)) prepend(item: .status(id: status.id, state: .unknown, addedLocally: true))
hasChanges = true hasChanges = true
} }
@ -249,11 +237,11 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
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)
let exists = snapshot.itemIdentifiers.contains(item) var exists = snapshot.itemIdentifiers.contains(item)
if predicate(status) && !exists { if status.bookmarked == true && !exists {
prepend(item: item) prepend(item: item)
hasChanges = true hasChanges = true
} else if !predicate(status) && exists { } else if status.bookmarked == false && exists {
snapshot.deleteItems([item]) snapshot.deleteItems([item])
hasChanges = true hasChanges = true
} }
@ -279,21 +267,22 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
state = .loadingNewer state = .loadingNewer
Task { Task {
do { do {
let req = request(newer.withCount(Self.pageSize)) let req = Client.getBookmarks(range: newer.withCount(BookmarksViewController.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
@ -311,9 +300,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
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 \(predicateTitle)", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Refreshing Bookmarks", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.refresh() self?.refresh()
} }
@ -329,15 +317,15 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
} }
extension LocalPredicateStatusesViewController { extension BookmarksViewController {
enum Section { enum Section {
case statuses case bookmarks
} }
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 hideSeparators: Bool { var hideIndicators: Bool {
switch self { switch self {
case .loadingIndicator: case .loadingIndicator:
return true return true
@ -369,7 +357,7 @@ extension LocalPredicateStatusesViewController {
} }
} }
extension LocalPredicateStatusesViewController { extension BookmarksViewController {
enum State { enum State {
case unloaded case unloaded
case loadingInitial case loadingInitial
@ -379,7 +367,7 @@ extension LocalPredicateStatusesViewController {
} }
} }
extension LocalPredicateStatusesViewController: UICollectionViewDelegate { extension BookmarksViewController: 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 {
@ -404,7 +392,7 @@ extension LocalPredicateStatusesViewController: UICollectionViewDelegate {
} }
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() (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) {
@ -412,17 +400,17 @@ extension LocalPredicateStatusesViewController: UICollectionViewDelegate {
} }
} }
extension LocalPredicateStatusesViewController: UICollectionViewDragDelegate { extension BookmarksViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
} }
} }
extension LocalPredicateStatusesViewController: TuskerNavigationDelegate { extension BookmarksViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension LocalPredicateStatusesViewController: StatusCollectionViewCellDelegate { extension BookmarksViewController: 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()
@ -432,17 +420,17 @@ extension LocalPredicateStatusesViewController: StatusCollectionViewCellDelegate
} }
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
// filtering isn't supported here // bookmarks aren't filtered
} }
} }
extension LocalPredicateStatusesViewController: TabBarScrollableViewController { extension BookmarksViewController: TabBarScrollableViewController {
func tabBarScrollToTop() { func tabBarScrollToTop() {
collectionView.scrollToTop() collectionView.scrollToTop()
} }
} }
extension LocalPredicateStatusesViewController: StatusBarTappableViewController { extension BookmarksViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop() collectionView.scrollToTop()
return .stop return .stop

View File

@ -236,7 +236,6 @@ 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 {
@ -272,7 +271,6 @@ 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)
@ -295,7 +293,6 @@ 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,17 +15,15 @@ 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, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) { init(text: Binding<String>, placeholder: String, 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
@ -76,7 +74,6 @@ 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
@ -98,7 +95,6 @@ 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>?
@ -118,14 +114,6 @@ 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,7 +20,6 @@ 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
@ -32,14 +31,6 @@ 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 {
@ -76,15 +67,9 @@ 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 { Label("Add Option", systemImage: "plus")
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: [
@ -170,8 +155,6 @@ 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)
@ -190,7 +173,7 @@ struct ComposePollOption: View {
} }
private var textField: some View { private var textField: some View {
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption) var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
return field.backgroundColor(.systemBackground) return field.backgroundColor(.systemBackground)
} }

View File

@ -9,6 +9,16 @@
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
@ -45,15 +55,11 @@ 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
if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) { config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden
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
} }
@ -93,7 +99,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, node: _, state: state, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, 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 {
@ -117,32 +123,45 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
loadViewIfNeeded() loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.ancestors, .mainStatus]) snapshot.appendSections([.statuses])
if status.inReplyToID != nil { if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .ancestors) snapshot.appendItems([.loadingIndicator], toSection: .statuses)
} }
// this will be replace with the actual node in the tree once it's loaded let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
let tempMainNode = ConversationNode(status: status) snapshot.appendItems([mainStatusItem], toSection: .statuses)
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 addTree(_ tree: ConversationTree, mainStatus: StatusMO) { func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator]) snapshot.deleteItems([.loadingIndicator])
let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
let parentItems = tree.ancestors.enumerated().map { index, node in let parentItems = parentIDs.enumerated().map { index, id in
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true) Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true)
} }
snapshot.appendItems(parentItems, toSection: .ancestors) snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
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(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot) self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
}
self.dataSource.apply(snapshot, animatingDifferences: false) { self.dataSource.apply(snapshot, animatingDifferences: false) {
let item: Item let item: Item
@ -152,7 +171,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
@ -168,6 +187,54 @@ 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
@ -181,7 +248,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, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section) snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node var currentNode = node
while true { while true {
@ -204,7 +271,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
currentNode = next currentNode = next
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section) snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
} }
} }
} }
@ -213,7 +280,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: _, node: _, state: let state, prevLink: _, nextLink: _) = item, guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else { state.collapsible == true else {
continue continue
} }
@ -244,18 +311,17 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
extension ConversationCollectionViewController { extension ConversationCollectionViewController {
enum Section: Hashable { enum Section: Hashable {
case ancestors case statuses
case mainStatus
case childThread(firstStatusID: String) case childThread(firstStatusID: String)
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool) case status(id: String, 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, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)): case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, 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
@ -268,7 +334,7 @@ extension ConversationCollectionViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
hasher.combine(0) hasher.combine(0)
hasher.combine(id) hasher.combine(id)
hasher.combine(prevLink) hasher.combine(prevLink)
@ -289,7 +355,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, node: _, state: _, prevLink: _, nextLink: _): case .status(id: let id, state: _, prevLink: _, nextLink: _):
return id != mainStatusID return id != mainStatusID
case .expandThread(childThreads: _, inline: _): case .expandThread(childThreads: _, inline: _):
return true return true
@ -304,24 +370,12 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
break break
case .loadingIndicator: case .loadingIndicator:
break break
case .status(id: let id, node: let node, state: let state, _, _): case .status(id: let id, 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: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node) // todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController) let conv = ConversationViewController(for: id, 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)
@ -329,34 +383,6 @@ 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

@ -1,101 +0,0 @@
//
// 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,14 +77,6 @@ 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")
} }
@ -123,18 +115,12 @@ 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,
@ -156,21 +142,10 @@ class ConversationViewController: UIViewController {
// MARK: Loading // MARK: Loading
@MainActor
private func loadMainStatus() async { private func loadMainStatus() async {
let mainStatusID: String guard let mainStatusID = await resolveStatusIfNecessary() else {
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? {
@ -191,7 +166,7 @@ class ConversationViewController: UIViewController {
Task { Task {
await doLoadMainStatus() await doLoadMainStatus()
} }
mainStatusLoaded(cached) await 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)
@ -199,13 +174,17 @@ class ConversationViewController: UIViewController {
state = .loading(indicator) state = .loading(indicator)
if let status = await doLoadMainStatus() { if let status = await doLoadMainStatus() {
mainStatusLoaded(status) await mainStatusLoaded(status)
} }
} }
} }
@MainActor @MainActor
private func resolveStatus(url: URL) async -> String? { private func resolveStatusIfNecessary() 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)
@ -225,68 +204,44 @@ class ConversationViewController: UIViewController {
return nil return nil
} }
} }
}
private func mainStatusLoaded(_ mainStatus: StatusMO) { @MainActor
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)
if case .preloaded(let tree) = mode { await loadContext(for: mainStatus)
vc.addTree(tree, mainStatus: mainStatus)
} else {
Task { @MainActor in
await loadTree(for: mainStatus)
}
}
} }
@MainActor @MainActor
private func loadTree(for mainStatus: StatusMO) async { private func loadContext(for mainStatus: StatusMO) async {
guard case .displaying(_) = state, guard case .displaying(_) = state else {
let context = await loadContext(for: mainStatus) else {
return return
} }
await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
let ancestorIDs = context.ancestors.map(\.id)
let ancestorsReq = StatusMO.fetchRequest()
ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs)
let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq)
let descendantIDs = context.descendants.map(\.id)
let descendantsReq = StatusMO.fetchRequest()
descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs)
let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq)
let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? [])
guard case .displaying(let vc) = state else {
return
}
vc.addTree(tree, mainStatus: mainStatus)
}
private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
let request = Status.getContext(mainStatus.id) let request = Status.getContext(mainStatus.id)
do { do {
let (context, _) = try await mastodonController.run(request) let (context, _) = try await mastodonController.run(request)
return context guard case .displaying(let vc) = state else {
return
}
await vc.addContext(context, for: mainStatus)
} catch { } catch {
guard case .displaying(_) = state else { guard case .displaying(_) = state else {
return nil return
} }
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?.loadTree(for: mainStatus) await self?.loadContext(for: mainStatus)
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
return nil
} }
} }
@ -386,7 +341,6 @@ 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, .favorites], toSection: .bookmarks) snapshot.appendItems([.bookmarks], toSection: .bookmarks)
if mastodonController.instanceFeatures.trends, if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideTrends { !Preferences.shared.hideDiscover {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems([.addList], toSection: .lists)
@ -175,17 +175,19 @@ 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([.trends], toSection: .discover) snapshot.appendItems([.trendingTags, .profileDirectory], 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) {
addDiscoverSection(to: &snapshot) snapshot.insertSections([.discover], afterSection: .bookmarks)
} else if !mastodonController.instanceFeatures.trends, snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
snapshot.sectionIdentifiers.contains(.discover) {
snapshot.deleteSections([.discover])
} }
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
} }
@ -248,7 +250,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.hideTrends let hide = Preferences.shared.hideDiscover
if hasSection && hide { if hasSection && hide {
snapshot.deleteSections([.discover]) snapshot.deleteSections([.discover])
} else if !hasSection && !hide { } else if !hasSection && !hide {
@ -334,11 +336,17 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
case .bookmarks: case .bookmarks:
show(BookmarksViewController(mastodonController: mastodonController), sender: nil) show(BookmarksViewController(mastodonController: mastodonController), sender: nil)
case .favorites: case .trendingStatuses:
show(FavoritesViewController(mastodonController: mastodonController), sender: nil) show(TrendingStatusesViewController(mastodonController: mastodonController), sender: nil)
case .trends: case .trendingTags:
show(TrendsViewController(mastodonController: mastodonController), sender: nil) show(TrendingHashtagsViewController(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)
@ -387,7 +395,7 @@ extension ExploreViewController {
case .bookmarks: case .bookmarks:
return nil return nil
case .discover: case .discover:
return nil return NSLocalizedString("Discover", comment: "discover section title")
case .lists: case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title") return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags: case .savedHashtags:
@ -400,8 +408,10 @@ extension ExploreViewController {
enum Item: Hashable { enum Item: Hashable {
case bookmarks case bookmarks
case favorites case trendingStatuses
case trends case trendingTags
case trendingLinks
case profileDirectory
case list(List) case list(List)
case addList case addList
case savedHashtag(Hashtag) case savedHashtag(Hashtag)
@ -413,10 +423,14 @@ 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 .favorites: case .trendingStatuses:
return NSLocalizedString("Favorites", comment: "favorites nav item title") return NSLocalizedString("Trending Posts", comment: "trending statuses nav item title")
case .trends: case .trendingTags:
return NSLocalizedString("Trends", comment: "trends nav item title") return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title")
case .trendingLinks:
return NSLocalizedString("Trending Links", comment: "trending links nav item title")
case .profileDirectory:
return NSLocalizedString("Profile Directory", comment: "profile directory nav item title")
case let .list(list): case let .list(list):
return list.title return list.title
case .addList: case .addList:
@ -437,10 +451,14 @@ extension ExploreViewController {
switch self { switch self {
case .bookmarks: case .bookmarks:
name = "bookmark.fill" name = "bookmark.fill"
case .favorites: case .trendingStatuses:
name = "star.fill" name = "doc.text.image"
case .trends: case .trendingTags:
name = "chart.line.uptrend.xyaxis" name = "number"
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:
@ -459,9 +477,13 @@ extension ExploreViewController {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.bookmarks, .bookmarks): case (.bookmarks, .bookmarks):
return true return true
case (.favorites, .favorites): case (.trendingStatuses, .trendingStatuses):
return true return true
case (.trends, .trends): case (.trendingTags, .trendingTags):
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
@ -484,10 +506,14 @@ extension ExploreViewController {
switch self { switch self {
case .bookmarks: case .bookmarks:
hasher.combine("bookmarks") hasher.combine("bookmarks")
case .favorites: case .trendingStatuses:
hasher.combine("favorites") hasher.combine("trendingStatuses")
case .trends: case .trendingTags:
hasher.combine("trends") hasher.combine("trendingTags")
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

@ -1,79 +0,0 @@
//
// 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, CollectionViewController { class TrendingStatusesViewController: UIViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let filterer: Filterer let filterer: Filterer
var collectionView: UICollectionView! { private var collectionView: UICollectionView {
view as? UICollectionView view as! UICollectionView
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -110,8 +110,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
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

@ -42,8 +42,6 @@ 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(_:))))
} }
@ -69,16 +67,10 @@ 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
@ -103,8 +95,6 @@ class FastAccountSwitcherViewController: UIViewController {
accountView.transform = .identity accountView.transform = .identity
} }
} }
} completion: { _ in
completion()
} }
} }
} }
@ -124,8 +114,6 @@ 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)
} }
} }
@ -284,14 +272,6 @@ 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,8 +117,6 @@ class FastSwitchingAccountView: UIView {
} }
updateLabelColors() updateLabelColors()
isAccessibilityElement = true
} }
private func setupAccount(account: LocalData.UserAccountInfo) { private func setupAccount(account: LocalData.UserAccountInfo) {
@ -136,16 +134,12 @@ 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" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" 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

@ -1,27 +0,0 @@
//
// 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

@ -1,27 +0,0 @@
//
// 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

@ -41,7 +41,7 @@ class MainSidebarViewController: UIViewController {
} }
var exploreTabItems: [Item] { var exploreTabItems: [Item] {
var items: [Item] = [.explore, .bookmarks, .favorites] var items: [Item] = [.explore, .bookmarks, .profileDirectory]
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,6 +104,7 @@ 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) }
@ -165,26 +166,47 @@ class MainSidebarViewController: UIViewController {
private func applyInitialSnapshot() { private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases) snapshot.appendSections(Section.allCases.filter { $0 != .discover })
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 {
@ -267,6 +289,22 @@ 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
@ -339,13 +377,15 @@ 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, favorites case explore, bookmarks
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
@ -358,8 +398,10 @@ extension MainSidebarViewController {
return "Explore" return "Explore"
case .bookmarks: case .bookmarks:
return "Bookmarks" return "Bookmarks"
case .favorites: case .discoverHeader:
return "Favorites" return "Discover"
case .profileDirectory:
return "Profile Directory"
case .listsHeader: case .listsHeader:
return "Lists" return "Lists"
case let .list(list): case let .list(list):
@ -389,15 +431,15 @@ extension MainSidebarViewController {
return "magnifyingglass" return "magnifyingglass"
case .bookmarks: case .bookmarks:
return "bookmark" return "bookmark"
case .favorites: case .profileDirectory:
return "star" return "person.2.fill"
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 .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: case .discoverHeader, .listsHeader, .savedHashtagsHeader, .savedInstancesHeader:
return nil return nil
case .addList, .addSavedHashtag, .addSavedInstance: case .addList, .addSavedHashtag, .addSavedInstance:
return "plus" return "plus"
@ -406,7 +448,7 @@ extension MainSidebarViewController {
var hasChildren: Bool { var hasChildren: Bool {
switch self { switch self {
case .listsHeader, .savedHashtagsHeader, .savedInstancesHeader: case .discoverHeader, .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! InlineTrendsViewController let search = secondaryNavController.viewControllers.first as! SearchViewController
// 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, .favorites, .list(_), .savedHashtag(_), .savedInstance(_): case .bookmarks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore) tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously // Make sure the Explore VC doesn't show it's 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 .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance: case .discoverHeader, .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,9 +285,7 @@ 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
// reuse the existing VC, if there is one let searchVC = SearchViewController(mastodonController: mastodonController)
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,
@ -305,8 +303,6 @@ 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:
@ -317,11 +313,10 @@ 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:
// transfer the navigation stack prepending, the existing explore VC fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
// 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)
@ -377,18 +372,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 InlineTrendsViewController(mastodonController: mastodonController) return SearchViewController(mastodonController: mastodonController)
case .bookmarks: case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController) return BookmarksViewController(mastodonController: mastodonController)
case .favorites: case .profileDirectory:
return FavoritesViewController(mastodonController: mastodonController) return ProfileDirectoryViewController(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 .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance: case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
return nil return nil
} }
} }
@ -478,7 +473,7 @@ extension MainSplitViewController: TuskerRootViewController {
select(item: .explore) select(item: .explore)
} }
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else { guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else {
return return
} }

View File

@ -296,7 +296,6 @@ 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,21 +29,6 @@ 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
@ -63,12 +48,12 @@ struct AppearancePrefsView : View {
} }
Picker(selection: accentColor, label: Text("Accent Color")) { Picker(selection: accentColor, label: Text("Accent Color")) {
ForEach(accentColorsAndImages, id: \.0.rawValue) { (color, image) in ForEach(Preferences.AccentColor.allCases, id: \.rawValue) { color in
HStack { HStack {
Text(color.name) Text(color.name)
if let image { if let color = color.color {
Spacer() Spacer()
Image(uiImage: image) Image(uiImage: UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal))
} }
} }
.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,18 +18,10 @@ 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 {
accountsSection Section(header: Text("Accounts")) {
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])
@ -74,6 +66,7 @@ struct PreferencesView: View {
}) { }) {
Text("Add Account...") Text("Add Account...")
} }
if localData.getMostRecentAccount() != nil {
Button(action: { Button(action: {
self.showingLogoutConfirmation = true self.showingLogoutConfirmation = true
}) { }) {
@ -81,12 +74,9 @@ 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")
@ -107,9 +97,7 @@ struct PreferencesView: View {
Text("Advanced") Text("Advanced")
} }
} }
}
private var aboutSection: some View {
Section { Section {
NavigationLink("About") { NavigationLink("About") {
AboutView() AboutView()
@ -122,6 +110,10 @@ 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
hideTrends hideDiscover
} }
.listStyle(InsetGroupedListStyle()) .listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Digital Wellness")) .navigationBarTitle(Text("Digital Wellness"))
@ -57,10 +57,10 @@ struct WellnessPrefsView: View {
} }
} }
private var hideTrends: some View { private var hideDiscover: some View {
Section(footer: Text("Do not show Trends (hashtags, links, posts, suggested accounts) on the Explore screen.")) { Section(footer: Text("Do not show the Discover section (Trends, Profile Directory) of the Explore screen or sidebar.")) {
Toggle(isOn: $preferences.hideTrends) { Toggle(isOn: $preferences.hideDiscover) {
Text("Hide Trends") Text("Hide Discover Section")
} }
} }
} }

View File

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

View File

@ -335,14 +335,12 @@ 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

@ -1,35 +1,36 @@
// //
// TrendsViewController.swift // SearchViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 2/5/23. // Created by Shadowfacts on 6/24/20.
// Copyright © 2023 Shadowfacts. All rights reserved. // Copyright © 2020 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import Pachyderm import Pachyderm
import SafariServices import SafariServices
import WebURLFoundationExtras
class TrendsViewController: UIViewController, CollectionViewController { class SearchViewController: UIViewController, CollectionViewController {
let mastodonController: MastodonController weak var mastodonController: MastodonController!
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var loadTask: Task<Void, Never>? var resultsController: SearchResultsViewController!
var searchController: UISearchController!
private var isShowingTrends = false var searchControllerStatusOnAppearance: Bool? = nil
private var shouldShowTrends: Bool {
mastodonController.instanceFeatures.trends && !Preferences.shared.hideTrends private var loadTask: Task<Void, Never>?
}
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 = "Trends" title = NSLocalizedString("Explore", comment: "explore tab title")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -52,9 +53,9 @@ class TrendsViewController: 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.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
let section = NSCollectionLayoutSection(group: group) let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
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)
] ]
@ -66,9 +67,9 @@ class TrendsViewController: 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.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8) group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
let section = NSCollectionLayoutSection(group: group) let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
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)
] ]
@ -91,9 +92,53 @@ class TrendsViewController: 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]
@ -143,27 +188,10 @@ class TrendsViewController: 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 loadTrends() async { private func applySnapshot() async {
guard isShowingTrends != shouldShowTrends else { guard mastodonController.instanceFeatures.trends,
return !Preferences.shared.hideDiscover else {
}
isShowingTrends = shouldShowTrends
guard shouldShowTrends else {
await dataSource.apply(NSDiffableDataSourceSnapshot()) await dataSource.apply(NSDiffableDataSourceSnapshot())
return return
} }
@ -216,11 +244,9 @@ class TrendsViewController: UIViewController, CollectionViewController {
} }
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
if isShowingTrends != shouldShowTrends {
loadTask?.cancel() loadTask?.cancel()
loadTask = Task { loadTask = Task {
await loadTrends() await applySnapshot()
}
} }
} }
@ -247,9 +273,10 @@ class TrendsViewController: UIViewController, CollectionViewController {
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
} }
} }
extension TrendsViewController { extension SearchViewController {
enum Section { enum Section {
case trendingHashtags case trendingHashtags
case trendingLinks case trendingLinks
@ -275,7 +302,7 @@ extension TrendsViewController {
case link(Card) case link(Card)
case account(String, Suggestion.Source) case account(String, Suggestion.Source)
static func == (lhs: Item, rhs: Item) -> Bool { static func == (lhs: SearchViewController.Item, rhs: SearchViewController.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
@ -309,7 +336,7 @@ extension TrendsViewController {
} }
} }
extension TrendsViewController: UICollectionViewDelegate { extension SearchViewController: 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
@ -416,7 +443,7 @@ extension TrendsViewController: UICollectionViewDelegate {
} }
} }
extension TrendsViewController: UICollectionViewDragDelegate { extension SearchViewController: 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 []
@ -463,17 +490,17 @@ extension TrendsViewController: UICollectionViewDragDelegate {
} }
} }
extension TrendsViewController: TuskerNavigationDelegate { extension SearchViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension TrendsViewController: ToastableViewController { extension SearchViewController: ToastableViewController {
} }
extension TrendsViewController: MenuActionProvider { extension SearchViewController: MenuActionProvider {
} }
extension TrendsViewController: StatusCollectionViewCellDelegate { extension SearchViewController: 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

@ -48,7 +48,6 @@ 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,23 +84,8 @@ class InstanceTimelineViewController: TimelineViewController {
// MARK: Timeline // MARK: Timeline
override func handleLoadAllError(_ error: Swift.Error) async { override func handleLoadAllError(_ error: Swift.Error) async {
guard let error = error as? Client.Error else { switch (error as? Client.Error)?.type {
await super.handleLoadAllError(error) case .mastodonError(422, _), .unexpectedStatus(422):
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
@ -136,6 +121,10 @@ 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

@ -1,50 +0,0 @@
//
// 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,31 +222,6 @@ 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,22 +186,6 @@ 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,29 +135,6 @@ 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="tintColor"/> <color key="titleColor" systemColor="systemBlueColor"/>
</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="tintColor"/> <color key="titleColor" systemColor="systemBlueColor"/>
</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="tintColor"> <systemColor name="systemBlueColor">
<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,25 +93,6 @@ 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,22 +85,6 @@ 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" customClass="CopyableLable" customModule="Tusker" customModuleProvider="target"> <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">
<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,10 +762,7 @@ 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
guard let delegate = self.delegate else { UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
return nil
}
return UIMenu(children: delegate.actionsForStatus(status, source: .view(self)))
} }
} }

View File

@ -56,7 +56,37 @@ 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
captureError(error, title: title) switch error.type {
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_, _):
let event = Event(error: error)
event.message = SentryMessage(formatted: "\(title): \(error)")
event.tags = [
"request_method": error.requestMethod.name,
"request_endpoint": error.requestEndpoint.description,
]
switch error.type {
case .invalidRequest:
event.tags!["error_type"] = "invalid_request"
case .invalidResponse:
event.tags!["error_type"] = "invalid_response"
case .invalidModel(let error):
event.tags!["error_type"] = "invalid_model"
event.extra = [
"underlying_error": String(describing: error)
]
case .mastodonError(let code, let error):
event.tags!["error_type"] = "mastodon_error"
event.tags!["response_code"] = "\(code)"
event.extra = [
"underlying_error": String(describing: error)
]
default:
break
}
SentrySDK.capture(event: event)
default:
break
}
} else { } else {
self.subtitle = error.localizedDescription self.subtitle = error.localizedDescription
self.systemImageName = "exclamationmark.triangle" self.systemImageName = "exclamationmark.triangle"
@ -86,39 +116,3 @@ 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)
}