Compare commits
29 Commits
5029b26b40
...
a49e9f2c1f
Author | SHA1 | Date |
---|---|---|
Shadowfacts | a49e9f2c1f | |
Shadowfacts | b1421767dd | |
Shadowfacts | 8ee916411e | |
Shadowfacts | 9d845bf6c1 | |
Shadowfacts | 9a2c24942a | |
Shadowfacts | cca2a03b2f | |
Shadowfacts | 1a64bfcef8 | |
Shadowfacts | 907810d98a | |
Shadowfacts | 23a4999196 | |
Shadowfacts | 3e0feba273 | |
Shadowfacts | 468a559127 | |
Shadowfacts | c03fc86300 | |
Shadowfacts | a33be0b556 | |
Shadowfacts | 6aee926f00 | |
Shadowfacts | 13640be91d | |
Shadowfacts | 5123cf20c3 | |
Shadowfacts | bf739b9f41 | |
Shadowfacts | 4211806b5f | |
Shadowfacts | 88aada8d35 | |
Shadowfacts | 5623cedab3 | |
Shadowfacts | ccfc8331fb | |
Shadowfacts | 10803408cd | |
Shadowfacts | fb7a7db6e8 | |
Shadowfacts | 78cd1313fe | |
Shadowfacts | db1bbf7148 | |
Shadowfacts | 5f19adf2d0 | |
Shadowfacts | 6f006adbc1 | |
Shadowfacts | 39bff06897 | |
Shadowfacts | 68682ee291 |
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -1,5 +1,30 @@
|
||||||
# Changelog
|
# 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)
|
## 2023.1 (63)
|
||||||
Bugfixes:
|
Bugfixes:
|
||||||
- Fix status cells being inset too much on iPhones
|
- Fix status cells being inset too much on iPhones
|
||||||
|
|
|
@ -64,8 +64,8 @@ public final class Status: StatusProtocol, Decodable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func delete(_ status: Status) -> Request<Empty> {
|
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(status.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {
|
public static func reblog(_ statusID: String, visibility: Visibility? = nil) -> Request<Status> {
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
||||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.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 */; };
|
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
|
||||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
|
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
|
||||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
|
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
|
||||||
|
@ -150,6 +151,12 @@
|
||||||
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B57297203A700DABDFB /* ReportSelectRulesView.swift */; };
|
||||||
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; };
|
D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5929720AB000DABDFB /* ReportStatusView.swift */; };
|
||||||
D65B4B5E2973040D00DABDFB /* ReportAddStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B4B5D2973040D00DABDFB /* ReportAddStatusView.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 */; };
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
||||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.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 */; };
|
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
|
||||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
|
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.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 */; };
|
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; };
|
||||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||||
|
@ -956,6 +970,7 @@
|
||||||
D641C780213DD7C4004B4513 /* Screens */ = {
|
D641C780213DD7C4004B4513 /* Screens */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D65B4B89297879DE00DABDFB /* Account Follows */,
|
||||||
D6A3BC822321F69400FD64D5 /* Account List */,
|
D6A3BC822321F69400FD64D5 /* Account List */,
|
||||||
D6B053A023BD2BED00A066FA /* Asset Picker */,
|
D6B053A023BD2BED00A066FA /* Asset Picker */,
|
||||||
0411610522B457290030A9B7 /* Attachment Gallery */,
|
0411610522B457290030A9B7 /* Attachment Gallery */,
|
||||||
|
@ -1033,6 +1048,7 @@
|
||||||
D641C785213DD83B004B4513 /* Conversation */ = {
|
D641C785213DD83B004B4513 /* Conversation */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
|
||||||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
|
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
|
||||||
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
|
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
|
||||||
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
|
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
|
||||||
|
@ -1196,6 +1212,15 @@
|
||||||
path = Report;
|
path = Report;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D65B4B89297879DE00DABDFB /* Account Follows */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D65B4B8A297879E900DABDFB /* AccountFollowsViewController.swift */,
|
||||||
|
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */,
|
||||||
|
);
|
||||||
|
path = "Account Follows";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D663626021360A9600C9CBA2 /* Preferences */ = {
|
D663626021360A9600C9CBA2 /* Preferences */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1285,7 +1310,8 @@
|
||||||
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
|
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */,
|
D65B4B672977769E00DABDFB /* StatusActionAccountListViewController.swift */,
|
||||||
|
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */,
|
||||||
);
|
);
|
||||||
path = "Status Action Account List";
|
path = "Status Action Account List";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1375,6 +1401,7 @@
|
||||||
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
|
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
|
||||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
||||||
|
D65B4B69297777D900DABDFB /* StatusNotFoundView.swift */,
|
||||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
||||||
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */,
|
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */,
|
||||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
|
||||||
|
@ -1606,6 +1633,8 @@
|
||||||
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
||||||
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
|
||||||
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
|
||||||
|
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
|
||||||
|
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1899,6 +1928,7 @@
|
||||||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
||||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
||||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||||
|
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
|
||||||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||||
|
@ -1979,6 +2009,7 @@
|
||||||
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
|
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
|
||||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
||||||
|
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
|
||||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||||
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
||||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
||||||
|
@ -2007,7 +2038,9 @@
|
||||||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||||
|
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
|
||||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||||
|
D65B4B6429771EFF00DABDFB /* ConversationViewController.swift in Sources */,
|
||||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||||
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
|
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */,
|
||||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */,
|
||||||
|
@ -2027,6 +2060,7 @@
|
||||||
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
||||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
||||||
|
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
|
||||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
||||||
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
|
||||||
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
|
||||||
|
@ -2041,6 +2075,7 @@
|
||||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||||
|
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */,
|
||||||
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||||
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
|
||||||
|
@ -2056,7 +2091,7 @@
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
|
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
|
||||||
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
|
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
|
||||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||||
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
|
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
|
||||||
|
@ -2148,6 +2183,7 @@
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
|
||||||
|
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||||
|
@ -2314,7 +2350,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2322,7 +2358,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
OTHER_CODE_SIGN_FLAGS = "";
|
OTHER_CODE_SIGN_FLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
@ -2382,7 +2418,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2391,7 +2427,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
@ -2533,7 +2569,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2541,7 +2577,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
OTHER_CODE_SIGN_FLAGS = "";
|
OTHER_CODE_SIGN_FLAGS = "";
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||||
|
@ -2562,7 +2598,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2570,7 +2606,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
OTHER_CODE_SIGN_FLAGS = "";
|
OTHER_CODE_SIGN_FLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
@ -2672,7 +2708,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2681,7 +2717,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
@ -2698,7 +2734,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 63;
|
CURRENT_PROJECT_VERSION = 66;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||||
|
@ -2707,7 +2743,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2023.1;
|
MARKETING_VERSION = 2023.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -97,19 +97,24 @@ class MastodonController: ObservableObject {
|
||||||
return client.run(request, completion: completion)
|
return client.run(request, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
|
||||||
let result: (Result, Pagination?) = try await withCheckedThrowingContinuation({ continuation in
|
let response = await withCheckedContinuation({ continuation in
|
||||||
client.run(request) { response in
|
client.run(request) { response in
|
||||||
switch response {
|
continuation.resume(returning: response)
|
||||||
case .failure(let error):
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
case .success(let result, let pagination):
|
|
||||||
continuation.resume(returning: (result, pagination))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
|
let response = await runResponse(request)
|
||||||
try Task.checkCancellation()
|
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.
|
/// - Returns: A tuple of client ID and client secret.
|
||||||
|
|
|
@ -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) {
|
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||||
backgroundContext.perform {
|
backgroundContext.perform {
|
||||||
let statuses = notifications.compactMap { $0.status }
|
let statuses = notifications.compactMap { $0.status }
|
||||||
|
|
|
@ -141,7 +141,6 @@
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|
|
@ -99,9 +99,9 @@ private func createFavoriteAction(status: StatusMO, container: StatusSwipeAction
|
||||||
}
|
}
|
||||||
let title = status.favourited ? "Unfavorite" : "Favorite"
|
let title = status.favourited ? "Unfavorite" : "Favorite"
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
||||||
|
completion(true)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
|
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
|
||||||
completion(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
action.image = UIImage(systemName: "star.fill")
|
action.image = UIImage(systemName: "star.fill")
|
||||||
|
@ -116,9 +116,9 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
|
||||||
}
|
}
|
||||||
let title = status.reblogged ? "Unreblog" : "Reblog"
|
let title = status.reblogged ? "Unreblog" : "Reblog"
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
||||||
|
completion(true)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
|
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
|
||||||
completion(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
action.image = UIImage(systemName: "repeat")
|
action.image = UIImage(systemName: "repeat")
|
||||||
|
@ -145,6 +145,7 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
|
||||||
let bookmarked = status.bookmarked ?? false
|
let bookmarked = status.bookmarked ?? false
|
||||||
let title = bookmarked ? "Unbookmark" : "Bookmark"
|
let title = bookmarked ? "Unbookmark" : "Bookmark"
|
||||||
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
|
||||||
|
completion(true)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
|
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
|
||||||
do {
|
do {
|
||||||
|
@ -156,7 +157,6 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
|
||||||
toastable.showToast(configuration: config, animated: true)
|
toastable.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completion(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
action.image = UIImage(systemName: "bookmark.fill")
|
action.image = UIImage(systemName: "bookmark.fill")
|
||||||
|
|
|
@ -75,7 +75,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
case .showConversation:
|
case .showConversation:
|
||||||
guard let id = UserActivityManager.getConversationStatus(from: activity) else { return nil }
|
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:
|
case .checkNotifications:
|
||||||
guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil }
|
guard let mode = UserActivityManager.getNotificationsMode(from: activity) else { return nil }
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -48,6 +48,8 @@ class BookmarksTableViewController: EnhancedTableViewController {
|
||||||
tableView.prefetchDataSource = self
|
tableView.prefetchDataSource = self
|
||||||
|
|
||||||
userActivity = UserActivityManager.bookmarksActivity()
|
userActivity = UserActivityManager.bookmarksActivity()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -152,6 +154,21 @@ class BookmarksTableViewController: EnhancedTableViewController {
|
||||||
return config
|
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 {
|
extension BookmarksTableViewController: TuskerNavigationDelegate {
|
||||||
|
|
|
@ -22,11 +22,6 @@ class ConversationNode {
|
||||||
|
|
||||||
class ConversationTableViewController: EnhancedTableViewController {
|
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!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
let mainStatusID: String
|
let mainStatusID: String
|
||||||
|
@ -35,11 +30,8 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
var showStatusesAutomatically = false
|
var showStatusesAutomatically = false
|
||||||
var visibilityBarButtonItem: UIBarButtonItem!
|
|
||||||
|
|
||||||
private var loadingState = LoadingState.unloaded
|
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
|
||||||
|
|
||||||
init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) {
|
|
||||||
self.mainStatusID = mainStatusID
|
self.mainStatusID = mainStatusID
|
||||||
self.mainStatusState = state
|
self.mainStatusState = state
|
||||||
self.statusIDToScrollToOnLoad = mainStatusID
|
self.statusIDToScrollToOnLoad = mainStatusID
|
||||||
|
@ -57,8 +49,6 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
title = NSLocalizedString("Conversation", comment: "conversation screen title")
|
|
||||||
|
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.prefetchDataSource = 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
|
// 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
|
// so they're not inserted in between statuses in the ame sub-thread
|
||||||
tableView.separatorStyle = .none
|
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
|
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
switch item {
|
switch item {
|
||||||
|
@ -99,9 +89,9 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
||||||
if lastInSection {
|
if lastInSection {
|
||||||
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
|
if cell.viewWithTag(ViewTags.conversationBottomSeparator) == nil {
|
||||||
let separator = UIView()
|
let separator = UIView()
|
||||||
separator.tag = ConversationTableViewController.bottomSeparatorTag
|
separator.tag = ViewTags.conversationBottomSeparator
|
||||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
separator.backgroundColor = tableView.separatorColor
|
separator.backgroundColor = tableView.separatorColor
|
||||||
cell.addSubview(separator)
|
cell.addSubview(separator)
|
||||||
|
@ -113,7 +103,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
|
cell.viewWithTag(ViewTags.conversationBottomSeparator)?.removeFromSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
@ -124,100 +114,29 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
return cell
|
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() {
|
func addMainStatus(_ status: StatusMO) {
|
||||||
visibilityBarButtonItem.isSelected = showStatusesAutomatically
|
loadViewIfNeeded()
|
||||||
visibilityBarButtonItem.accessibilityLabel = showStatusesAutomatically ? "Collapse All" : "Expand All"
|
|
||||||
|
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
|
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
|
||||||
private func loadMainStatus() async {
|
let parentIDs = self.getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
|
||||||
guard loadingState == .unloaded else { return }
|
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
|
||||||
|
|
||||||
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
|
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
|
|
||||||
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems([mainStatusItem], toSection: .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)
|
snapshot.insertItems(parentIDs.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem)
|
||||||
|
|
||||||
// fetch all descendant status managed objects
|
// fetch all descendant status managed objects
|
||||||
|
@ -249,8 +168,6 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
|
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.loadingState = .loadedAll
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
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) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: 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)) {
|
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.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
||||||
conv.showStatusesAutomatically = showStatusesAutomatically
|
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||||
show(conv)
|
show(conv)
|
||||||
|
@ -378,9 +295,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func toggleVisibilityButtonPressed() {
|
func updateVisibleCellCollapseState() {
|
||||||
showStatusesAutomatically = !showStatusesAutomatically
|
|
||||||
|
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
||||||
state.collapsed = !showStatusesAutomatically
|
state.collapsed = !showStatusesAutomatically
|
||||||
|
@ -396,10 +311,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
||||||
// recalculate cell heights
|
// recalculate cell heights
|
||||||
tableView.beginUpdates()
|
tableView.beginUpdates()
|
||||||
tableView.endUpdates()
|
tableView.endUpdates()
|
||||||
|
|
||||||
updateVisibilityBarButtonItem()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationTableViewController {
|
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 {
|
extension ConversationTableViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController! { mastodonController }
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
|
||||||
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
let vc = ConversationViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
||||||
// transfer show statuses automatically state when showing new conversation
|
// transfer show statuses automatically state when showing new conversation
|
||||||
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
||||||
return vc
|
return vc
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
@ -137,6 +143,27 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) })
|
snapshot.appendItems(statuses.map { .status(id: $0.id, collapseState: .unknown, filterState: .unknown) })
|
||||||
await dataSource.apply(snapshot)
|
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 {
|
extension TrendingStatusesViewController {
|
||||||
|
|
|
@ -89,6 +89,8 @@ class MainSidebarViewController: UIViewController {
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.dragDelegate = self
|
collectionView.dragDelegate = self
|
||||||
collectionView.isSpringLoaded = true
|
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)
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
dataSource = createDataSource()
|
dataSource = createDataSource()
|
||||||
|
|
|
@ -56,8 +56,33 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
|
||||||
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
|
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
|
||||||
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
|
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
|
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]> {
|
private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> {
|
||||||
|
|
|
@ -143,6 +143,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
filterer.filtersChanged = { [unowned self] actionsChanged in
|
filterer.filtersChanged = { [unowned self] actionsChanged in
|
||||||
self.reapplyFilters(actionsChanged: actionsChanged)
|
self.reapplyFilters(actionsChanged: actionsChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
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 {
|
extension ProfileStatusesViewController {
|
||||||
|
@ -376,6 +403,7 @@ extension ProfileStatusesViewController {
|
||||||
typealias TimelineItem = String
|
typealias TimelineItem = String
|
||||||
|
|
||||||
case header(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 status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
|
|
|
@ -121,6 +121,8 @@ class SearchResultsViewController: EnhancedTableViewController {
|
||||||
.sink(receiveValue: performSearch(query:))
|
.sink(receiveValue: performSearch(query:))
|
||||||
|
|
||||||
userActivity = UserActivityManager.searchActivity()
|
userActivity = UserActivityManager.searchActivity()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? {
|
override func targetViewController(forAction action: Selector, sender: Any?) -> UIViewController? {
|
||||||
|
@ -207,6 +209,28 @@ class SearchResultsViewController: EnhancedTableViewController {
|
||||||
errorLabel.text = error.localizedDescription
|
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
|
// MARK: - Table view delegate
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,28 +2,58 @@
|
||||||
// StatusActionAccountListViewController.swift
|
// StatusActionAccountListViewController.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/5/19.
|
// Created by Shadowfacts on 1/17/23.
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
// Copyright © 2023 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class StatusActionAccountListViewController: UIViewController, CollectionViewController {
|
class StatusActionAccountListViewController: UIViewController {
|
||||||
|
|
||||||
private let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
private let actionType: ActionType
|
private let actionType: StatusActionAccountListViewController.ActionType
|
||||||
private let statusID: String
|
private let statusID: String
|
||||||
private let statusState: CollapseState
|
private let statusState: CollapseState
|
||||||
private var accountIDs: [String]?
|
private var accountIDs: [String]?
|
||||||
|
|
||||||
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
||||||
var showInacurateCountWarning = false
|
var showInacurateCountWarning = false {
|
||||||
|
didSet {
|
||||||
var collectionView: UICollectionView! {
|
if case .displaying(let vc) = state {
|
||||||
view as? UICollectionView
|
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.
|
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 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.
|
- 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.mastodonController = mastodonController
|
||||||
self.actionType = actionType
|
self.actionType = actionType
|
||||||
self.statusID = statusID
|
self.statusID = statusID
|
||||||
|
@ -41,6 +71,14 @@ class StatusActionAccountListViewController: UIViewController, CollectionViewCon
|
||||||
self.accountIDs = accountIDs
|
self.accountIDs = accountIDs
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
switch actionType {
|
switch actionType {
|
||||||
case .favorite:
|
case .favorite:
|
||||||
|
@ -48,120 +86,102 @@ class StatusActionAccountListViewController: UIViewController, CollectionViewCon
|
||||||
case .reblog:
|
case .reblog:
|
||||||
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
|
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
view.backgroundColor = .secondarySystemBackground
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func loadView() {
|
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
clearSelectionOnAppear(animated: animated)
|
if case .unloaded = state {
|
||||||
|
|
||||||
if accountIDs == nil {
|
|
||||||
Task {
|
Task {
|
||||||
await loadAccounts()
|
await loadStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAccounts() async {
|
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
|
||||||
let request: Request<[Account]>
|
guard let userInfo = notification.userInfo,
|
||||||
switch actionType {
|
let accountID = mastodonController.accountInfo?.id,
|
||||||
case .favorite:
|
userInfo["accountID"] as? String == accountID,
|
||||||
request = Status.getFavourites(statusID)
|
let statusIDs = userInfo["statusIDs"] as? [String] else {
|
||||||
case .reblog:
|
return
|
||||||
request = Status.getReblogs(statusID)
|
|
||||||
}
|
}
|
||||||
do {
|
if statusIDs.contains(statusID) {
|
||||||
// TODO: pagination
|
state = .notFound
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
extension StatusActionAccountListViewController {
|
||||||
|
@ -170,104 +190,12 @@ extension StatusActionAccountListViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusActionAccountListViewController {
|
extension StatusActionAccountListViewController: ToastableViewController {
|
||||||
enum Section {
|
var toastScrollView: UIScrollView? {
|
||||||
case status
|
if case .displaying(let vc) = state {
|
||||||
case accounts
|
return vc.toastScrollView
|
||||||
}
|
} else {
|
||||||
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 {
|
|
||||||
return nil
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,6 +146,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
_ = syncPositionIfNecessary(alwaysPrompt: true)
|
_ = syncPositionIfNecessary(alwaysPrompt: true)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -364,6 +365,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
]
|
]
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
SentrySDK.addBreadcrumb(crumb: crumb)
|
||||||
}()
|
}()
|
||||||
|
let originalPositionStatusIDs = position.statusIDs
|
||||||
|
|
||||||
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
|
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
|
||||||
guard !unloaded.isEmpty else {
|
guard !unloaded.isEmpty else {
|
||||||
return true
|
return true
|
||||||
|
@ -420,9 +423,18 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
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
|
// update the timeline position in case some statuses couldn't be loaded
|
||||||
if let center = position.centerStatusID {
|
if let center = position.centerStatusID,
|
||||||
let nearestLoadedStatusToCenter = position.statusIDs[position.statusIDs.firstIndex(of: center)!...].first(where: { id in
|
let centerIndex = position.statusIDs.firstIndex(of: center) {
|
||||||
|
let nearestLoadedStatusToCenter = position.statusIDs[centerIndex...].first(where: { id in
|
||||||
// was already loaded or was just now loaded
|
// was already loaded or was just now loaded
|
||||||
!unloaded.contains(id) || statuses.contains(where: { $0.id == id })
|
!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 {
|
extension TimelineViewController {
|
||||||
|
|
|
@ -172,7 +172,8 @@ extension MenuActionProvider {
|
||||||
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
||||||
guard let mastodonController = mastodonController else { return [] }
|
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
|
// user is logged out
|
||||||
return [
|
return [
|
||||||
openInSafariAction(url: status.url!),
|
openInSafariAction(url: status.url!),
|
||||||
|
@ -230,15 +231,7 @@ extension MenuActionProvider {
|
||||||
}), at: 1)
|
}), at: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionsSection: [UIAction] = [
|
var actionsSection: [UIMenuElement] = []
|
||||||
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)
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
if includeStatusButtonActions {
|
if includeStatusButtonActions {
|
||||||
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
||||||
|
@ -247,27 +240,44 @@ extension MenuActionProvider {
|
||||||
}), at: 0)
|
}), 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)
|
||||||
// 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 }) {
|
||||||
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
|
let muted = status.muted
|
||||||
let muted = status.muted
|
toggleableSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
|
||||||
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 }
|
||||||
guard let self = self else { return }
|
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
|
||||||
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
|
self.mastodonController?.run(request) { (response) in
|
||||||
self.mastodonController?.run(request) { (response) in
|
switch response {
|
||||||
switch response {
|
case .success(let status, _):
|
||||||
case .success(let status, _):
|
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
case .failure(let error):
|
||||||
case .failure(let error):
|
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
|
||||||
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
}
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// only allowing pinning user's own statuses
|
if status.poll != nil {
|
||||||
if account.id == status.account.id,
|
actionsSection.append(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
|
||||||
mastodonController.instanceFeatures.profilePinnedStatuses {
|
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
|
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
|
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 }
|
guard let self = self else { return }
|
||||||
|
@ -283,25 +293,28 @@ extension MenuActionProvider {
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if status.poll != nil {
|
actionsSection.append(UIMenu(title: "Delete Post", image: UIImage(systemName: "trash"), children: [
|
||||||
actionsSection.insert(createAction(identifier: "refresh", title: "Refresh Poll", systemImageName: "arrow.clockwise", handler: { [weak self] (_) in
|
UIAction(title: "Cancel", handler: { _ in }),
|
||||||
guard let mastodonController = self?.mastodonController else { return }
|
UIAction(title: "Delete Post", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [weak self] _ in
|
||||||
let request = Client.getStatus(id: status.id)
|
guard let self,
|
||||||
mastodonController.run(request, completion: { (response) in
|
let navigationDelegate = self.navigationDelegate else {
|
||||||
switch response {
|
return
|
||||||
case .success(let status, _):
|
}
|
||||||
// todo: this shouldn't really use the viewContext, but for some reason saving the
|
Task { @MainActor in
|
||||||
// backgroundContext with the new version of the status isn't updating the viewContext
|
let service = DeleteStatusService(status: status, mastodonController: mastodonController, presenter: navigationDelegate)
|
||||||
DispatchQueue.main.async {
|
await service.run()
|
||||||
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
self?.handleError(error, title: "Error Refreshing Poll")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}), 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] = []
|
var shareSection: [UIAction] = []
|
||||||
|
|
|
@ -156,7 +156,7 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIView
|
||||||
// MARK: TabbedPageViewController
|
// MARK: TabbedPageViewController
|
||||||
|
|
||||||
func selectNextPage() {
|
func selectNextPage() {
|
||||||
guard currentIndex < pageControllers.count - 1 else { return }
|
guard currentIndex < pages.count - 1 else { return }
|
||||||
selectPage(pages[currentIndex + 1], animated: true)
|
selectPage(pages[currentIndex + 1], animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,3 +198,9 @@ extension SegmentedPageViewController: StatusBarTappableViewController {
|
||||||
return .continue
|
return .continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SegmentedPageViewController: NestedResponderProvider {
|
||||||
|
var innerResponder: UIResponder? {
|
||||||
|
currentViewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -283,7 +283,11 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
|
||||||
// ordinarily, the next responder in the chain would be the SplitNavigationController's view
|
// 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
|
// 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
|
// 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) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -84,8 +84,11 @@ class TimelineLikeController<Item> {
|
||||||
guard state == .notLoadedInitial || state == .idle else {
|
guard state == .notLoadedInitial || state == .idle else {
|
||||||
return
|
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 doRestore()
|
||||||
|
await loadingIndicator.end()
|
||||||
state = .idle
|
state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,7 +201,7 @@ class TimelineLikeController<Item> {
|
||||||
enum State: Equatable, CustomDebugStringConvertible {
|
enum State: Equatable, CustomDebugStringConvertible {
|
||||||
case notLoadedInitial
|
case notLoadedInitial
|
||||||
case idle
|
case idle
|
||||||
case restoringInitial
|
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingNewer(LoadAttemptToken)
|
case loadingNewer(LoadAttemptToken)
|
||||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
|
@ -210,8 +213,8 @@ class TimelineLikeController<Item> {
|
||||||
return "notLoadedInitial"
|
return "notLoadedInitial"
|
||||||
case .idle:
|
case .idle:
|
||||||
return "idle"
|
return "idle"
|
||||||
case .restoringInitial:
|
case .restoringInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return "restoringInitial"
|
return "restoringInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingNewer(let token):
|
case .loadingNewer(let token):
|
||||||
|
@ -234,13 +237,13 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
case .idle:
|
case .idle:
|
||||||
switch to {
|
switch to {
|
||||||
case .restoringInitial, .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .restoringInitial:
|
case .restoringInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .idle
|
return to == .idle || (!hasAddedLoadingIndicator && to == .restoringInitial(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingNewer(_):
|
case .loadingNewer(_):
|
||||||
|
@ -256,14 +259,14 @@ class TimelineLikeController<Item> {
|
||||||
switch event {
|
switch event {
|
||||||
case .addLoadingIndicator:
|
case .addLoadingIndicator:
|
||||||
switch self {
|
switch self {
|
||||||
case .loadingInitial(_, _), .loadingOlder(_, _):
|
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingOlder(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .removeLoadingIndicator:
|
case .removeLoadingIndicator:
|
||||||
switch self {
|
switch self {
|
||||||
case .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
|
case .restoringInitial(_, hasAddedLoadingIndicator: true), .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -344,6 +347,7 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class DeferredLoadingIndicator {
|
class DeferredLoadingIndicator {
|
||||||
private let owner: TimelineLikeController<Item>
|
private let owner: TimelineLikeController<Item>
|
||||||
private let addedIndicatorState: State
|
private let addedIndicatorState: State
|
||||||
|
@ -352,19 +356,18 @@ class TimelineLikeController<Item> {
|
||||||
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
|
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.addedIndicatorState = addedIndicatorState
|
self.addedIndicatorState = addedIndicatorState
|
||||||
self.task = Task {
|
self.task = Task { @MainActor in
|
||||||
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
|
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
|
||||||
guard await state == owner.state else {
|
guard state == owner.state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await owner.emit(event: .addLoadingIndicator)
|
await owner.emit(event: .addLoadingIndicator)
|
||||||
await owner.transition(to: addedIndicatorState)
|
owner.transition(to: addedIndicatorState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func end() async {
|
func end() async {
|
||||||
let state = await owner.state
|
if owner.state == addedIndicatorState {
|
||||||
if state == addedIndicatorState {
|
|
||||||
await owner.emit(event: .removeLoadingIndicator)
|
await owner.emit(event: .removeLoadingIndicator)
|
||||||
} else {
|
} else {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
|
@ -13,7 +13,7 @@ import Pachyderm
|
||||||
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
|
||||||
var apiController: MastodonController! { get }
|
var apiController: MastodonController! { get }
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TuskerNavigationDelegate {
|
extension TuskerNavigationDelegate {
|
||||||
|
@ -78,8 +78,8 @@ extension TuskerNavigationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
|
func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController {
|
||||||
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
return ConversationViewController(for: mainStatusID, state: state, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selected(status statusID: String) {
|
func selected(status statusID: String) {
|
||||||
|
|
|
@ -16,4 +16,5 @@ struct ViewTags {
|
||||||
static let navEmptyTitleView = 42003
|
static let navEmptyTitleView = 42003
|
||||||
static let splitNavCloseSecondaryButton = 42004
|
static let splitNavCloseSecondaryButton = 42004
|
||||||
static let customAlertSeparator = 42005
|
static let customAlertSeparator = 42005
|
||||||
|
static let conversationBottomSeparator = 42006
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ class CachedImageView: UIImageView {
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
self.image = transformedImage
|
self.image = transformedImage
|
||||||
|
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ class ProfileHeaderView: UIView {
|
||||||
@IBOutlet weak var relationshipLabel: UILabel!
|
@IBOutlet weak var relationshipLabel: UILabel!
|
||||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||||
|
@IBOutlet weak var followCountButton: UIButton!
|
||||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||||
|
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
|
@ -148,6 +149,26 @@ class ProfileHeaderView: UIView {
|
||||||
fieldsView.delegate = delegate
|
fieldsView.delegate = delegate
|
||||||
fieldsView.updateUI(account: account)
|
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 = [
|
accessibilityElements = [
|
||||||
displayNameLabel!,
|
displayNameLabel!,
|
||||||
usernameLabel!,
|
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
|
// MARK: Interaction
|
||||||
|
|
||||||
@objc func avatarPressed() {
|
@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 {
|
extension ProfileHeaderView {
|
||||||
|
|
|
@ -46,26 +46,22 @@
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</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">
|
<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>
|
<constraints>
|
||||||
<constraint firstAttribute="width" constant="32" id="969-oZ-nVJ"/>
|
<constraint firstAttribute="width" secondItem="vFa-g3-xIP" secondAttribute="height" multiplier="1:1" id="B01-24-GJj"/>
|
||||||
<constraint firstAttribute="height" constant="32" id="Rm3-CK-8eb"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
<state key="normal" title="Button"/>
|
<state key="normal" title="Button"/>
|
||||||
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
|
<buttonConfiguration key="configuration" style="plain" image="ellipsis" catalog="system"/>
|
||||||
</button>
|
</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">
|
<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"/>
|
<rect key="frame" x="249" y="140" width="101" height="52"/>
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="32" id="8Ww-Yo-g7G"/>
|
|
||||||
</constraints>
|
|
||||||
<state key="normal" title="Button"/>
|
<state key="normal" title="Button"/>
|
||||||
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
|
<buttonConfiguration key="configuration" style="plain" image="person.badge.plus" catalog="system" title="Follow" imagePadding="4"/>
|
||||||
<connections>
|
<connections>
|
||||||
<action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/>
|
<action selector="followPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="OM3-lq-Z14"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</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"/>
|
<rect key="frame" x="16" y="266" width="398" height="596"/>
|
||||||
<subviews>
|
<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">
|
<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"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</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">
|
<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>
|
<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"/>
|
<color key="textColor" systemColor="labelColor"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||||
</textView>
|
</textView>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
<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"/>
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</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>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
|
<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="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="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="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="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="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"/>
|
<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="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
|
||||||
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
|
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
|
||||||
<outlet property="followButton" destination="cr8-p9-xkc" id="E1n-gh-mCl"/>
|
<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="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
|
||||||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
||||||
<outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/>
|
<outlet property="moreButton" destination="vFa-g3-xIP" id="dEX-1a-PHF"/>
|
||||||
|
|
|
@ -361,23 +361,31 @@ class BaseStatusTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!
|
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!
|
||||||
|
|
||||||
if animated, let buttonImageView = collapseButton.imageView {
|
if 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 {
|
|
||||||
collapseButton.setImage(buttonImage, for: .normal)
|
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 {
|
if collapsed {
|
||||||
|
|
|
@ -81,10 +81,14 @@
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/>
|
<constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<color key="tintColor" systemColor="systemBackgroundColor"/>
|
<color key="tintColor" systemColor="tintColor"/>
|
||||||
<state key="normal" image="chevron.down" catalog="system">
|
<state key="normal" image="chevron.down" catalog="system">
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
||||||
</state>
|
</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>
|
<connections>
|
||||||
<action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/>
|
<action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/>
|
||||||
</connections>
|
</connections>
|
||||||
|
@ -98,9 +102,9 @@
|
||||||
</textView>
|
</textView>
|
||||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
|
<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"/>
|
<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>
|
<constraints>
|
||||||
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/>
|
<constraint firstAttribute="height" priority="999" constant="90" id="Tdo-Hv-ITE"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
|
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
|
||||||
|
|
|
@ -25,8 +25,10 @@ class StatusCardView: UIView {
|
||||||
private var imageRequest: ImageCache.Request?
|
private var imageRequest: ImageCache.Request?
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
|
|
||||||
|
private var hStack: UIStackView!
|
||||||
private var titleLabel: UILabel!
|
private var titleLabel: UILabel!
|
||||||
private var descriptionLabel: UILabel!
|
private var descriptionLabel: UILabel!
|
||||||
|
private var domainLabel: UILabel!
|
||||||
private var imageView: UIImageView!
|
private var imageView: UIImageView!
|
||||||
private var placeholderImageView: UIImageView!
|
private var placeholderImageView: UIImageView!
|
||||||
|
|
||||||
|
@ -41,11 +43,10 @@ class StatusCardView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func commonInit() {
|
private func commonInit() {
|
||||||
self.clipsToBounds = true
|
self.layer.shadowColor = UIColor.black.cgColor
|
||||||
self.layer.cornerRadius = 6.5
|
self.layer.shadowRadius = 5
|
||||||
self.layer.borderWidth = 1
|
self.layer.shadowOpacity = 0.2
|
||||||
self.layer.borderColor = UIColor.lightGray.cgColor
|
self.layer.shadowOffset = .zero
|
||||||
self.backgroundColor = inactiveBackgroundColor
|
|
||||||
|
|
||||||
self.addInteraction(UIContextMenuInteraction(delegate: self))
|
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.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0)
|
||||||
titleLabel.adjustsFontForContentSizeCategory = true
|
titleLabel.adjustsFontForContentSizeCategory = true
|
||||||
titleLabel.numberOfLines = 2
|
titleLabel.numberOfLines = 2
|
||||||
|
titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
|
||||||
descriptionLabel = UILabel()
|
descriptionLabel = UILabel()
|
||||||
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
|
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
|
||||||
descriptionLabel.adjustsFontForContentSizeCategory = true
|
descriptionLabel.adjustsFontForContentSizeCategory = true
|
||||||
descriptionLabel.numberOfLines = 2
|
descriptionLabel.numberOfLines = 3
|
||||||
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
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: [
|
let vStack = UIStackView(arrangedSubviews: [
|
||||||
titleLabel,
|
titleLabel,
|
||||||
descriptionLabel
|
descriptionLabel,
|
||||||
|
domainLabel,
|
||||||
])
|
])
|
||||||
vStack.axis = .vertical
|
vStack.axis = .vertical
|
||||||
vStack.alignment = .leading
|
vStack.alignment = .leading
|
||||||
|
@ -73,15 +82,23 @@ class StatusCardView: UIView {
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.clipsToBounds = true
|
imageView.clipsToBounds = true
|
||||||
|
|
||||||
let hStack = UIStackView(arrangedSubviews: [
|
let spacer = UIView()
|
||||||
|
spacer.backgroundColor = .clear
|
||||||
|
|
||||||
|
hStack = UIStackView(arrangedSubviews: [
|
||||||
imageView,
|
imageView,
|
||||||
vStack
|
vStack,
|
||||||
|
spacer,
|
||||||
])
|
])
|
||||||
hStack.translatesAutoresizingMaskIntoConstraints = false
|
hStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
hStack.axis = .horizontal
|
hStack.axis = .horizontal
|
||||||
hStack.alignment = .center
|
hStack.alignment = .center
|
||||||
hStack.distribution = .fill
|
hStack.distribution = .fill
|
||||||
hStack.spacing = 4
|
hStack.spacing = 4
|
||||||
|
hStack.clipsToBounds = true
|
||||||
|
hStack.layer.borderWidth = 0.5
|
||||||
|
hStack.layer.borderColor = UIColor.lightGray.cgColor
|
||||||
|
hStack.backgroundColor = inactiveBackgroundColor
|
||||||
|
|
||||||
addSubview(hStack)
|
addSubview(hStack)
|
||||||
|
|
||||||
|
@ -98,8 +115,10 @@ class StatusCardView: UIView {
|
||||||
|
|
||||||
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
|
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
|
||||||
|
|
||||||
|
spacer.widthAnchor.constraint(equalToConstant: 4),
|
||||||
|
|
||||||
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
hStack.topAnchor.constraint(equalTo: topAnchor),
|
hStack.topAnchor.constraint(equalTo: topAnchor),
|
||||||
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
|
@ -112,6 +131,11 @@ class StatusCardView: UIView {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
|
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) {
|
func updateUI(status: StatusMO) {
|
||||||
guard status.id != statusID else {
|
guard status.id != statusID else {
|
||||||
return
|
return
|
||||||
|
@ -135,6 +159,13 @@ class StatusCardView: UIView {
|
||||||
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
descriptionLabel.text = description
|
descriptionLabel.text = description
|
||||||
descriptionLabel.isHidden = description.isEmpty
|
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() {
|
@objc private func updateUIForPreferences() {
|
||||||
|
@ -201,7 +232,7 @@ class StatusCardView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
backgroundColor = activeBackgroundColor
|
hStack.backgroundColor = activeBackgroundColor
|
||||||
setNeedsDisplay()
|
setNeedsDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -209,7 +240,7 @@ class StatusCardView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
backgroundColor = inactiveBackgroundColor
|
hStack.backgroundColor = inactiveBackgroundColor
|
||||||
setNeedsDisplay()
|
setNeedsDisplay()
|
||||||
|
|
||||||
if let card = card, let delegate = navigationDelegate {
|
if let card = card, let delegate = navigationDelegate {
|
||||||
|
@ -218,7 +249,7 @@ class StatusCardView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
backgroundColor = inactiveBackgroundColor
|
hStack.backgroundColor = inactiveBackgroundColor
|
||||||
setNeedsDisplay()
|
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) {
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
if let viewController = animator.previewViewController,
|
if let viewController = animator.previewViewController,
|
||||||
let delegate = navigationDelegate {
|
let delegate = navigationDelegate {
|
||||||
|
|
|
@ -20,7 +20,7 @@ class StatusContentContainer: UIView {
|
||||||
|
|
||||||
let cardView = StatusCardView().configure {
|
let cardView = StatusCardView().configure {
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
$0.heightAnchor.constraint(equalToConstant: 65),
|
$0.heightAnchor.constraint(equalToConstant: 90),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -759,7 +759,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return UIContextMenuConfiguration {
|
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
|
} actionProvider: { _ in
|
||||||
UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
|
UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
|
||||||
}
|
}
|
||||||
|
|
|
@ -433,7 +433,7 @@ extension TimelineStatusTableViewCell: MenuPreviewProvider {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (
|
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)) ?? [] }
|
actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,10 +109,16 @@
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/>
|
<constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/>
|
||||||
</constraints>
|
</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">
|
<state key="normal" image="chevron.down" catalog="system">
|
||||||
|
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
||||||
</state>
|
</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>
|
<connections>
|
||||||
<action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/>
|
<action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/>
|
||||||
</connections>
|
</connections>
|
||||||
|
@ -126,9 +132,9 @@
|
||||||
</textView>
|
</textView>
|
||||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
|
<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"/>
|
<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>
|
<constraints>
|
||||||
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
|
<constraint firstAttribute="height" priority="999" constant="90" id="khY-jm-CPn"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
|
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue