Compare commits

...

29 Commits

Author SHA1 Message Date
Shadowfacts a49e9f2c1f Bump build number and update changelog 2023-01-21 11:24:19 -05:00
Shadowfacts b1421767dd Fix tapping expand thread cell not working 2023-01-20 14:17:15 -05:00
Shadowfacts 8ee916411e Further card tweaks 2023-01-20 13:58:40 -05:00
Shadowfacts 9d845bf6c1 Show loading indicator when restoring timeline state 2023-01-20 13:47:14 -05:00
Shadowfacts 9a2c24942a Fix SegmentedPageViewController next sub-page shortcut not working 2023-01-20 11:38:31 -05:00
Shadowfacts cca2a03b2f When routing the SplitNav responder chain through the root VC, go as deep into it as possible
Makes keyboard shortcuts from, e.g., TimelineVC accessible when the root is TimelinesPageVC

See #302
2023-01-20 11:34:44 -05:00
Shadowfacts 1a64bfcef8 Disallow keyboard focus in sidebar
Makes keyboard shortcuts from the split VC's primary content available

See #302
2023-01-20 11:33:28 -05:00
Shadowfacts 907810d98a Make link preview cards larger 2023-01-20 11:22:28 -05:00
Shadowfacts 23a4999196 Complete asynchronous swipe actions immediately
Fixes crash when the user things the action has failed and taps it
again, which results in an invalid completion handler later being called
2023-01-20 10:53:30 -05:00
Shadowfacts 3e0feba273 Fix collapse button disappearing when navigating away 2023-01-20 10:51:56 -05:00
Shadowfacts 468a559127 Fix crash when TimelinePosition's center status ID isn't in the list of IDs 2023-01-19 21:46:57 -05:00
Shadowfacts c03fc86300 Bump build number 2023-01-19 14:38:35 -05:00
Shadowfacts a33be0b556 Remove unused background audio mode 2023-01-19 13:13:08 -05:00
Shadowfacts 6aee926f00 Fix table views being too far inset on iPhone 2023-01-19 13:13:01 -05:00
Shadowfacts 13640be91d Bump build number and update changelog 2023-01-19 13:08:05 -05:00
Shadowfacts 5123cf20c3 Rename Delete Status -> Delete Post 2023-01-18 15:05:12 -05:00
Shadowfacts bf739b9f41 Add pagination to status actions account list 2023-01-18 15:02:56 -05:00
Shadowfacts 4211806b5f Add followers/following screen
Closes #323
2023-01-18 15:02:56 -05:00
Shadowfacts 88aada8d35 Add follower/ing counts to profile header 2023-01-18 14:02:23 -05:00
Shadowfacts 5623cedab3 Fix conversation reloading on appear 2023-01-18 13:59:42 -05:00
Shadowfacts ccfc8331fb Fix avatars not un-grayscaling on timeline 2023-01-18 11:37:15 -05:00
Shadowfacts 10803408cd Post status deleted notifications when load fails with not found 2023-01-17 20:04:48 -05:00
Shadowfacts fb7a7db6e8 Handle deleted statuses in status action account list 2023-01-17 20:02:03 -05:00
Shadowfacts 78cd1313fe Fix new conversation VC not responding to status bar taps 2023-01-17 19:36:12 -05:00
Shadowfacts db1bbf7148 Add delete status action 2023-01-17 19:32:50 -05:00
Shadowfacts 5f19adf2d0 Only show report action for other people's posts 2023-01-17 19:15:54 -05:00
Shadowfacts 6f006adbc1 Show better message when opening conv for deleted status
Also split conversation loading out into separate view controller
2023-01-17 19:15:54 -05:00
Shadowfacts 39bff06897 Fix profile header buttons not adjusting height for dynamic type size
Closes #317
2023-01-17 11:51:14 -05:00
Shadowfacts 68682ee291 Maybe fix race condition between iCloud sync and state restoration 2023-01-17 10:50:36 -05:00
40 changed files with 1870 additions and 479 deletions

View File

@ -1,5 +1,30 @@
# Changelog
## 2023.2 (66)
Features/Improvements:
- Improve design of link preview card
- Show loading indicator during timeline state restoration
Bugfixes:
- iPadOS/macOS: Fix some keyboard shortcuts not working
- Fix crash when restoring timeline state
- Fix status collapse button disappearing when navigating away
- Fix crash when status swipe action takes too long to complete
- Fix tapping expand thread cell not working
## 2023.1 (64)
Features/Improvements:
- Add Delete Post action to statuses
- Add follower/following counts and lists to profiles
- Show better message when opening conversation for deleted status
- Add pagination for showing all accounts that favorited/reblogged a status
Bugfixes:
- Fix race condition causing crash when syncing timeline position from iCloud
- Fix profile header buttons not adjusting to Dynamic Type
- Don't show report button for your own posts
- Fix avatars on timeline not reverting from grayscale when turning off preference
## 2023.1 (63)
Bugfixes:
- Fix status cells being inset too much on iPhones

View File

@ -64,8 +64,8 @@ public final class Status: StatusProtocol, Decodable {
return request
}
public static func delete(_ status: Status) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
public static func delete(_ statusID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
}
public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {

View File

@ -17,6 +17,7 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; };
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
@ -150,6 +151,12 @@
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; };
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */; };
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */; };
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */; };
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */; };
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */; };
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */; };
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */; };
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
@ -297,7 +304,7 @@
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; };
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; };
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
@ -408,6 +415,7 @@
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
@ -537,6 +545,12 @@
D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSelectRulesView.swift; sourceTree = "<group>"; };
D65B4B5929720AB000DABDFB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = "<group>"; };
D65B4B5D2973040D00DABDFB /* ReportAddStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportAddStatusView.swift; sourceTree = "<group>"; };
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusService.swift; sourceTree = "<group>"; };
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = "<group>"; };
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteStatusService.swift; sourceTree = "<group>"; };
D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusNotFoundView.swift; sourceTree = "<group>"; };
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsViewController.swift; sourceTree = "<group>"; };
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -688,7 +702,7 @@
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; };
D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
@ -956,6 +970,7 @@
D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup;
children = (
D65B4B89297879DE00DABDFB /* Account Follows */,
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
@ -1033,6 +1048,7 @@
D641C785213DD83B004B4513 /* Conversation */ = {
isa = PBXGroup;
children = (
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
@ -1196,6 +1212,15 @@
path = Report;
sourceTree = "<group>";
};
D65B4B89297879DE00DABDFB /* Account Follows */ = {
isa = PBXGroup;
children = (
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */,
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */,
);
path = "Account Follows";
sourceTree = "<group>";
};
D663626021360A9600C9CBA2 /* Preferences */ = {
isa = PBXGroup;
children = (
@ -1285,7 +1310,8 @@
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
isa = PBXGroup;
children = (
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */,
D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */,
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */,
);
path = "Status Action Account List";
sourceTree = "<group>";
@ -1375,6 +1401,7 @@
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */,
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
@ -1606,6 +1633,8 @@
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1899,6 +1928,7 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
@ -1979,6 +2009,7 @@
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
@ -2007,7 +2038,9 @@
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
@ -2027,6 +2060,7 @@
D659F36229541065002D944A /* TTTView.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
@ -2041,6 +2075,7 @@
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
@ -2056,7 +2091,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
@ -2148,6 +2183,7 @@
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
@ -2314,7 +2350,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2322,7 +2358,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2382,7 +2418,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2391,7 +2427,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2533,7 +2569,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2541,7 +2577,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2562,7 +2598,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2570,7 +2606,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2672,7 +2708,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2681,7 +2717,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2698,7 +2734,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 63;
CURRENT_PROJECT_VERSION = 66;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2707,7 +2743,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.1;
MARKETING_VERSION = 2023.2;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -0,0 +1,63 @@
//
// DeleteStatusService.swift
// Tusker
//
// Created by Shadowfacts on 1/17/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class DeleteStatusService {
let status: StatusMO
let mastodonController: MastodonController
let presenter: any TuskerNavigationDelegate
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
self.status = status
self.mastodonController = mastodonController
self.presenter = presenter
}
func run() async {
do {
let req = Status.delete(status.id)
let _ = try await mastodonController.run(req)
// we deliberately don't remove the status from the cache because there are almost certainly places where it'll still be fetched again
var reblogIDs = [String]()
let reblogsReq = StatusMO.fetchRequest()
reblogsReq.predicate = NSPredicate(format: "reblog = %@", status)
if let reblogs = try? mastodonController.persistentContainer.viewContext.fetch(reblogsReq) {
reblogIDs = reblogs.map(\.id)
}
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": mastodonController.accountInfo!.id,
"statusIDs": [status.id] + reblogIDs,
])
} catch {
let message: String
if let error = error as? Client.Error {
message = error.localizedDescription
} else {
message = error.localizedDescription
}
let alert = UIAlertController(title: "Error Deleting Post", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.run()
}
}))
presenter.present(alert, animated: true)
}
}
}
extension Foundation.Notification.Name {
static let statusDeleted = Foundation.Notification.Name("statusDeleted")
}

View File

@ -0,0 +1,64 @@
//
// FetchStatusService.swift
// Tusker
//
// Created by Shadowfacts on 1/17/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
@MainActor
class FetchStatusService {
let statusID: String
let mastodonController: MastodonController
init(statusID: String, mastodonController: MastodonController) {
self.statusID = statusID
self.mastodonController = mastodonController
}
func run() async -> Result {
let response = await mastodonController.runResponse(Client.getStatus(id: statusID))
switch response {
case .success(let status, _):
return .loaded(status)
case .failure(let error):
switch error.type {
case .unexpectedStatus(404), .mastodonError(404, _):
self.handleStatusNotFound()
return .notFound
default:
return .error(error)
}
}
}
private func handleStatusNotFound() {
// todo: what about when browsing on another instance?
guard let accountID = mastodonController.accountInfo?.id else {
return
}
var reblogIDs = [String]()
if let cached = mastodonController.persistentContainer.status(for: statusID) {
let reblogsReq = StatusMO.fetchRequest()
reblogsReq.predicate = NSPredicate(format: "reblog = %@", cached)
if let reblogs = try? mastodonController.persistentContainer.viewContext.fetch(reblogsReq) {
reblogIDs = reblogs.map(\.id)
}
}
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": accountID,
"statusIDs": [statusID] + reblogIDs
])
}
enum Result {
case loaded(Status)
case notFound
case error(Client.Error)
}
}

View File

@ -97,19 +97,24 @@ class MastodonController: ObservableObject {
return client.run(request, completion: completion)
}
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
let result: (Result, Pagination?) = try await withCheckedThrowingContinuation({ continuation in
func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
let response = await withCheckedContinuation({ continuation in
client.run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
continuation.resume(returning: response)
}
})
return response
}
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
let response = await runResponse(request)
try Task.checkCancellation()
return result
switch response {
case .failure(let error):
throw error
case .success(let result, let pagination):
return (result, pagination)
}
}
/// - Returns: A tuple of client ID and client secret.

View File

@ -349,6 +349,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
}
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil) async {
await withCheckedContinuation { continuation in
addAll(accounts: accounts, in: context) {
continuation.resume()
}
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }

View File

@ -141,7 +141,6 @@
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>

View File

@ -99,9 +99,9 @@ private func createFavoriteAction(status: StatusMO, container: StatusSwipeAction
}
let title = status.favourited ? "Unfavorite" : "Favorite"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
completion(true)
}
}
action.image = UIImage(systemName: "star.fill")
@ -116,9 +116,9 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
}
let title = status.reblogged ? "Unreblog" : "Reblog"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
completion(true)
}
}
action.image = UIImage(systemName: "repeat")
@ -145,6 +145,7 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
let bookmarked = status.bookmarked ?? false
let title = bookmarked ? "Unbookmark" : "Bookmark"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
do {
@ -156,7 +157,6 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
toastable.showToast(configuration: config, animated: true)
}
}
completion(true)
}
}
action.image = UIImage(systemName: "bookmark.fill")

View File

@ -75,7 +75,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
case .showConversation:
guard let id = UserActivityManager.getConversationStatus(from: activity) else { return nil }
return ConversationTableViewController(for: id, mastodonController: mastodonController)
return ConversationViewController(for: id, state: .unknown, mastodonController: mastodonController)
case .checkNotifications:
guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil }

View File

@ -0,0 +1,277 @@
//
// AccountFollowsListViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/18/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class AccountFollowsListViewController: UIViewController, CollectionViewController {
let accountID: String
let mastodonController: MastodonController
let mode: AccountFollowsViewController.Mode
var collectionView: UICollectionView! {
view as? UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state: State = .unloaded
private var newer: RequestRange?
private var older: RequestRange?
init(accountID: String, mastodonController: MastodonController, mode: AccountFollowsViewController.Mode) {
self.accountID = accountID
self.mastodonController = mastodonController
self.mode = mode
super.init(nibName: nil, bundle: nil)
title = mode.title
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionConfig
}
var config = sectionConfig
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
return config
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
cell.indicator.startAnimating()
}
return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .account(let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
}
})
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if case .unloaded = state {
Task {
await loadInitial()
}
}
}
private func request(for range: RequestRange) -> Request<[Account]> {
switch mode {
case .following:
return Account.getFollowing(accountID, range: range)
case .followers:
return Account.getFollowers(accountID, range: range)
}
}
private func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
await Task { @MainActor in
self.dataSource.apply(snapshot)
}.value
}
@MainActor
private func loadInitial() async {
guard case .unloaded = state else {
return
}
self.state = .loadingInitial
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator])
await apply(snapshot: snapshot)
do {
let (accounts, pagination) = try await mastodonController.run(request(for: .default))
await mastodonController.persistentContainer.addAll(accounts: accounts)
guard case .loadingInitial = self.state else {
return
}
self.state = .loaded
self.newer = pagination?.newer
self.older = pagination?.older
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems(accounts.map { .account($0.id) })
await apply(snapshot: snapshot)
} catch {
self.state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadInitial()
}
self.showToast(configuration: config, animated: true)
}
}
@MainActor
private func loadOlder() async {
guard case .loaded = state,
let older else {
return
}
self.state = .loadingOlder
var snapshot = dataSource.snapshot()
snapshot.appendItems([.loadingIndicator])
await apply(snapshot: snapshot)
do {
let (accounts, pagination) = try await mastodonController.run(request(for: older))
await mastodonController.persistentContainer.addAll(accounts: accounts)
guard case .loadingOlder = self.state else {
return
}
self.state = .loaded
self.older = pagination?.older
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
snapshot.appendItems(accounts.map { .account($0.id) })
await apply(snapshot: snapshot)
} catch {
self.state = .loaded
let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadOlder()
}
self.showToast(configuration: config, animated: true)
}
}
}
extension AccountFollowsListViewController {
enum State {
case unloaded
case loadingInitial
case loaded
case loadingOlder
}
}
extension AccountFollowsListViewController {
enum Section {
case accounts
}
enum Item: Hashable {
case account(String)
case loadingIndicator
var hideSeparators: Bool {
switch self {
case .account(_):
return false
case .loadingIndicator:
return true
}
}
}
}
extension AccountFollowsListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if indexPath.section == collectionView.numberOfSections - 1,
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
Task {
await self.loadOlder()
}
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath) else {
return
}
selected(account: id)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension AccountFollowsListViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard case .account(let id) = dataSource.itemIdentifier(for: indexPath),
let currentAccountID = mastodonController.accountInfo?.id,
let account = mastodonController.persistentContainer.account(for: id) else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}
extension AccountFollowsListViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension AccountFollowsListViewController: MenuActionProvider {
}
extension AccountFollowsListViewController: ToastableViewController {
}
extension AccountFollowsListViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -0,0 +1,46 @@
//
// AccountFollowsViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/18/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class AccountFollowsViewController: SegmentedPageViewController<AccountFollowsViewController.Mode> {
let accountID: String
let mastodonController: MastodonController
init(accountID: String, mastodonController: MastodonController) {
self.accountID = accountID
self.mastodonController = mastodonController
super.init(pages: [.following, .followers]) { mode in
AccountFollowsListViewController(accountID: accountID, mastodonController: mastodonController, mode: mode)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension AccountFollowsViewController {
enum Mode: SegmentedPageViewControllerPage {
case following, followers
var title: String {
switch self {
case .following:
return "Following"
case .followers:
return "Followers"
}
}
var segmentedControlTitle: String { title }
}
}

View File

@ -48,6 +48,8 @@ class BookmarksTableViewController: EnhancedTableViewController {
tableView.prefetchDataSource = self
userActivity = UserActivityManager.bookmarksActivity()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
@ -152,6 +154,21 @@ class BookmarksTableViewController: EnhancedTableViewController {
return config
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
let indicesToDelete = statusIDs
.compactMap { id in
self.statuses.firstIndex(where: { $0.id == id })
}
self.statuses.remove(atOffsets: IndexSet(indicesToDelete))
self.tableView.deleteRows(at: indicesToDelete.map { IndexPath(row: $0, section: 0) }, with: .automatic)
}
}
extension BookmarksTableViewController: TuskerNavigationDelegate {

View File

@ -22,11 +22,6 @@ class ConversationNode {
class ConversationTableViewController: EnhancedTableViewController {
static let showPostsImage = UIImage(systemName: "eye.fill")!
static let hidePostsImage = UIImage(systemName: "eye.slash.fill")!
static let bottomSeparatorTag = 101
weak var mastodonController: MastodonController!
let mainStatusID: String
@ -35,11 +30,8 @@ class ConversationTableViewController: EnhancedTableViewController {
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
var showStatusesAutomatically = false
var visibilityBarButtonItem: UIBarButtonItem!
private var loadingState = LoadingState.unloaded
init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) {
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID
self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
@ -57,8 +49,6 @@ class ConversationTableViewController: EnhancedTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("Conversation", comment: "conversation screen title")
tableView.delegate = self
tableView.dataSource = self
tableView.prefetchDataSource = self
@ -72,7 +62,7 @@ class ConversationTableViewController: EnhancedTableViewController {
// separators are disabled on the table view so we can re-add them ourselves
// so they're not inserted in between statuses in the ame sub-thread
tableView.separatorStyle = .none
tableView.cellLayoutMarginsFollowReadableWidth = true
tableView.cellLayoutMarginsFollowReadableWidth = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
@ -99,9 +89,9 @@ class ConversationTableViewController: EnhancedTableViewController {
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
if lastInSection {
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
if cell.viewWithTag(ViewTags.conversationBottomSeparator) == nil {
let separator = UIView()
separator.tag = ConversationTableViewController.bottomSeparatorTag
separator.tag = ViewTags.conversationBottomSeparator
separator.translatesAutoresizingMaskIntoConstraints = false
separator.backgroundColor = tableView.separatorColor
cell.addSubview(separator)
@ -113,7 +103,7 @@ class ConversationTableViewController: EnhancedTableViewController {
])
}
} else {
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
cell.viewWithTag(ViewTags.conversationBottomSeparator)?.removeFromSuperview()
}
return cell
@ -124,100 +114,29 @@ class ConversationTableViewController: EnhancedTableViewController {
return cell
}
})
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
updateVisibilityBarButtonItem()
navigationItem.rightBarButtonItem = visibilityBarButtonItem
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
Task {
await loadMainStatus()
}
}
private func updateVisibilityBarButtonItem() {
visibilityBarButtonItem.isSelected = showStatusesAutomatically
visibilityBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All"
func addMainStatus(_ status: StatusMO) {
loadViewIfNeeded()
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
}
@MainActor
private func loadMainStatus() async {
guard loadingState == .unloaded else { return }
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
let parentIDs = self.getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
await mainStatusLoaded(mainStatus)
} else {
loadingState = .loadingMain
let req = Client.getStatus(id: mainStatusID)
do {
let (status, _) = try await mastodonController.run(req)
let statusMO = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
await mainStatusLoaded(statusMO)
} catch {
let error = error as! Client.Error
loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadMainStatus()
}
showToast(configuration: config, animated: true)
return
}
}
}
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems([mainStatusItem], toSection: .statuses)
await dataSource.apply(snapshot, animatingDifferences: false)
loadingState = .loadedMain
await loadContext(for: mainStatus)
}
@MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard loadingState == .loadedMain else { return }
loadingState = .loadingContext
// save the id here because we can't access the MO from the whatever thread the network callback happens on
let mainStatusInReplyToID = mainStatus.inReplyToID
// todo: it would be nice to cache these contexts
let request = Status.getContext(mainStatusID)
do {
let (context, _) = try await mastodonController.run(request)
let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs)
} catch {
let error = error as! Client.Error
self.loadingState = .loadedMain
let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus)
}
self.showToast(configuration: config, animated: true)
}
}
private func contextLoaded(mainStatus: StatusMO, context: ConversationContext, parentIDs: [String]) {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = self.dataSource.snapshot()
snapshot.insertItems(parentIDs.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem)
// fetch all descendant status managed objects
@ -249,8 +168,6 @@ class ConversationTableViewController: EnhancedTableViewController {
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
}
}
self.loadingState = .loadedAll
}
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
@ -357,7 +274,7 @@ class ConversationTableViewController: EnhancedTableViewController {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: indexPath),
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
let conv = ConversationViewController(for: id, state: state, mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
@ -378,9 +295,7 @@ class ConversationTableViewController: EnhancedTableViewController {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
@objc func toggleVisibilityButtonPressed() {
showStatusesAutomatically = !showStatusesAutomatically
func updateVisibleCellCollapseState() {
let snapshot = dataSource.snapshot()
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
state.collapsed = !showStatusesAutomatically
@ -396,10 +311,7 @@ class ConversationTableViewController: EnhancedTableViewController {
// recalculate cell heights
tableView.beginUpdates()
tableView.endUpdates()
updateVisibilityBarButtonItem()
}
}
extension ConversationTableViewController {
@ -436,21 +348,11 @@ extension ConversationTableViewController {
}
}
extension ConversationTableViewController {
private enum LoadingState: Equatable {
case unloaded
case loadingMain
case loadedMain
case loadingContext
case loadedAll
}
}
extension ConversationTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
let vc = ConversationViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
// transfer show statuses automatically state when showing new conversation
vc.showStatusesAutomatically = self.showStatusesAutomatically
return vc

View File

@ -0,0 +1,270 @@
//
// ConversationViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/17/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class ConversationViewController: UIViewController {
weak var mastodonController: MastodonController!
let mainStatusID: String
let mainStatusState: CollapseState
var statusIDToScrollToOnLoad: String {
didSet {
if case .displaying(let vc) = state {
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad
}
}
}
var showStatusesAutomatically = false {
didSet {
if case .displaying(let vc) = state {
vc.showStatusesAutomatically = showStatusesAutomatically
}
}
}
private var collapseBarButtonItem: UIBarButtonItem!
private var state: State = .unloaded {
didSet {
switch oldValue {
case .loading(let indicator):
indicator.removeFromSuperview()
case .displaying(let vc):
vc.removeViewAndController()
default:
break
}
switch state {
case .unloaded:
break
case .loading(let indicator):
indicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
case .displaying(let vc):
embedChild(vc)
case .notFound:
showMainStatusNotFound()
}
updateVisibilityBarButtonItem()
}
}
init(for mainStatusID: String, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID
self.mainStatusState = mainStatusState
self.statusIDToScrollToOnLoad = mainStatusID
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("Conversation", comment: "conversation screen title")
view.backgroundColor = .secondarySystemBackground
collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed))
updateVisibilityBarButtonItem()
navigationItem.rightBarButtonItem = collapseBarButtonItem
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
private func updateVisibilityBarButtonItem() {
switch state {
case .loading(_), .displaying(_):
collapseBarButtonItem.isEnabled = true
default:
collapseBarButtonItem.isEnabled = false
}
collapseBarButtonItem.isSelected = showStatusesAutomatically
collapseBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All"
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
if case .unloaded = state {
await loadMainStatus()
}
}
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
if statusIDs.contains(mainStatusID) {
state = .notFound
} else if case .displaying(_) = state {
let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID)!
Task {
await loadContext(for: mainStatus)
}
}
}
// MARK: Loading
private func loadMainStatus() async {
@MainActor
func doLoadMainStatus() async -> StatusMO? {
switch await FetchStatusService(statusID: mainStatusID, mastodonController: mastodonController).run() {
case .loaded(let status):
return mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
case .notFound:
state = .notFound
return nil
case .error(let error):
self.showMainStatusError(error)
return nil
}
}
if let cached = mastodonController.persistentContainer.status(for: mainStatusID) {
// if we have a cached copy, display it immediately but still try to refresh it
Task {
await doLoadMainStatus()
}
await mainStatusLoaded(cached)
} else {
// otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
if let status = await doLoadMainStatus() {
await mainStatusLoaded(status)
}
}
}
@MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationTableViewController(for: mainStatusID, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad
vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus)
state = .displaying(vc)
await loadContext(for: mainStatus)
}
@MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard case .displaying(_) = state else {
return
}
let request = Status.getContext(mainStatus.id)
do {
let (context, _) = try await mastodonController.run(request)
guard case .displaying(let vc) = state else {
return
}
await vc.addContext(context, for: mainStatus)
} catch {
guard case .displaying(_) = state else {
return
}
let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus)
}
self.showToast(configuration: config, animated: true)
}
}
private func showMainStatusNotFound() {
let notFoundView = StatusNotFoundView(frame: .zero)
notFoundView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(notFoundView)
NSLayoutConstraint.activate([
notFoundView.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: notFoundView.trailingAnchor, multiplier: 1),
notFoundView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
}
private func showMainStatusError(_ error: Client.Error) {
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadMainStatus()
}
self.showToast(configuration: config, animated: true)
}
// MARK: Interaction
@objc func toggleCollapseButtonPressed() {
guard case .displaying(let vc) = state else {
return
}
showStatusesAutomatically = !showStatusesAutomatically
vc.updateVisibleCellCollapseState()
updateVisibilityBarButtonItem()
}
}
extension ConversationViewController {
enum State {
case unloaded
case loading(UIActivityIndicatorView)
case displaying(ConversationTableViewController)
case notFound
}
}
extension ConversationViewController: ToastableViewController {
var toastScrollView: UIScrollView? {
if case .displaying(let vc) = state {
return vc.toastScrollView
} else {
return nil
}
}
}
extension ConversationViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
if case .displaying(let vc) = state {
return vc.handleStatusBarTapped(xPosition: xPosition)
} else {
return .continue
}
}
}

View File

@ -101,6 +101,12 @@ class TrendingStatusesViewController: UIViewController {
}
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
@ -137,6 +143,27 @@ class TrendingStatusesViewController: UIViewController {
snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) })
await dataSource.apply(snapshot)
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
let toDelete = statusIDs
.map { id in
Item.status(id: id, collapseState: .unknown, filterState: .unknown)
}
.filter { item in
snapshot.itemIdentifiers.contains(item)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension TrendingStatusesViewController {

View File

@ -89,6 +89,8 @@ class MainSidebarViewController: UIViewController {
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.isSpringLoaded = true
// TODO: allow focusing sidebar once there's a workaround for keyboard shortcuts from main split content not being accessible when not in the responder chain
collectionView.allowsFocus = false
view.addSubview(collectionView)
dataSource = createDataSource()

View File

@ -56,8 +56,33 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
tableView.cellLayoutMarginsFollowReadableWidth = true
tableView.cellLayoutMarginsFollowReadableWidth = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
tableView.allowsFocus = true
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
// this is not efficient, since the number of notifications is almost certainly greater than the number of deleted statuses
// but we can't just check if the status is in the data source, since we don't have the corresponding notification/group
let toDelete = snapshot.itemIdentifiers
.filter { item in
guard case .notificationGroup(let group) = item else {
return false
}
return group.kind == .mention && statusIDs.contains(group.notifications.first!.status!.id)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> {

View File

@ -143,6 +143,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -344,6 +346,31 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
}
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
let toDelete = statusIDs
.flatMap { id in
// need to delete from both pinned and non-pinned sections
[
Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: false),
Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: true),
]
}
.filter { item in
snapshot.itemIdentifiers.contains(item)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension ProfileStatusesViewController {
@ -376,6 +403,7 @@ extension ProfileStatusesViewController {
typealias TimelineItem = String
case header(String)
// the status item must contain the pinned state, since a status can appear in both the pinned and regular sections simultaneously
case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
case loadingIndicator
case confirmLoadMore

View File

@ -121,6 +121,8 @@ class SearchResultsViewController: EnhancedTableViewController {
.sink(receiveValue: performSearch(query:))
userActivity = UserActivityManager.searchActivity()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? {
@ -207,6 +209,28 @@ class SearchResultsViewController: EnhancedTableViewController {
errorLabel.text = error.localizedDescription
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
let toDelete = statusIDs
.map { id in
Item.status(id, .unknown)
}
.filter { item in
snapshot.itemIdentifiers.contains(item)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

View File

@ -0,0 +1,399 @@
//
// StatusActionAccountListCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController {
private let statusID: String
private let actionType: StatusActionAccountListViewController.ActionType
private let mastodonController: MastodonController
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
var showInacurateCountWarning = false
var collectionView: UICollectionView! {
view as? UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state: State = .unloaded
private var older: RequestRange?
/**
Creates a new view controller showing the accounts that performed the given action on the given status.
- Parameter mastodonController The `MastodonController` instance this view controller uses.
*/
init(statusID: String, actionType: StatusActionAccountListViewController.ActionType, mastodonController: MastodonController) {
self.statusID = statusID
self.actionType = actionType
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
var accountsConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
accountsConfig.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionConfig
}
var config = sectionConfig
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
return config
}
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.footerMode = self.showInacurateCountWarning ? .supplementary : .none
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
case .accounts:
return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
}
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
cell.indicator.startAnimating()
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(let id, let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .account(let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
}
}
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
var config = headerView.defaultContentConfiguration()
config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
headerView.contentConfiguration = config
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
}
return dataSource
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if case .unloaded = state {
Task {
await loadAccounts()
}
}
}
func addStatus(_ status: StatusMO, state: CollapseState) {
loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.status, .accounts])
snapshot.appendItems([.status(status.id, state)], toSection: .status)
dataSource.apply(snapshot, animatingDifferences: false)
}
func setAccounts(_ accountIDs: [String], animated: Bool) {
guard case .unloaded = state else {
return
}
var snapshot = dataSource.snapshot()
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
dataSource.apply(snapshot, animatingDifferences: animated)
self.state = .loaded
}
private func request(for range: RequestRange) -> Request<[Account]> {
switch actionType {
case .favorite:
return Status.getFavourites(statusID, range: range)
case .reblog:
return Status.getReblogs(statusID, range: range)
}
}
func apply(snapshot: NSDiffableDataSourceSnapshot<Section, Item>) async {
await Task { @MainActor in
self.dataSource.apply(snapshot)
}.value
}
@MainActor
private func loadAccounts() async {
guard case .unloaded = state else {
return
}
self.state = .loadingInitial
var snapshot = dataSource.snapshot()
snapshot.appendItems([.loadingIndicator], toSection: .accounts)
await apply(snapshot: snapshot)
do {
let (accounts, pagination) = try await mastodonController.run(request(for: .default))
await mastodonController.persistentContainer.addAll(accounts: accounts)
guard case .loadingInitial = self.state else {
return
}
self.state = .loaded
self.older = pagination?.older
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
await apply(snapshot: snapshot)
} catch {
self.state = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
toast.dismissToast(animated: true)
await self.loadAccounts()
}
self.showToast(configuration: config, animated: true)
}
}
@MainActor
private func loadOlder() async {
guard case .loaded = state,
let older else {
return
}
self.state = .loadingOlder
var snapshot = self.dataSource.snapshot()
snapshot.appendItems([.loadingIndicator], toSection: .accounts)
await apply(snapshot: snapshot)
do {
try! await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
let (accounts, pagination) = try await mastodonController.run(request(for: older))
await mastodonController.persistentContainer.addAll(accounts: accounts)
guard case .loadingOlder = self.state else {
return
}
self.state = .loaded
self.older = pagination?.older
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
await apply(snapshot: snapshot)
} catch {
self.state = .loaded
let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadOlder()
}
self.showToast(configuration: config, animated: true)
}
}
}
extension StatusActionAccountListCollectionViewController {
enum State {
case unloaded
case loadingInitial
case loaded
case loadingOlder
}
}
extension StatusActionAccountListCollectionViewController {
enum Section {
case status
case accounts
}
enum Item: Hashable {
case status(String, CollapseState)
case account(String)
case loadingIndicator
var hideSeparators: Bool {
switch self {
case .loadingIndicator:
return true
default:
return false
}
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.status(let a, _), .status(let b, _)):
return a == b
case (.account(let a), .account(let b)):
return a == b
case (.loadingIndicator, .loadingIndicator):
return true
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .status(let id, _):
hasher.combine(0)
hasher.combine(id)
case .account(let id):
hasher.combine(1)
hasher.combine(id)
case .loadingIndicator:
hasher.combine(2)
}
}
}
}
extension StatusActionAccountListCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if indexPath.section == collectionView.numberOfSections - 1,
indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
Task {
await self.loadOlder()
}
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
case .status(let id, let state):
selected(status: id, state: state.copy())
case .account(let id):
selected(account: id)
case .loadingIndicator:
return
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
switch item {
case .status:
return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
case .account(let id):
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
}
case .loadingIndicator:
return nil
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension StatusActionAccountListCollectionViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,
let item = dataSource.itemIdentifier(for: indexPath) else {
return []
}
let provider: NSItemProvider
switch item {
case .status(let id, _):
guard let status = mastodonController.persistentContainer.status(for: id) else {
return []
}
provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
case .account(let id):
guard let account = mastodonController.persistentContainer.account(for: id) else {
return []
}
provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
case .loadingIndicator:
return []
}
return [UIDragItem(itemProvider: provider)]
}
}
extension StatusActionAccountListCollectionViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension StatusActionAccountListCollectionViewController: MenuActionProvider {
}
extension StatusActionAccountListCollectionViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
}
extension StatusActionAccountListCollectionViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -2,28 +2,58 @@
// StatusActionAccountListViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 1/17/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class StatusActionAccountListViewController: UIViewController, CollectionViewController {
class StatusActionAccountListViewController: UIViewController {
private let mastodonController: MastodonController
private let actionType: ActionType
private let actionType: StatusActionAccountListViewController.ActionType
private let statusID: String
private let statusState: CollapseState
private var accountIDs: [String]?
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
var showInacurateCountWarning = false
var collectionView: UICollectionView! {
view as? UICollectionView
var showInacurateCountWarning = false {
didSet {
if case .displaying(let vc) = state {
vc.showInacurateCountWarning = showInacurateCountWarning
}
}
}
private var state: State = .unloaded {
didSet {
switch oldValue {
case .loading(let indicator):
indicator.removeFromSuperview()
case .displaying(let vc):
vc.removeViewAndController()
default:
break
}
switch state {
case .unloaded:
break
case .loading(let indicator):
indicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(indicator)
NSLayoutConstraint.activate([
indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
case .displaying(let vc):
embedChild(vc)
case .notFound:
showStatusNotFound()
}
}
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
/**
Creates a new view controller showing the accounts that performed the given action on the given status.
@ -33,7 +63,7 @@ class StatusActionAccountListViewController: UIViewController, CollectionViewCon
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
- Parameter mastodonController The `MastodonController` instance this view controller uses.
*/
init(actionType: ActionType, statusID: String, statusState: CollapseState, accountIDs: [String]?, mastodonController: MastodonController) {
init(actionType: StatusActionAccountListViewController.ActionType, statusID: String, statusState: CollapseState, accountIDs: [String]?, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.actionType = actionType
self.statusID = statusID
@ -41,6 +71,14 @@ class StatusActionAccountListViewController: UIViewController, CollectionViewCon
self.accountIDs = accountIDs
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
switch actionType {
case .favorite:
@ -48,120 +86,102 @@ class StatusActionAccountListViewController: UIViewController, CollectionViewCon
case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
view.backgroundColor = .secondarySystemBackground
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.footerMode = self.showInacurateCountWarning ? .supplementary : .none
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
case .accounts:
return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment)
}
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.delegate = self
cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow, precomputedContent: nil)
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status:
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: ())
case .account(let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
}
}
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
var config = headerView.defaultContentConfiguration()
config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
headerView.contentConfiguration = config
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
}
return dataSource
}
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.status, .accounts])
snapshot.appendItems([.status], toSection: .status)
if let accountIDs {
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
}
dataSource.apply(snapshot, animatingDifferences: false)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if accountIDs == nil {
if case .unloaded = state {
Task {
await loadAccounts()
await loadStatus()
}
}
}
private func loadAccounts() async {
let request: Request<[Account]>
switch actionType {
case .favorite:
request = Status.getFavourites(statusID)
case .reblog:
request = Status.getReblogs(statusID)
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
do {
// TODO: pagination
let (accounts, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: accounts) {
continuation.resume()
}
}
accountIDs = accounts.map(\.id)
var snapshot = dataSource.snapshot()
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
dataSource.apply(snapshot, animatingDifferences: true) {}
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
toast.dismissToast(animated: true)
await self.loadAccounts()
}
self.showToast(configuration: config, animated: true)
if statusIDs.contains(statusID) {
state = .notFound
}
}
// MARK: Loading
private func loadStatus() async {
@MainActor
func doLoadStatus() async -> StatusMO? {
switch await FetchStatusService(statusID: statusID, mastodonController: mastodonController).run() {
case .loaded(let status):
return mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
case .notFound:
state = .notFound
return nil
case .error(let error):
self.showStatusError(error)
return nil
}
}
if let cached = mastodonController.persistentContainer.status(for: statusID) {
await statusLoaded(cached)
} else {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
if let status = await doLoadStatus() {
await statusLoaded(status)
}
}
}
private func statusLoaded(_ status: StatusMO) async {
let vc = StatusActionAccountListCollectionViewController(statusID: statusID, actionType: actionType, mastodonController: mastodonController)
vc.addStatus(status, state: statusState)
vc.showInacurateCountWarning = showInacurateCountWarning
if let accountIDs {
vc.setAccounts(accountIDs, animated: false)
}
state = .displaying(vc)
}
private func showStatusNotFound() {
let notFoundView = StatusNotFoundView(frame: .zero)
notFoundView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(notFoundView)
NSLayoutConstraint.activate([
notFoundView.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1),
view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: notFoundView.trailingAnchor, multiplier: 1),
notFoundView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
}
private func showStatusError(_ error: Client.Error) {
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadStatus()
}
self.showToast(configuration: config, animated: true)
}
}
extension StatusActionAccountListViewController {
enum State {
case unloaded
case loading(UIActivityIndicatorView)
case displaying(StatusActionAccountListCollectionViewController)
case notFound
}
}
extension StatusActionAccountListViewController {
@ -170,104 +190,12 @@ extension StatusActionAccountListViewController {
}
}
extension StatusActionAccountListViewController {
enum Section {
case status
case accounts
}
enum Item: Hashable {
case status
case account(String)
}
}
extension StatusActionAccountListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
case .status:
selected(status: statusID, state: statusState.copy())
case .account(let id):
selected(account: id)
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
extension StatusActionAccountListViewController: ToastableViewController {
var toastScrollView: UIScrollView? {
if case .displaying(let vc) = state {
return vc.toastScrollView
} else {
return nil
}
switch item {
case .status:
return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
case .account(let id):
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
}
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension StatusActionAccountListViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,
let item = dataSource.itemIdentifier(for: indexPath) else {
return []
}
let provider: NSItemProvider
switch item {
case .status:
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return []
}
provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: statusID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
case .account(let id):
guard let account = mastodonController.persistentContainer.account(for: id) else {
return []
}
provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
}
}
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension StatusActionAccountListViewController: MenuActionProvider {
}
extension StatusActionAccountListViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
}
extension StatusActionAccountListViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -146,6 +146,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
_ = syncPositionIfNecessary(alwaysPrompt: true)
}
.store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
// separate method because InstanceTimelineViewController needs to be able to customize it
@ -364,6 +365,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
]
SentrySDK.addBreadcrumb(crumb: crumb)
}()
let originalPositionStatusIDs = position.statusIDs
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
guard !unloaded.isEmpty else {
return true
@ -420,9 +423,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
SentrySDK.addBreadcrumb(crumb: crumb)
}()
// if an icloud sync completed in between starting to load the statuses and finishing, try to load again
if position.statusIDs != originalPositionStatusIDs {
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
crumb.message = "TimelinePosition statusIDs changed, retrying load"
SentrySDK.addBreadcrumb(crumb: crumb)
return await loadStatusesToRestore(position: position)
}
// update the timeline position in case some statuses couldn't be loaded
if let center = position.centerStatusID {
let nearestLoadedStatusToCenter = position.statusIDs[position.statusIDs.firstIndex(of: center)!...].first(where: { id in
if let center = position.centerStatusID,
let centerIndex = position.statusIDs.firstIndex(of: center) {
let nearestLoadedStatusToCenter = position.statusIDs[centerIndex...].first(where: { id in
// was already loaded or was just now loaded
!unloaded.contains(id) || statuses.contains(where: { $0.id == id })
})
@ -820,6 +832,26 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = self.dataSource.snapshot()
let toDelete = statusIDs
.map { id in
Item.status(id: id, collapseState: .unknown, filterState: .unknown)
}
.filter { item in
snapshot.itemIdentifiers.contains(item)
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension TimelineViewController {

View File

@ -172,7 +172,8 @@ extension MenuActionProvider {
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id else {
guard let accountID = mastodonController.accountInfo?.id,
let account = mastodonController.account else {
// user is logged out
return [
openInSafariAction(url: status.url!),
@ -230,15 +231,7 @@ extension MenuActionProvider {
}), at: 1)
}
var actionsSection: [UIAction] = [
createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [weak self] _ in
let report = EditedReport(accountID: status.account.id)
report.statusIDs = [status.id]
let view = ReportView(report: report, mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self?.navigationDelegate?.present(host, animated: true)
})
]
var actionsSection: [UIMenuElement] = []
if includeStatusButtonActions {
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
@ -247,27 +240,44 @@ extension MenuActionProvider {
}), at: 0)
}
if let account = mastodonController.account {
// only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
let muted = status.muted
toggleableSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.run(request) { (response) in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
}
// only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
let muted = status.muted
toggleableSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.run(request) { (response) in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
}
}))
}
}
}))
}
// only allowing pinning user's own statuses
if account.id == status.account.id,
mastodonController.instanceFeatures.profilePinnedStatuses {
if status.poll != nil {
actionsSection.append(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
guard let mastodonController = self?.mastodonController else { return }
let request = Client.getStatus(id: status.id)
mastodonController.run(request, completion: { (response) in
switch response {
case .success(let status, _):
// todo: this shouldn't really use the viewContext, but for some reason saving the
// backgroundContext with the new version of the status isn't updating the viewContext
DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
}
case .failure(let error):
self?.handleError(error, title: "Error Refreshing Poll")
}
})
}))
}
if account.id == status.account.id {
if mastodonController.instanceFeatures.profilePinnedStatuses {
let pinned = status.pinned ?? false
toggleableSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
@ -283,25 +293,28 @@ extension MenuActionProvider {
})
}))
}
}
if status.poll != nil {
actionsSection.insert(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
guard let mastodonController = self?.mastodonController else { return }
let request = Client.getStatus(id: status.id)
mastodonController.run(request, completion: { (response) in
switch response {
case .success(let status, _):
// todo: this shouldn't really use the viewContext, but for some reason saving the
// backgroundContext with the new version of the status isn't updating the viewContext
DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
}
case .failure(let error):
self?.handleError(error, title: "Error Refreshing Poll")
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
guard let self,
let navigationDelegate = self.navigationDelegate else {
return
}
Task { @MainActor in
let service = DeleteStatusService(status: status, mastodonController: mastodonController, presenter: navigationDelegate)
await service.run()
}
})
}), at: 0)
]))
} else {
actionsSection.append(createAction(identifier: "report", title: "Report", systemImageName: "flag", handler: { [weak self] _ in
let report = EditedReport(accountID: status.account.id)
report.statusIDs = [status.id]
let view = ReportView(report: report, mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self?.navigationDelegate?.present(host, animated: true)
}))
}
var shareSection: [UIAction] = []

View File

@ -156,7 +156,7 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
// MARK: TabbedPageViewController
func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return }
guard currentIndex < pages.count - 1 else { return }
selectPage(pages[currentIndex + 1], animated: true)
}
@ -198,3 +198,9 @@ extension SegmentedPageViewController: StatusBarTappableViewController {
return .continue
}
}
extension SegmentedPageViewController: NestedResponderProvider {
var innerResponder: UIResponder? {
currentViewController
}
}

View File

@ -283,7 +283,11 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
// ordinarily, the next responder in the chain would be the SplitNavigationController's view
// but that would bypass the VC in the root nav, so we reroute the repsonder chain to include it
// first seems to be nil when using the view debugger for some reason, so in that case, defer to super
owner.viewControllers.first?.view ?? super.next
if let root = owner.viewControllers.first {
return root.innermostResponder() ?? super.next
} else {
return super.next
}
}
private func configureSecondarySplitCloseButton(for viewController: UIViewController) {
@ -300,3 +304,17 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
}
}
protocol NestedResponderProvider {
var innerResponder: UIResponder? { get }
}
extension UIResponder {
func innermostResponder() -> UIResponder? {
if let nestedProvider = self as? NestedResponderProvider {
return nestedProvider.innerResponder?.innermostResponder() ?? self
} else {
return self
}
}
}

View File

@ -84,8 +84,11 @@ class TimelineLikeController<Item> {
guard state == .notLoadedInitial || state == .idle else {
return
}
state = .restoringInitial
let token = LoadAttemptToken()
state = .restoringInitial(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .restoringInitial(token, hasAddedLoadingIndicator: true))
await doRestore()
await loadingIndicator.end()
state = .idle
}
@ -198,7 +201,7 @@ class TimelineLikeController<Item> {
enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial
case idle
case restoringInitial
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken)
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
@ -210,8 +213,8 @@ class TimelineLikeController<Item> {
return "notLoadedInitial"
case .idle:
return "idle"
case .restoringInitial:
return "restoringInitial"
case .restoringInitial(let token, let hasAddedLoadingIndicator):
return "restoringInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingNewer(let token):
@ -234,13 +237,13 @@ class TimelineLikeController<Item> {
}
case .idle:
switch to {
case .restoringInitial, .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
return true
default:
return false
}
case .restoringInitial:
return to == .idle
case .restoringInitial(let token, let hasAddedLoadingIndicator):
return to == .idle || (!hasAddedLoadingIndicator && to == .restoringInitial(token, hasAddedLoadingIndicator: true))
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
case .loadingNewer(_):
@ -256,14 +259,14 @@ class TimelineLikeController<Item> {
switch event {
case .addLoadingIndicator:
switch self {
case .loadingInitial(_, _), .loadingOlder(_, _):
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingOlder(_, _):
return true
default:
return false
}
case .removeLoadingIndicator:
switch self {
case .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
case .restoringInitial(_, hasAddedLoadingIndicator: true), .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
return true
default:
return false
@ -344,6 +347,7 @@ class TimelineLikeController<Item> {
}
}
@MainActor
class DeferredLoadingIndicator {
private let owner: TimelineLikeController<Item>
private let addedIndicatorState: State
@ -352,19 +356,18 @@ class TimelineLikeController<Item> {
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
self.owner = owner
self.addedIndicatorState = addedIndicatorState
self.task = Task {
self.task = Task { @MainActor in
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
guard await state == owner.state else {
guard state == owner.state else {
return
}
await owner.emit(event: .addLoadingIndicator)
await owner.transition(to: addedIndicatorState)
owner.transition(to: addedIndicatorState)
}
}
func end() async {
let state = await owner.state
if state == addedIndicatorState {
if owner.state == addedIndicatorState {
await owner.emit(event: .removeLoadingIndicator)
} else {
task.cancel()

View File

@ -13,7 +13,7 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController
}
extension TuskerNavigationDelegate {
@ -78,8 +78,8 @@ extension TuskerNavigationDelegate {
}
}
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
return ConversationViewController(for: mainStatusID, state: state, mastodonController: apiController)
}
func selected(status statusID: String) {

View File

@ -16,4 +16,5 @@ struct ViewTags {
static let navEmptyTitleView = 42003
static let splitNavCloseSecondaryButton = 42004
static let customAlertSeparator = 42005
static let conversationBottomSeparator = 42006
}

View File

@ -58,6 +58,7 @@ class CachedImageView: UIImageView {
}
try Task.checkCancellation()
self.image = transformedImage
self.isGrayscale = Preferences.shared.grayscaleImages
}
}

View File

@ -35,6 +35,7 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var relationshipLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var followCountButton: UIButton!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
var accountID: String!
@ -148,6 +149,26 @@ class ProfileHeaderView: UIView {
fieldsView.delegate = delegate
fieldsView.updateUI(account: account)
let (followingAbbr, followingSpelledOut) = formatBigNumber(account.followingCount)
let (followersAbbr, followersSpelledOut) = formatBigNumber(account.followersCount)
let followCountTitle = NSMutableAttributedString()
followCountTitle.append(NSAttributedString(string: followingAbbr, attributes: [
.font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!,
.foregroundColor: UIColor.label,
]))
followCountTitle.append(NSAttributedString(string: " Following, ", attributes: [
.foregroundColor: UIColor.secondaryLabel,
]))
followCountTitle.append(NSAttributedString(string: followersAbbr, attributes: [
.font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!,
.foregroundColor: UIColor.label,
]))
followCountTitle.append(NSAttributedString(string: " Follower\(account.followersCount == 1 ? "" : "s")", attributes: [
.foregroundColor: UIColor.secondaryLabel,
]))
followCountButton.setAttributedTitle(followCountTitle, for: .normal)
followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers"
accessibilityElements = [
displayNameLabel!,
usernameLabel!,
@ -260,6 +281,22 @@ class ProfileHeaderView: UIView {
}
}
private func formatBigNumber(_ value: Int) -> (String, String) {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 1
for (threshold, abbr, spelledOut) in [(1_000_000, "m", "million"), (1_000, "k", "thousand")] {
if value >= threshold {
let frac = Double(value) / Double(threshold)
let s = formatter.string(from: frac as NSNumber)!
return ("\(s)\(abbr)", "\(s) \(spelledOut)")
}
}
let s = formatter.string(from: value as NSNumber)!
return (s, s)
}
// MARK: Interaction
@objc func avatarPressed() {
@ -313,6 +350,10 @@ class ProfileHeaderView: UIView {
}
}
@IBAction func followCountButtonPressed(_ sender: Any) {
guard let accountID else { return }
delegate?.show(AccountFollowsViewController(accountID: accountID, mastodonController: mastodonController))
}
}
extension ProfileHeaderView {

View File

@ -46,26 +46,22 @@
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vFa-g3-xIP" customClass="ProfileHeaderButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="374" y="158" width="32" height="32"/>
<rect key="frame" x="358" y="142" width="48" height="48"/>
<constraints>
<constraint firstAttribute="width" constant="32" id="969-oZ-nVJ"/>
<constraint firstAttribute="height" constant="32" id="Rm3-CK-8eb"/>
<constraint firstAttribute="width" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1:1" id="B01-24-GJj"/>
</constraints>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="cr8-p9-xkc" customClass="ProfileHeaderButton" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="265" y="158" width="101" height="32"/>
<constraints>
<constraint firstAttribute="height" constant="32" id="8Ww-Yo-g7G"/>
</constraints>
<rect key="frame" x="249" y="140" width="101" height="52"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
<connections>
<action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/>
</connections>
</button>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
<rect key="frame" x="16" y="266" width="398" height="596"/>
<subviews>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
@ -75,19 +71,37 @@
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/>
<rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="468" width="398" height="128"/>
<rect key="frame" x="0.0" y="263.5" width="398" height="128"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ood-3e-sSu" userLabel="Spacer">
<rect key="frame" x="0.0" y="395.5" width="240" height="8"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="8" id="5ri-vD-wXe"/>
</constraints>
</view>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
<rect key="frame" x="0.0" y="407.5" width="219" height="188.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" title="123 Following, 1.2k Followers">
<fontDescription key="titleFontDescription" style="UICTFontTextStyleBody"/>
<directionalEdgeInsets key="contentInsets" top="0.0" leading="0.0" bottom="0.0" trailing="0.0"/>
</buttonConfiguration>
<connections>
<action selector="followCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
@ -134,6 +148,7 @@
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
<constraint firstItem="cr8-p9-xkc" firstAttribute="height" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1.07812" id="Z5o-4H-Wc1"/>
<constraint firstItem="vFa-g3-xIP" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" constant="-8" id="ZB4-ys-9zP"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
<constraint firstItem="cr8-p9-xkc" firstAttribute="trailing" secondItem="vFa-g3-xIP" secondAttribute="leading" constant="-8" id="f1L-S8-l6H"/>
@ -152,6 +167,7 @@
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
<outlet property="followButton" destination="cr8-p9-xkc" id="E1n-gh-mCl"/>
<outlet property="followCountButton" destination="5w9-LA-8kc" id="umN-5g-q8N"/>
<outlet property="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
<outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/>

View File

@ -361,23 +361,31 @@ class BaseStatusTableViewCell: UITableViewCell {
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!
if animated, let buttonImageView = collapseButton.imageView {
// we need to use a keyframe animation for this, because we want to control the direction the chevron rotates
// when rotating ±π, UIKit will always rotate in the same direction
// using a keyframe to set an intermediate point in the animation allows us to force a specific direction
UIView.animateKeyframes(withDuration: 0.2, delay: 0, options: .calculationModeLinear, animations: {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
buttonImageView.transform = CGAffineTransform(rotationAngle: collapsed ? .pi / 2 : -.pi / 2)
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
buttonImageView.transform = CGAffineTransform(rotationAngle: .pi)
}
}, completion: { (finished) in
buttonImageView.transform = .identity
self.collapseButton.setImage(buttonImage, for: .normal)
})
} else {
if let buttonImageView = collapseButton.imageView {
collapseButton.setImage(buttonImage, for: .normal)
if animated {
buttonImageView.layer.opacity = 0
// this whole hack is necessary because when just rotating buttonImageView, it moves to the left of the button and then animates back to the center
let imageView = UIImageView(image: buttonImageView.image)
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: buttonImageView.widthAnchor),
imageView.heightAnchor.constraint(equalTo: buttonImageView.heightAnchor),
imageView.centerXAnchor.constraint(equalTo: collapseButton.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: collapseButton.centerYAnchor),
])
imageView.tintColor = .white
UIView.animate(withDuration: 0.3, delay: 0) {
imageView.transform = CGAffineTransform(rotationAngle: .pi)
} completion: { _ in
imageView.removeFromSuperview()
buttonImageView.layer.opacity = 1
}
}
}
if collapsed {

View File

@ -81,10 +81,14 @@
<constraints>
<constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/>
</constraints>
<color key="tintColor" systemColor="systemBackgroundColor"/>
<color key="tintColor" systemColor="tintColor"/>
<state key="normal" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections>
<action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/>
</connections>
@ -98,9 +102,9 @@
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="131" width="361" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/>
<constraint firstAttribute="height" priority="999" constant="90" id="Tdo-Hv-ITE"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">

View File

@ -25,8 +25,10 @@ class StatusCardView: UIView {
private var imageRequest: ImageCache.Request?
private var isGrayscale = false
private var hStack: UIStackView!
private var titleLabel: UILabel!
private var descriptionLabel: UILabel!
private var domainLabel: UILabel!
private var imageView: UIImageView!
private var placeholderImageView: UIImageView!
@ -41,11 +43,10 @@ class StatusCardView: UIView {
}
private func commonInit() {
self.clipsToBounds = true
self.layer.cornerRadius = 6.5
self.layer.borderWidth = 1
self.layer.borderColor = UIColor.lightGray.cgColor
self.backgroundColor = inactiveBackgroundColor
self.layer.shadowColor = UIColor.black.cgColor
self.layer.shadowRadius = 5
self.layer.shadowOpacity = 0.2
self.layer.shadowOffset = .zero
self.addInteraction(UIContextMenuInteraction(delegate: self))
@ -53,16 +54,24 @@ class StatusCardView: UIView {
titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 2
titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
descriptionLabel = UILabel()
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
descriptionLabel.adjustsFontForContentSizeCategory = true
descriptionLabel.numberOfLines = 2
descriptionLabel.numberOfLines = 3
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
domainLabel = UILabel()
domainLabel.font = .preferredFont(forTextStyle: .caption2)
domainLabel.adjustsFontForContentSizeCategory = true
domainLabel.numberOfLines = 1
domainLabel.textColor = .tintColor
let vStack = UIStackView(arrangedSubviews: [
titleLabel,
descriptionLabel
descriptionLabel,
domainLabel,
])
vStack.axis = .vertical
vStack.alignment = .leading
@ -73,15 +82,23 @@ class StatusCardView: UIView {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
let hStack = UIStackView(arrangedSubviews: [
let spacer = UIView()
spacer.backgroundColor = .clear
hStack = UIStackView(arrangedSubviews: [
imageView,
vStack
vStack,
spacer,
])
hStack.translatesAutoresizingMaskIntoConstraints = false
hStack.axis = .horizontal
hStack.alignment = .center
hStack.distribution = .fill
hStack.spacing = 4
hStack.clipsToBounds = true
hStack.layer.borderWidth = 0.5
hStack.layer.borderColor = UIColor.lightGray.cgColor
hStack.backgroundColor = inactiveBackgroundColor
addSubview(hStack)
@ -98,8 +115,10 @@ class StatusCardView: UIView {
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
spacer.widthAnchor.constraint(equalToConstant: 4),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
hStack.topAnchor.constraint(equalTo: topAnchor),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
@ -112,6 +131,11 @@ class StatusCardView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func layoutSubviews() {
super.layoutSubviews()
hStack.layer.cornerRadius = 0.1 * bounds.height
}
func updateUI(status: StatusMO) {
guard status.id != statusID else {
return
@ -135,6 +159,13 @@ class StatusCardView: UIView {
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty
if let host = card.url.host {
domainLabel.text = host.serialized
domainLabel.isHidden = false
} else {
domainLabel.isHidden = true
}
}
@objc private func updateUIForPreferences() {
@ -201,7 +232,7 @@ class StatusCardView: UIView {
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = activeBackgroundColor
hStack.backgroundColor = activeBackgroundColor
setNeedsDisplay()
}
@ -209,7 +240,7 @@ class StatusCardView: UIView {
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor
hStack.backgroundColor = inactiveBackgroundColor
setNeedsDisplay()
if let card = card, let delegate = navigationDelegate {
@ -218,7 +249,7 @@ class StatusCardView: UIView {
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor
hStack.backgroundColor = inactiveBackgroundColor
setNeedsDisplay()
}
@ -238,6 +269,12 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
let params = UIPreviewParameters()
params.visiblePath = UIBezierPath(roundedRect: hStack.bounds, cornerRadius: hStack.layer.cornerRadius)
return UITargetedPreview(view: hStack, parameters: params)
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController,
let delegate = navigationDelegate {

View File

@ -20,7 +20,7 @@ class StatusContentContainer: UIView {
let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 65),
$0.heightAnchor.constraint(equalToConstant: 90),
])
}

View File

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

View File

@ -433,7 +433,7 @@ extension TimelineStatusTableViewCell: MenuPreviewProvider {
return nil
}
return (
content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
content: { ConversationViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] }
)
}

View File

@ -109,10 +109,16 @@
<constraints>
<constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/>
</constraints>
<color key="tintColor" systemColor="systemBackgroundColor"/>
<color key="tintColor" systemColor="tintColor"/>
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
<state key="normal" image="chevron.down" catalog="system">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections>
<action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/>
</connections>
@ -126,9 +132,9 @@
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="200.5" width="295" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
<constraint firstAttribute="height" priority="999" constant="90" id="khY-jm-CPn"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">

View File

@ -0,0 +1,60 @@
//
// StatusNotFoundView.swift
// Tusker
//
// Created by Shadowfacts on 1/17/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class StatusNotFoundView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let emoji = UILabel()
emoji.font = .systemFont(ofSize: 64)
emoji.text = "🤷"
let title = UILabel()
title.textColor = .secondaryLabel
title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
title.adjustsFontForContentSizeCategory = true
title.text = "Not Found"
let subtitle = UILabel()
subtitle.textColor = .secondaryLabel
subtitle.font = .preferredFont(forTextStyle: .body)
subtitle.adjustsFontForContentSizeCategory = true
subtitle.text = "The post you are looking for may have been deleted, or may not be visible to you."
subtitle.numberOfLines = 0
subtitle.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [
emoji,
title,
subtitle,
])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 8
stack.isAccessibilityElement = true
stack.accessibilityLabel = "\(title.text!). \(subtitle.text!)"
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}