Compare commits

...

54 Commits

Author SHA1 Message Date
Shadowfacts b38c24b347 Bump build number and update changelog 2022-11-02 23:48:53 -04:00
Shadowfacts a6d51cee3c More fiddling with the sentry script 2022-11-02 23:47:14 -04:00
Shadowfacts 7bdbd9f71a Handle task cancellation in MastodonController.run 2022-11-02 23:00:29 -04:00
Shadowfacts b47876dc3d Fix retain cycle due to account follow action workaround 2022-11-02 22:59:44 -04:00
Shadowfacts 4644475bc7 Fix crashes when ProfileStatusesVC doesn't finish loading until ProfileVC is deinit'd 2022-11-02 22:53:07 -04:00
Shadowfacts 16ba292afa Remove debug print 2022-11-02 22:34:40 -04:00
Shadowfacts c7f3bac330 Add sterner warning about post content type 2022-11-02 22:06:08 -04:00
Shadowfacts abb8352c92 Fix ImageCache.get completion not being called when image isn't loaded 2022-11-02 22:06:08 -04:00
Shadowfacts 59d866aa23 Ditch custom image request grouping, rely on URLSession's 2022-11-02 22:06:08 -04:00
Shadowfacts ba032412eb Fix timeline reloading every time VC appears
Caused by changes to TimelineLikeController required to let list
timelines reload from scratch
2022-11-02 22:06:07 -04:00
Shadowfacts 5de0c034f4 Remove old TimelineTableViewController 2022-11-01 21:11:13 -04:00
Shadowfacts b1d83f2746 Switch hashtag/instance/list timelines to use new collection view impl 2022-11-01 21:10:41 -04:00
Shadowfacts 658c08010d Re-add undo scroll-to-top to timelines/profiles 2022-11-01 20:49:07 -04:00
Shadowfacts 6a5753fac8 Fix crash when tapping Load More button with Disable Infinite Scrolling 2022-10-31 17:45:36 -04:00
Shadowfacts 8da89986df Fix find instance VC requiring double dismiss 2022-10-31 17:39:57 -04:00
Shadowfacts c7e39cb041 Use short descriptions in instance selector when available 2022-10-31 17:35:50 -04:00
Shadowfacts b755607895 Fix crash when TimelineStatusTableViewCell outlives its containing VC 2022-10-31 17:33:33 -04:00
Shadowfacts 508eef8c07 Nothing to see here 2022-10-31 17:33:33 -04:00
Shadowfacts a18dfc38af Fix crash when refreshing profile before it has loaded 2022-10-31 17:33:33 -04:00
Shadowfacts 95f9fad673 Tweak Sentry config 2022-10-31 17:33:33 -04:00
Shadowfacts 4857b507b1 Send CoreData saving errors to Sentry 2022-10-31 12:26:09 -04:00
Shadowfacts bca7bd3586 Tweak sentry upload script and fix using dist build config in debug 2022-10-31 12:25:54 -04:00
Shadowfacts 9978e392a2 Bump build number and update changelog 2022-10-31 12:25:37 -04:00
Shadowfacts cc33cf18f2 Workaround for follow menu item never resolving on macOS
See #198
2022-10-30 18:54:14 -04:00
Shadowfacts c5921bc4cb Add option to disable automatic crash reporting 2022-10-30 18:17:53 -04:00
Shadowfacts 91450ced7c Use Sentry for crash reporting 2022-10-30 17:10:58 -04:00
Shadowfacts 5afd9e83eb Shhh 2022-10-30 14:47:36 -04:00
Shadowfacts d05275020f Tweak timeline status cell spacing 2022-10-29 21:18:01 -04:00
Shadowfacts c420c236d9 Whoops 2022-10-29 21:06:27 -04:00
Shadowfacts d5433e9b91 Fix crash when opening profile view controller with uncached account
E.g., by tapping a mention in a status
2022-10-29 18:55:13 -04:00
Shadowfacts cbbe9ec11f Fix crash in profile due to accessing data source before it exists
This could happen if an account is updated in the background while a
profile is on screen and the user has not visited all of the tabs.
2022-10-29 18:40:41 -04:00
Shadowfacts 0e06d47687 Fix status collapse changes not animating on profiles 2022-10-29 18:27:24 -04:00
Shadowfacts c907b7257a Bump build number and update changelog 2022-10-29 18:27:12 -04:00
Shadowfacts 10239d14c9 Fix selected segment not updating on profiles when switching tabs with keyboard shortcuts 2022-10-29 15:08:03 -04:00
Shadowfacts 2344275ff9 Enable blurhash in debug
Capping the size at 32x32 means this is fast enough even in un-optimized builds
2022-10-29 14:19:43 -04:00
Shadowfacts e0ffa1d9c5 Cap blurhash image size at 32x32 2022-10-29 14:19:43 -04:00
Shadowfacts 77a6654ff2 Fix crash when generating blurhash image for AttachmentView that hasn't been laid out
It was passing a negative size into the blurhash decoder, which is invalid

Instead, cap the size at 32x32 (letting the image view scale it up when rendering)
2022-10-29 14:19:43 -04:00
Shadowfacts 43aee0ec67 Add pointer interaction to avatar in timeline status cell 2022-10-29 14:19:43 -04:00
Shadowfacts d95ba82e5b Improve pointer interaction on new status cell action buttons
Closes #195
2022-10-29 14:19:43 -04:00
Shadowfacts b6d8232951 Fix replies appearing multiple times in drafts 2022-10-29 14:19:43 -04:00
Shadowfacts bb9cef55ea Don't remove persistent data when clearing cache 2022-10-29 14:19:43 -04:00
Shadowfacts 67718d8fe4 Fix wrong logs getting sent with crash reports 2022-10-29 14:19:43 -04:00
Shadowfacts 71a2029752 Switch everything to new profile view controller 2022-10-28 21:38:56 -04:00
Shadowfacts 6bb1f3b7dc Finish converting profiles to collection views 2022-10-28 21:31:18 -04:00
Shadowfacts 2469d285bc Initial implementation of profile switching with collection views 2022-10-28 19:17:33 -04:00
Shadowfacts 5f410213e2 Start converting profile statuses to collection view 2022-10-28 19:17:33 -04:00
Shadowfacts bb3e1b44b1 Hide live text controls when other gallery controls are hidden
Closes #189
2022-10-28 19:16:00 -04:00
Shadowfacts 868df25417 Disable pruning offscreen rows in new timelines
I don't think this is actually necessary, the system should kill us
often enough that the amount of items in the data source doesn't become
unmanageable.

Making modifications to the data source in viewDidDisappear was causing
the collection view's contentOffset to change to be scrolled to top
(roughly) when the view became visible again.

Disabling it also fixes several issues caused by updating the data
source even when there were no changes.

Closes #193
Closes #192
Closes #187
Closes #186
2022-10-28 19:05:07 -04:00
Shadowfacts 2801f65e67 Fix reblog labels in new cells not being tappable
Closes #197
2022-10-28 18:48:30 -04:00
Shadowfacts cccde29e6c Fix crash when long-pressing Send Report button on iPad
Closes #190
2022-10-27 23:11:21 -04:00
Shadowfacts aa0629d202 Don't dismiss issue reporter when email is cancelled
Closes #191
2022-10-27 23:10:00 -04:00
Shadowfacts ba209fa4d2 Protect DiskCache.fileStates with a lock
Closes #194
2022-10-27 23:06:50 -04:00
Shadowfacts d224f47b8c Fix long content warnings getting truncated in new status cells
Closes #185
2022-10-11 17:04:31 -04:00
Shadowfacts ffb0ceba20 Remove old XCB code 2022-10-11 10:10:55 -04:00
80 changed files with 1706 additions and 1354 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
Dist.xcconfig
.DS_Store .DS_Store
MyPlayground.playground/ MyPlayground.playground/

View File

@ -1,5 +1,45 @@
# Changelog # Changelog
## 2022.1 (43)
Features/Improvements:
- Re-add undo scroll-to-top by tapping the status bar a second time
- Convert hashtag/list/instance timelines to use new timeline implementation
- Clarify warning on Post Content Type preference
Bugfixes:
- Fix crash when refreshing profile before it loaded
- Fix crash when tapping Load More when infinite scrolling is disabled
- Fix crash when profile screen is closed before loading finishes
- Fix having to tap Cancel twice to dismiss Find Instance screen
## 2022.1 (42)
Features/Improvements:
- Add automatic crash reporting
- Tweak spacing on timeline statuses
Bugfixes:
- Fix status collapse/expand not animating on profiles
- Fix crash when opening profile for unloaded account (e.g., by tapping mentions)
- macOS: Add workaround for Follow/Unfollow menu item never loading
## 2022.1 (41)
Features/Improvements:
- Rewrite profile screens to use new timeline implementation
- Disable Infinite Scrolling preference (Preferences -> Digital Wellness) now applies to profiles
- Improve behavior when switching tabs on profiles
- Improve pointer interaction on timeline status cells
Bugfixes:
- Fix crash when loading images in certain circumstances
- Fix gallery dismissal leaving status bar hidden and breaking future gallery dismisses
- Fix timeline scroll position changing after dismissing gallery
- Fix images flickering when switching back to the Home tab
- Fix crash reporter being dismissed when sending email is cancelled
- Fix crash when long pressing Send Report button in crash reporter on iPad
- Fix Live Text controls not hiding when other gallery controls are hidden
- Fix replies appearing multiple times in Drafts list
- Fix crash when displaying blur hash images on Pleroma
## 2022.1 (40) ## 2022.1 (40)
Bugfixes: Bugfixes:
- Fix selecting reblogged statuses in the timeline - Fix selecting reblogged statuses in the timeline

View File

@ -12,6 +12,7 @@ public class Instance: Decodable {
public let uri: String public let uri: String
public let title: String public let title: String
public let description: String public let description: String
public let shortDescription: String?
public let email: String? public let email: String?
public let version: String public let version: String
public let urls: [String: URL] public let urls: [String: URL]
@ -40,6 +41,7 @@ public class Instance: Decodable {
self.uri = try container.decode(String.self, forKey: .uri) self.uri = try container.decode(String.self, forKey: .uri)
self.title = try container.decode(String.self, forKey: .title) self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description) self.description = try container.decode(String.self, forKey: .description)
self.shortDescription = try container.decodeIfPresent(String.self, forKey: .shortDescription)
self.email = try container.decodeIfPresent(String.self, forKey: .email) self.email = try container.decodeIfPresent(String.self, forKey: .email)
self.version = try container.decode(String.self, forKey: .version) self.version = try container.decode(String.self, forKey: .version)
if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) { if let urls = try? container.decodeIfPresent([String: URL].self, forKey: .urls) {
@ -72,6 +74,7 @@ public class Instance: Decodable {
case uri case uri
case title case title
case description case description
case shortDescription = "short_description"
case email case email
case version case version
case urls case urls

View File

@ -10,7 +10,6 @@
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041160FE22B442870030A9B7 /* LoadingLargeImageViewController.swift */; }; 0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041160FE22B442870030A9B7 /* LoadingLargeImageViewController.swift */; };
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */; }; 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */; };
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */; }; 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */; };
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */; };
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; }; 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; };
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.swift */; }; 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.swift */; };
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4222B301470021BD04 /* AppearancePrefsView.swift */; }; 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4222B301470021BD04 /* AppearancePrefsView.swift */; };
@ -26,7 +25,6 @@
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; }; D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; };
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; }; D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; };
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; }; D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; };
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */; };
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; }; D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; }; D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; }; D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; };
@ -37,12 +35,15 @@
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; }; D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; };
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */; };
D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; };
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; }; D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
@ -93,13 +94,15 @@
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; }; D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; }; D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */; }; D63CC702290EC0B8000E19DE /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = D63CC701290EC0B8000E19DE /* Sentry */; };
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CC70B2910AADB000E19DE /* TuskerSceneDelegate.swift */; };
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */; };
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */; };
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; }; D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; };
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; }; D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; }; D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; }; D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; }; D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; };
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; }; D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; }; D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; }; D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
@ -122,7 +125,6 @@
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; }; D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; }; D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; }; D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
@ -195,7 +197,6 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; }; D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; }; D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; };
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; }; D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = D69CCBBE249E6EFD000AF167 /* CrashReporter */; };
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; }; D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; };
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; }; D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
@ -365,7 +366,6 @@
041160FE22B442870030A9B7 /* LoadingLargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingLargeImageViewController.swift; sourceTree = "<group>"; }; 041160FE22B442870030A9B7 /* LoadingLargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingLargeImageViewController.swift; sourceTree = "<group>"; };
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorPrefsView.swift; sourceTree = "<group>"; }; 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorPrefsView.swift; sourceTree = "<group>"; };
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPrefsView.swift; sourceTree = "<group>"; }; 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPrefsView.swift; sourceTree = "<group>"; };
0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SilentActionPrefs.swift; sourceTree = "<group>"; };
0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = "<group>"; }; 0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = "<group>"; };
04586B4022B2FFB10021BD04 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; }; 04586B4022B2FFB10021BD04 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
04586B4222B301470021BD04 /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; }; 04586B4222B301470021BD04 /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
@ -380,7 +380,6 @@
D60E2F252442372B005F8713 /* AccountMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMO.swift; sourceTree = "<group>"; }; D60E2F252442372B005F8713 /* AccountMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMO.swift; sourceTree = "<group>"; };
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazilyDecoding.swift; sourceTree = "<group>"; }; D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazilyDecoding.swift; sourceTree = "<group>"; };
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; }; D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; }; D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; }; D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; }; D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; };
@ -391,11 +390,14 @@
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; }; D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; };
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; }; D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; }; D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -446,13 +448,15 @@
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; }; D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; }; D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; }; D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; }; D63CC703290EC472000E19DE /* Dist.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Dist.xcconfig; sourceTree = "<group>"; };
D63CC70B2910AADB000E19DE /* TuskerSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerSceneDelegate.swift; sourceTree = "<group>"; };
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Top.swift"; sourceTree = "<group>"; };
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarTappableViewController.swift; sourceTree = "<group>"; };
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = "<group>"; }; D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = "<group>"; };
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; }; D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; }; D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; }; D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; }; D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; }; D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; }; D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; }; D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
@ -475,7 +479,6 @@
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; }; D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; }; D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; }; D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; }; D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
@ -682,10 +685,10 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -877,6 +880,17 @@
path = CoreData; path = CoreData;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D63CC70A2910AAC6000E19DE /* Scenes */ = {
isa = PBXGroup;
children = (
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D63CC70B2910AADB000E19DE /* TuskerSceneDelegate.swift */,
);
path = Scenes;
sourceTree = "<group>";
};
D641C780213DD7C4004B4513 /* Screens */ = { D641C780213DD7C4004B4513 /* Screens */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -911,7 +925,6 @@
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */, D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */, D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */, D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
); );
@ -943,9 +956,10 @@
D641C784213DD819004B4513 /* Profile */ = { D641C784213DD819004B4513 /* Profile */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */,
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */, D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
); );
path = Profile; path = Profile;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1019,7 +1033,6 @@
D68015412401A74600D6103B /* MediaPrefsView.swift */, D68015412401A74600D6103B /* MediaPrefsView.swift */,
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */, D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */,
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
); );
path = Preferences; path = Preferences;
@ -1141,6 +1154,7 @@
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */, D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
D62E9984279CA23900C26176 /* URLSession+Development.swift */, D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */, D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1318,6 +1332,7 @@
D6E0DC8D216EDF1E00369478 /* Previewing.swift */, D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */, D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */, D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */, D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
@ -1343,6 +1358,7 @@
D6D4DDC3212518A000E1C4BB = { D6D4DDC3212518A000E1C4BB = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */, D674A50727F910F300BA03AC /* Pachyderm */,
D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDE3212518A200E1C4BB /* TuskerTests */,
@ -1372,14 +1388,11 @@
D6D4DDDB212518A200E1C4BB /* Info.plist */, D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */, D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */, D61DC84528F498F200B82C6E /* Logging.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
@ -1397,6 +1410,7 @@
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */, D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */, D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */, D663626021360A9600C9CBA2 /* Preferences */,
D63CC70A2910AAC6000E19DE /* Scenes */,
D641C780213DD7C4004B4513 /* Screens */, D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */, D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */, D67B506B250B28FF00FAECFB /* Vendor */,
@ -1469,7 +1483,6 @@
D6F2E960249E772F005846BB /* Crash Reporter */ = { D6F2E960249E772F005846BB /* Crash Reporter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */,
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */, D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */,
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */, D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */,
); );
@ -1501,7 +1514,7 @@
D6F953E52125197500CF0F2B /* Embed Frameworks */, D6F953E52125197500CF0F2B /* Embed Frameworks */,
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */, D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */,
D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */, D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */,
D6F1F9E127B0677000CB7D88 /* ShellScript */, D63CC704290EC913000E19DE /* ShellScript */,
); );
buildRules = ( buildRules = (
); );
@ -1510,11 +1523,11 @@
); );
name = Tusker; name = Tusker;
packageProductDependencies = ( packageProductDependencies = (
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
D60CFFDA24A290BA00D00083 /* SwiftSoup */, D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */, D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */, D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */,
); );
productName = Tusker; productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1621,10 +1634,10 @@
); );
mainGroup = D6D4DDC3212518A000E1C4BB; mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = ( packageReferences = (
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */, D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */, D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
); );
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */; productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -1701,6 +1714,26 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
D63CC704290EC913000E19DE /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/bash;
shellScript = "echo \"$CONFIGURATION\"\nif [ \"$CONFIGURATION\" == \"Dist\" ]; then\nif which sentry-cli >/dev/null; then\nexport SENTRY_ORG=vaccor\nexport SENTRY_PROJECT=tusker\necho \"DWARF_DSYM_FOLDER_PATH: $DWARF_DSYM_FOLDER_PATH\"\nsentry-cli upload-dif \"$DWARF_DSYM_FOLDER_PATH\" --force-foreground 2>&1\nif [ $? -eq 0 ]; then\necho \"sentry-cli uploaded debug info\"\nelse\necho \"error: sentry-cli failed\"\nexit 1\nfi\nelse\necho \"error: sentry-cli not installed, download from https://github.com/getsentry/sentry-cli/releases\"\nexit 1\nfi\nfi\n";
showEnvVarsInLog = 0;
};
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */ = { D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -1723,24 +1756,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "#if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n# echo \"Embedding ${SCRIPT_INPUT_FILE_0}\"\n# cp -R $SCRIPT_INPUT_FILE_0 $SCRIPT_OUTPUT_FILE_0\n# codesign --force --verbose --sign $EXPANDED_CODE_SIGN_IDENTITY $SCRIPT_OUTPUT_FILE_0\n# \n# echo \"Embedding ${SCRIPT_INPUT_FILE_1}\"\n# cp -R $SCRIPT_INPUT_FILE_1 $SCRIPT_OUTPUT_FILE_1\n# codesign --force --verbose --sign $EXPANDED_CODE_SIGN_IDENTITY $SCRIPT_OUTPUT_FILE_1\n#else\n# echo \"Skipping embedding debug frameworks\"\n#fi\n"; shellScript = "#if [ \"${CONFIGURATION}\" == \"Debug\" ]; then\n# echo \"Embedding ${SCRIPT_INPUT_FILE_0}\"\n# cp -R $SCRIPT_INPUT_FILE_0 $SCRIPT_OUTPUT_FILE_0\n# codesign --force --verbose --sign $EXPANDED_CODE_SIGN_IDENTITY $SCRIPT_OUTPUT_FILE_0\n# \n# echo \"Embedding ${SCRIPT_INPUT_FILE_1}\"\n# cp -R $SCRIPT_INPUT_FILE_1 $SCRIPT_OUTPUT_FILE_1\n# codesign --force --verbose --sign $EXPANDED_CODE_SIGN_IDENTITY $SCRIPT_OUTPUT_FILE_1\n#else\n# echo \"Skipping embedding debug frameworks\"\n#fi\n";
}; };
D6F1F9E127B0677000CB7D88 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\n# the nested framework doens't get signed automatically for some reason\n# so we sign it ourselves\n#codesign --force --verbose --sign $EXPANDED_CODE_SIGN_IDENTITY \"$BUILT_PRODUCTS_DIR/Tusker.app/Frameworks/Pachyderm.framework/Frameworks/WebURL.framework\"\n\n# xcode wants to include the weburl framework twice for some reason, but the app store doesn't like nested frameworks\n# we already have a copy in the app's Frameworks/ dir, so we can delete the nested one\nif [ \"$(ls \"$BUILT_PRODUCTS_DIR/Tusker.app/Frameworks/Pachyderm.framework/Frameworks/\")\" -ne \"WebURL.framework\" ]; then\n exit 1\nfi\nrm -rf \"$BUILT_PRODUCTS_DIR/Tusker.app/Frameworks/Pachyderm.framework/Frameworks/\"\n";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -1781,6 +1796,7 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */, D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
@ -1811,7 +1827,7 @@
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */, D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */, D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
@ -1832,6 +1848,7 @@
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
@ -1858,6 +1875,7 @@
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
@ -1873,6 +1891,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
@ -1953,6 +1972,7 @@
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */, D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */, D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
@ -1972,13 +1992,10 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
@ -1991,7 +2008,6 @@
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */,
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
@ -2087,6 +2103,161 @@
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
D63CC705290ECE77000E19DE /* Dist */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D63CC703290EC472000E19DE /* Dist.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES;
};
name = Dist;
};
D63CC706290ECE77000E19DE /* Dist */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2022.1;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Dist;
};
D63CC707290ECE77000E19DE /* Dist */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tusker.app/Tusker";
};
name = Dist;
};
D63CC708290ECE77000E19DE /* Dist */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = TuskerUITests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.TuskerUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Tusker;
};
name = Dist;
};
D63CC709290ECE77000E19DE /* Dist */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2022.1;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.OpenInTusker;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Dist;
};
D6D4DDF2212518A200E1C4BB /* Debug */ = { D6D4DDF2212518A200E1C4BB /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -2217,7 +2388,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2246,7 +2417,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 = 40; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2356,7 +2527,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 = 40; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2383,7 +2554,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 = 40; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2411,6 +2582,7 @@
buildConfigurations = ( buildConfigurations = (
D6D4DDF2212518A200E1C4BB /* Debug */, D6D4DDF2212518A200E1C4BB /* Debug */,
D6D4DDF3212518A200E1C4BB /* Release */, D6D4DDF3212518A200E1C4BB /* Release */,
D63CC705290ECE77000E19DE /* Dist */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -2420,6 +2592,7 @@
buildConfigurations = ( buildConfigurations = (
D6D4DDF5212518A200E1C4BB /* Debug */, D6D4DDF5212518A200E1C4BB /* Debug */,
D6D4DDF6212518A200E1C4BB /* Release */, D6D4DDF6212518A200E1C4BB /* Release */,
D63CC706290ECE77000E19DE /* Dist */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -2429,6 +2602,7 @@
buildConfigurations = ( buildConfigurations = (
D6D4DDF8212518A200E1C4BB /* Debug */, D6D4DDF8212518A200E1C4BB /* Debug */,
D6D4DDF9212518A200E1C4BB /* Release */, D6D4DDF9212518A200E1C4BB /* Release */,
D63CC707290ECE77000E19DE /* Dist */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -2438,6 +2612,7 @@
buildConfigurations = ( buildConfigurations = (
D6D4DDFB212518A200E1C4BB /* Debug */, D6D4DDFB212518A200E1C4BB /* Debug */,
D6D4DDFC212518A200E1C4BB /* Release */, D6D4DDFC212518A200E1C4BB /* Release */,
D63CC708290ECE77000E19DE /* Dist */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -2447,6 +2622,7 @@
buildConfigurations = ( buildConfigurations = (
D6E343B7265AAD6B00C4AA01 /* Debug */, D6E343B7265AAD6B00C4AA01 /* Debug */,
D6E343B8265AAD6B00C4AA01 /* Release */, D6E343B8265AAD6B00C4AA01 /* Release */,
D63CC709290ECE77000E19DE /* Dist */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -2462,6 +2638,14 @@
minimumVersion = 2.3.2; minimumVersion = 2.3.2;
}; };
}; };
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 7.29.0;
};
};
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = { D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kylebshr/ScreenCorners"; repositoryURL = "https://github.com/kylebshr/ScreenCorners";
@ -2478,14 +2662,6 @@
kind = branch; kind = branch;
}; };
}; };
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/microsoft/plcrashreporter";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 1.8.0;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -2498,6 +2674,11 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;
}; };
D63CC701290EC0B8000E19DE /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
D6552366289870790048A653 /* ScreenCorners */ = { D6552366289870790048A653 /* ScreenCorners */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */; package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
@ -2512,11 +2693,6 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;
}; };
D69CCBBE249E6EFD000AF167 /* CrashReporter */ = {
isa = XCSwiftPackageProductDependency;
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;
productName = CrashReporter;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */ /* Begin XCVersionGroup section */

View File

@ -104,11 +104,6 @@
value = "" value = ""
isEnabled = "NO"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable
key = "DEBUG_BLUR_HASH"
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
@ -132,7 +127,7 @@
buildConfiguration = "Debug"> buildConfiguration = "Debug">
</AnalyzeAction> </AnalyzeAction>
<ArchiveAction <ArchiveAction
buildConfiguration = "Release" buildConfiguration = "Dist"
revealArchiveInOrganizer = "YES"> revealArchiveInOrganizer = "YES">
</ArchiveAction> </ArchiveAction>
</Scheme> </Scheme>

View File

@ -50,6 +50,10 @@ struct InstanceFeatures {
|| (instanceType == .pleroma && hasVersion(2, 0, 0)) || (instanceType == .pleroma && hasVersion(2, 0, 0))
} }
var probablySupportsMarkdown: Bool {
instanceType == .pleroma || instanceType == .glitch || instanceType == .hometown
}
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
if ver.contains("glitch") { if ver.contains("glitch") {

View File

@ -68,7 +68,7 @@ class MastodonController: ObservableObject {
} }
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) { func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation({ continuation in let result: (Result, Pagination?) = try await withCheckedThrowingContinuation({ continuation in
client.run(request) { response in client.run(request) { response in
switch response { switch response {
case .failure(let error): case .failure(let error):
@ -78,6 +78,8 @@ class MastodonController: ObservableObject {
} }
} }
}) })
try Task.checkCancellation()
return result
} }
/// - Returns: A tuple of client ID and client secret. /// - Returns: A tuple of client ID and client secret.

View File

@ -7,22 +7,19 @@
// //
import UIKit import UIKit
import CrashReporter
import CoreData import CoreData
import OSLog import OSLog
import Sentry
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration") let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
static private(set) var crashReporter: PLCrashReporter!
static var pendingCrashReport: PLCrashReport?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if !DEBUG configureSentry()
setupCrashReporter() swizzleStatusBar()
#endif
AppShortcutItem.createItems(for: application) AppShortcutItem.createItems(for: application)
@ -52,18 +49,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true return true
} }
private func setupCrashReporter() { private func configureSentry() {
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all) guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String,
AppDelegate.crashReporter = PLCrashReporter(configuration: config) !dsn.isEmpty else {
return
if AppDelegate.crashReporter.hasPendingCrashReport(),
let data = try? AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError(),
let report = try? PLCrashReport(data: data) {
AppDelegate.crashReporter.purgePendingCrashReport()
AppDelegate.pendingCrashReport = report
} }
SentrySDK.start { options in
#if DEBUG
options.debug = true
options.environment = "dev"
#endif
AppDelegate.crashReporter.enable() // the '//' in the full url can't be escaped, so we have to add the scheme back
options.dsn = "https://\(dsn)"
options.enableSwizzling = false
// required to support releases/release health
options.enableAutoSessionTracking = true
options.enableOutOfMemoryTracking = false
options.enableAutoPerformanceTracking = false
options.enableNetworkTracking = false
options.enableAppHangTracking = false
options.enableCoreDataTracking = false
// we don't care about events like battery, keyboard show/hide
options.enableAutoBreadcrumbTracking = false
options.beforeSend = { event in
// just no, why would anyone need this information
event.context?.removeValue(forKey: "culture")
return Preferences.shared.reportErrorsAutomatically ? event : nil
}
}
} }
override func buildMenu(with builder: UIMenuBuilder) { override func buildMenu(with builder: UIMenuBuilder) {
@ -113,4 +129,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil) UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil)
} }
private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:"))
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UIStatusBarManager, sender: AnyObject) in
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIStatusBarManager, Selector, AnyObject) -> Void).self)
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
original(self, selector, sender)
return
}
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
case .stop:
return
case .continue:
original(self, selector, sender)
}
} as @convention(block) (UIStatusBarManager, AnyObject) -> Void)
originalIMP = class_replaceMethod(UIStatusBarManager.self, selector, imp, "v@:@")
if originalIMP == nil {
Logging.general.error("Unable to swizzle status bar manager")
}
}
} }

View File

@ -21,7 +21,7 @@ class DiskCache<T> {
let defaultExpiry: CacheExpiry let defaultExpiry: CacheExpiry
let transformer: DiskCacheTransformer<T> let transformer: DiskCacheTransformer<T>
private var fileStates = [String: FileState]() private var fileStates = MultiThreadDictionary<String, FileState>()
init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws { init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws {
self.defaultExpiry = defaultExpiry self.defaultExpiry = defaultExpiry
@ -117,7 +117,9 @@ class DiskCache<T> {
func removeAll() throws { func removeAll() throws {
try fileManager.removeItem(atPath: path) try fileManager.removeItem(atPath: path)
try createDirectory() try createDirectory()
fileStates.removeAll() fileStates.withLock { dict in
dict.removeAll()
}
} }
} }

View File

@ -24,10 +24,6 @@ class ImageCache {
private let cache: ImageDataCache private let cache: ImageDataCache
private let desiredPixelSize: CGSize? private let desiredPixelSize: CGSize?
private var groups = MultiThreadDictionary<URL, RequestGroup>()
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) { init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) {
// todo: might not always want to use UIScreen.main for this, e.g. Catalyst? // todo: might not always want to use UIScreen.main for this, e.g. Catalyst?
let pixelSize = desiredSize?.applying(.init(scaleX: UIScreen.main.scale, y: UIScreen.main.scale)) let pixelSize = desiredSize?.applying(.init(scaleX: UIScreen.main.scale, y: UIScreen.main.scale))
@ -41,15 +37,19 @@ class ImageCache {
let wrappedCompletion: ((Data?, UIImage?) -> Void)? let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion { if let completion = completion {
wrappedCompletion = { (data, image) in wrappedCompletion = { (data, image) in
if let image {
if !loadOriginal, if !loadOriginal,
let size = self.desiredPixelSize { let size = self.desiredPixelSize {
image?.prepareThumbnail(of: size, completionHandler: { image.prepareThumbnail(of: size) {
completion(data, $0)
})
} else {
image?.prepareForDisplay {
completion(data, $0) completion(data, $0)
} }
} else {
image.prepareForDisplay {
completion(data, $0)
}
}
} else {
completion(data, image)
} }
} }
} else { } else {
@ -61,14 +61,9 @@ class ImageCache {
wrappedCompletion?(entry.data, entry.image) wrappedCompletion?(entry.data, entry.image)
return nil return nil
} else { } else {
if let group = groups[url] { let task = dataTask(url: url, completion: wrappedCompletion)
return group.addCallback(wrappedCompletion) task.resume()
} else { return task
let group = createGroup(url: url)
let request = group.addCallback(wrappedCompletion)
group.run()
return request
}
} }
} }
@ -85,22 +80,23 @@ class ImageCache {
// if caching is disabled, don't bother fetching since nothing will be done with the result // if caching is disabled, don't bother fetching since nothing will be done with the result
guard !ImageCache.disableCaching else { return } guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false), if !((try? cache.has(url.absoluteString)) ?? false) {
!groups.contains(key: url) { let task = dataTask(url: url) { data, image in
let group = createGroup(url: url) guard let data else { return }
group.run() try? self.cache.set(url.absoluteString, data: data, image: image)
}
task.resume()
} }
} }
private func createGroup(url: URL) -> RequestGroup { private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask {
let group = RequestGroup(url: url) { (data, image) in return URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data { guard error == nil,
try? self.cache.set(url.absoluteString, data: data, image: image) let data else {
return
} }
_ = self.groups.removeValue(forKey: url) completion?(data, UIImage(data: data))
} }
groups[url] = group
return group
} }
func getData(_ url: URL) -> Data? { func getData(_ url: URL) -> Data? {
@ -111,87 +107,10 @@ class ImageCache {
return try? cache.get(url.absoluteString, loadOriginal: loadOriginal) return try? cache.get(url.absoluteString, loadOriginal: loadOriginal)
} }
func cancelWithoutCallback(_ url: URL) {
groups[url]?.cancelWithoutCallback()
}
func reset() throws { func reset() throws {
try cache.removeAll() try cache.removeAll()
} }
private class RequestGroup { typealias Request = URLSessionDataTask
let url: URL
private let onFinished: (Data?, UIImage?) -> Void
private var task: URLSessionDataTask?
private var requests = [Request]()
init(url: URL, onFinished: @escaping (Data?, UIImage?) -> Void) {
self.url = url
self.onFinished = onFinished
}
deinit {
task?.cancel()
}
func run() {
task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in
guard error == nil, let data = data else {
self.complete(with: nil)
return
}
self.complete(with: data)
})
task!.resume()
}
func addCallback(_ completion: ((Data?, UIImage?) -> Void)?) -> Request {
let request = Request(callback: completion)
requests.append(request)
return request
}
func cancelWithoutCallback() {
if let request = requests.first(where: { $0.callback == nil && !$0.cancelled }) {
request.cancel()
}
}
fileprivate func requestCancelled() {
let remaining = requests.filter { !$0.cancelled }.count
if remaining <= 0 {
task?.cancel()
complete(with: nil)
}
}
func complete(with data: Data?) {
let image = data != nil ? UIImage(data: data!) : nil
requests.filter { !$0.cancelled }.forEach {
if let callback = $0.callback {
callback(data, image)
}
}
self.onFinished(data, image)
}
}
class Request {
private weak var group: RequestGroup?
private(set) var callback: ((Data?, UIImage?) -> Void)?
private(set) var cancelled: Bool = false
init(callback: ((Data?, UIImage?) -> Void)?) {
self.callback = callback
}
func cancel() {
cancelled = true
callback = nil
group?.requestCancelled()
}
}
} }

View File

@ -11,6 +11,7 @@ import CoreData
import Pachyderm import Pachyderm
import Combine import Combine
import OSLog import OSLog
import Sentry
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore") fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
@ -35,6 +36,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
return context return context
}() }()
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
// would need to audit existing uses to make sure everything happens on the main thread
// and when updating things on the background context would need to switch to main, refetch, and then publish
let statusSubject = PassthroughSubject<String, Never>() let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>() let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>() let relationshipSubject = PassthroughSubject<String, Never>()
@ -70,6 +74,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
try context.save() try context.save()
} catch { } catch {
logger.error("Unable to save managed object context: \(String(describing: error), privacy: .public)") logger.error("Unable to save managed object context: \(String(describing: error), privacy: .public)")
let crumb = Breadcrumb(level: .fatal, category: "PersistentStore")
crumb.message = String(describing: error)
SentrySDK.addBreadcrumb(crumb: crumb)
fatalError("Unable to save managed object context") fatalError("Unable to save managed object context")
} }
} }
@ -144,19 +151,20 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
@discardableResult @discardableResult
private func upsert(account: Account) -> AccountMO { private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) { if let accountMO = self.account(for: account.id, in: context) {
accountMO.updateFrom(apiAccount: account, container: self) accountMO.updateFrom(apiAccount: account, container: self)
return accountMO return accountMO
} else { } else {
return AccountMO(apiAccount: account, container: self, context: self.backgroundContext) return AccountMO(apiAccount: account, container: self, context: context)
} }
} }
func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) { func addOrUpdate(account: Account, in context: NSManagedObjectContext? = nil, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform { let context = context ?? backgroundContext
let accountMO = self.upsert(account: account) context.perform {
self.save(context: self.backgroundContext) let accountMO = self.upsert(account: account, in: context)
self.save(context: context)
completion?(accountMO) completion?(accountMO)
self.accountSubject.send(account.id) self.accountSubject.send(account.id)
} }
@ -175,20 +183,21 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
@discardableResult @discardableResult
private func upsert(relationship: Relationship) -> RelationshipMO { private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) { if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self) relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO return relationshipMO
} else { } else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext) let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: context)
return relationshipMO return relationshipMO
} }
} }
func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) { func addOrUpdate(relationship: Relationship, in context: NSManagedObjectContext? = nil, completion: ((RelationshipMO) -> Void)? = nil) {
backgroundContext.perform { let context = context ?? backgroundContext
let relationshipMO = self.upsert(relationship: relationship) context.perform {
self.save(context: self.backgroundContext) let relationshipMO = self.upsert(relationship: relationship, in: context)
self.save(context: context)
completion?(relationshipMO) completion?(relationshipMO)
self.relationshipSubject.send(relationship.id) self.relationshipSubject.send(relationship.id)
} }
@ -196,7 +205,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(accounts: [Account], completion: (() -> Void)? = nil) { func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext) self.save(context: self.backgroundContext)
completion?() completion?()
accounts.forEach { self.accountSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) }
@ -210,7 +219,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
// since the status has the same account as the notification // since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account } let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext) self.save(context: self.backgroundContext)
completion?() completion?()
statuses.forEach { self.statusSubject.send($0.id) } statuses.forEach { self.statusSubject.send($0.id) }
@ -224,7 +233,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
var updatedStatuses = [String]() var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
updatedAccounts.append(contentsOf: accounts.map { $0.id }) updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in }, { (statuses) in
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }

View File

@ -2,6 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<array> <array>
<string>counter\.social</string>
<string>gab\..+</string> <string>gab\..+</string>
</array> </array>
</plist> </plist>

View File

@ -0,0 +1,45 @@
//
// UIScrollView+Top.swift
// Tusker
//
// Created by Shadowfacts on 11/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
private var prevScrollOffsetBeforeScrollToTopKey: Void = ()
extension UIScrollView {
private var prevScrollOffsetBeforeScrollToTop: CGFloat? {
get {
if let v = (objc_getAssociatedObject(self, &prevScrollOffsetBeforeScrollToTopKey) as? NSNumber)?.doubleValue {
return CGFloat(v)
} else {
return nil
}
}
set {
if let newValue {
objc_setAssociatedObject(self, &prevScrollOffsetBeforeScrollToTopKey, NSNumber(value: newValue), .OBJC_ASSOCIATION_COPY_NONATOMIC)
} else {
objc_setAssociatedObject(self, &prevScrollOffsetBeforeScrollToTopKey, nil, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
}
}
func scrollToTop() {
let top = -adjustedContentInset.top
// +5 to add a little bit of wiggle room
let isScrolledToTop = contentOffset.y < top + 5
if isScrolledToTop {
if let prevScrollOffsetBeforeScrollToTop {
self.prevScrollOffsetBeforeScrollToTop = nil
setContentOffset(CGPoint(x: 0, y: prevScrollOffsetBeforeScrollToTop), animated: true)
}
} else {
prevScrollOffsetBeforeScrollToTop = contentOffset.y
setContentOffset(CGPoint(x: 0, y: top), animated: true)
}
}
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>OSLogPreferences</key> <key>OSLogPreferences</key>
<dict> <dict>
<key>space.vaccor.Tusker</key> <key>space.vaccor.Tusker</key>

View File

@ -17,12 +17,13 @@ struct Logging {
static func getLogData() -> Data? { static func getLogData() -> Data? {
do { do {
let store = try OSLogStore(scope: .currentProcessIdentifier) let store = try OSLogStore(scope: .currentProcessIdentifier)
// past hour // do the filtering ourself, passing position/predicate into getEntries is far slower (priority inversion, I think)
let position = store.position(date: Date().addingTimeInterval(-60 * 60)) let entries = try store.getEntries()
let entries = try store.getEntries(at: position, matching: NSPredicate(format: "subsystem = %@", Bundle.main.bundleIdentifier!))
var data = Data() var data = Data()
let subsystem = Bundle.main.bundleIdentifier!
for entry in entries { for entry in entries {
guard let entry = entry as? OSLogEntryLog else { guard let entry = entry as? OSLogEntryLog,
entry.subsystem == subsystem else {
continue continue
} }
data.append(contentsOf: entry.date.formatted(.iso8601).utf8) data.append(contentsOf: entry.date.formatted(.iso8601).utf8)
@ -39,4 +40,23 @@ struct Logging {
return nil return nil
} }
} }
static func writeDataForCrash() {
guard let data = getLogData(),
let cacheDir = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else {
return
}
let timestamp = ISO8601DateFormatter().string(from: Date())
let url = cacheDir.appendingPathComponent("Tusker-\(timestamp).log", isDirectory: false)
do {
try data.write(to: url)
UserDefaults.standard.set(url, forKey: "lastCrashLog")
} catch {
// if we can't write the data, oh well, we just won't have logs
}
}
static func logURLForLastCrash() -> URL? {
return UserDefaults.standard.url(forKey: "lastCrashLog")
}
} }

View File

@ -62,7 +62,7 @@ class Draft: Codable, ObservableObject {
self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility) self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility)
self.poll = try container.decode(Poll.self, forKey: .poll) self.poll = try container.decode(Poll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText) self.initialText = try container.decode(String.self, forKey: .initialText)

View File

@ -34,21 +34,39 @@ class DraftsManager: Codable {
private init() {} private init() {}
var drafts: [Draft] = [] required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let dict = try? container.decode([UUID: Draft].self, forKey: .drafts) {
self.drafts = dict
} else if let array = try? container.decode([Draft].self, forKey: .drafts) {
self.drafts = array.reduce(into: [:], { partialResult, draft in
partialResult[draft.id] = draft
})
} else {
throw DecodingError.dataCorruptedError(forKey: .drafts, in: container, debugDescription: "expected drafts to be a dict or array of drafts")
}
}
private var drafts: [UUID: Draft] = [:]
var sorted: [Draft] { var sorted: [Draft] {
return drafts.sorted(by: { $0.lastModified > $1.lastModified }) return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
} }
func add(_ draft: Draft) { func add(_ draft: Draft) {
drafts.append(draft) drafts[draft.id] = draft
} }
func remove(_ draft: Draft) { func remove(_ draft: Draft) {
drafts.removeAll { $0 == draft } drafts.removeValue(forKey: draft.id)
} }
func getBy(id: UUID) -> Draft? { func getBy(id: UUID) -> Draft? {
return drafts.first { $0.id == id } return drafts[id]
}
enum CodingKeys: String, CodingKey {
case drafts
} }
} }

View File

@ -16,13 +16,7 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: LockHolder<[AnyHashable: Any]> private let lock: LockHolder<[AnyHashable: Any]>
init() { init() {
if #available(iOS 16.0, *) { self.lock = LockHolder(initialState: [:])
let lock = OSAllocatedUnfairLock(initialState: [:])
self.lock = LockHolder(withLock: lock.withLock(_:))
} else {
let lock = UnfairLock(initialState: [:])
self.lock = LockHolder(withLock: lock.withLock(_:))
}
} }
subscript(key: Key) -> Value? { subscript(key: Key) -> Value? {
@ -66,6 +60,16 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
// see #178 // see #178
fileprivate struct LockHolder<State> { fileprivate struct LockHolder<State> {
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
init(initialState: State) {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} else {
let lock = UnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
}
}
} }
// TODO: replace this only with OSAllocatedUnfairLock // TODO: replace this only with OSAllocatedUnfairLock

View File

@ -67,7 +67,6 @@ class Preferences: Codable, ObservableObject {
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false
self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions)
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
@ -107,7 +106,6 @@ class Preferences: Codable, ObservableObject {
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling) try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideDiscover, forKey: .hideDiscover) try container.encode(hideDiscover, forKey: .hideDiscover)
try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType) try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription) try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
@ -150,8 +148,8 @@ class Preferences: Codable, ObservableObject {
@Published var hideDiscover = false @Published var hideDiscover = false
// MARK: Advanced // MARK: Advanced
@Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain @Published var statusContentType: StatusContentType = .plain
@Published var reportErrorsAutomatically = true
// MARK: // MARK:
@Published var hasShownLocalTimelineDescription = false @Published var hasShownLocalTimelineDescription = false
@ -188,7 +186,6 @@ class Preferences: Codable, ObservableObject {
case disableInfiniteScrolling case disableInfiniteScrolling
case hideDiscover case hideDiscover
case silentActions
case statusContentType case statusContentType
case hasShownLocalTimelineDescription case hasShownLocalTimelineDescription
@ -197,10 +194,4 @@ class Preferences: Codable, ObservableObject {
} }
extension Preferences {
enum Permission: String, Codable {
case undecided, accepted, rejected
}
}
extension UIUserInterfaceStyle: Codable {} extension UIUserInterfaceStyle: Codable {}

View File

@ -105,7 +105,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
switch timeline { switch timeline {
// todo: list/hashtag controllers need whole objects which must be fetched asynchronously // todo: list/hashtag controllers need whole objects which must be fetched asynchronously
default: default:
return TimelineTableViewController(for: timeline, mastodonController: mastodonController) return TimelineViewController(for: timeline, mastodonController: mastodonController)
} }
} }

View File

@ -8,11 +8,10 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import CrashReporter
import MessageUI import MessageUI
import CoreData import CoreData
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate { class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
var window: UIWindow? var window: UIWindow?
@ -32,15 +31,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
if let report = AppDelegate.pendingCrashReport {
AppDelegate.pendingCrashReport = nil
handlePendingCrashReport(report, session: session)
} else {
showAppOrOnboardingUI(session: session) showAppOrOnboardingUI(session: session)
if connectionOptions.urlContexts.count > 0 { if connectionOptions.urlContexts.count > 0 {
self.scene(scene, openURLContexts: connectionOptions.urlContexts) self.scene(scene, openURLContexts: connectionOptions.urlContexts)
} }
}
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
@ -142,19 +136,6 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
} }
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {
#if !DEBUG
guard MFMailComposeViewController.canSendMail() else {
print("Cannot send email")
showAppOrOnboardingUI(session: session)
return
}
window!.rootViewController = CrashReporterViewController.create(report: report, dismiss: {
self.showAppOrOnboardingUI()
})
#endif
}
func showAppOrOnboardingUI(session: UISceneSession? = nil) { func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session let session = session ?? window!.windowScene!.session

View File

@ -0,0 +1,30 @@
//
// TuskerSceneDelegate.swift
// Tusker
//
// Created by Shadowfacts on 10/31/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
protocol TuskerSceneDelegate: UISceneDelegate {
var rootViewController: TuskerRootViewController? { get }
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
}
enum StatusBarTapActionResult {
case `continue`
case stop
}
extension TuskerSceneDelegate {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
if let rootViewController {
let converted = rootViewController.view.convert(CGPoint(x: xPosition, y: 0), from: nil)
return rootViewController.handleStatusBarTapped(xPosition: converted.x)
}
return .continue
}
}

View File

@ -65,7 +65,7 @@ class AccountListTableViewController: EnhancedTableViewController {
} }
extension AccountListTableViewController: TuskerNavigationDelegate { extension AccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension AccountListTableViewController: ToastableViewController { extension AccountListTableViewController: ToastableViewController {

View File

@ -154,6 +154,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
} }
extension BookmarksTableViewController: TuskerNavigationDelegate { extension BookmarksTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
} }
extension BookmarksTableViewController: ToastableViewController { extension BookmarksTableViewController: ToastableViewController {
@ -163,8 +164,6 @@ extension BookmarksTableViewController: MenuActionProvider {
} }
extension BookmarksTableViewController: StatusTableViewCellDelegate { extension BookmarksTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates() tableView.beginUpdates()
tableView.endUpdates() tableView.endUpdates()
@ -176,14 +175,4 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching, Status
let ids = indexPaths.map { statuses[$0.row].id } let ids = indexPaths.map { statuses[$0.row].id }
prefetchStatuses(with: ids) prefetchStatuses(with: ids)
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap {
guard $0.row < statuses.count else {
return nil
}
return statuses[$0.row].id
}
cancelPrefetchingStatuses(with: ids)
}
} }

View File

@ -234,7 +234,6 @@ struct SheetOrPopover<V: View>: ViewModifier {
@Environment(\.horizontalSizeClass) var sizeClass @Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View { func body(content: Content) -> some View {
let _ = print("isPresented: \(isPresented)")
if sizeClass == .compact { if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view) content.sheet(isPresented: $isPresented, content: view)
} else { } else {

View File

@ -137,8 +137,7 @@ struct ComposeView: View {
} }
MainComposeTextView( MainComposeTextView(
draft: draft, draft: draft
placeholder: Text("What's on your mind?")
) )
if let poll = draft.poll { if let poll = draft.poll {

View File

@ -11,7 +11,23 @@ import Pachyderm
struct MainComposeTextView: View { struct MainComposeTextView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let placeholder: Text @State private var placeholder: Text = {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14 {
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
return Text("Happy π day!")
}
} else if components.month == 9 && components.day == 21 {
return Text("Do you remember?")
} else if components.month == 10 && components.day == 31 {
if .random() {
return Text("Post something spooky!")
} else {
return Text("Any questions?")
}
}
return Text("What's on your mind?")
}()
let minHeight: CGFloat = 150 let minHeight: CGFloat = 150
@State private var height: CGFloat? @State private var height: CGFloat?

View File

@ -441,6 +441,8 @@ extension ConversationTableViewController {
} }
extension ConversationTableViewController: TuskerNavigationDelegate { extension ConversationTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController { func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController) let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
// transfer show statuses automatically state when showing new conversation // transfer show statuses automatically state when showing new conversation
@ -453,7 +455,6 @@ extension ConversationTableViewController: MenuActionProvider {
} }
extension ConversationTableViewController: StatusTableViewCellDelegate { extension ConversationTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights // causes the table view to recalculate the cell heights
tableView.beginUpdates() tableView.beginUpdates()
@ -466,11 +467,6 @@ extension ConversationTableViewController: UITableViewDataSourcePrefetching, Sta
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id } let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
prefetchStatuses(with: ids) prefetchStatuses(with: ids)
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
cancelPrefetchingStatuses(with: ids)
}
} }
extension ConversationTableViewController: ToastableViewController { extension ConversationTableViewController: ToastableViewController {

View File

@ -1,47 +0,0 @@
//
// CrashReporterViewController.swift
// Tusker
//
// Created by Shadowfacts on 3/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import CrashReporter
class CrashReporterViewController: IssueReporterViewController {
private let report: PLCrashReport
override var preamble: String {
"Tusker has detected that it crashed the last time it was running. You can email the report to the developer or skip sending and continue to the app. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior to the crash that may be pertinent."
}
override var subject: String {
"Tusker Crash Report"
}
static func create(report: PLCrashReport, dismiss: @escaping () -> Void) -> UINavigationController {
return create(CrashReporterViewController(report: report, dismiss: dismiss))
}
private init(report: PLCrashReport, dismiss: @escaping () -> Void) {
self.report = report
let reportText = PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS)!
let timestamp = ISO8601DateFormatter().string(from: report.systemInfo.timestamp)
let reportFilename = "Tusker-crash-\(timestamp).crash"
super.init(reportText: reportText, reportFilename: reportFilename, dismiss: dismiss)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = NSLocalizedString("Crash Detected", comment: "crash reporter title")
}
}

View File

@ -7,7 +7,6 @@
// //
import UIKit import UIKit
import CrashReporter
import MessageUI import MessageUI
import OSLog import OSLog
@ -26,7 +25,7 @@ class IssueReporterViewController: UIViewController {
let reportText: String let reportText: String
let reportFilename: String let reportFilename: String
private let dismiss: () -> Void private let doDismiss: () -> Void
var preamble: String { var preamble: String {
"Tusker has encountered an error. You can email a report to the developer. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior that may be pertinent." "Tusker has encountered an error. You can email a report to the developer. You may review the report below before sending.\n\nIf you choose to send the report, please include any additional details about what you were doing prior that may be pertinent."
@ -43,7 +42,7 @@ class IssueReporterViewController: UIViewController {
init(reportText: String, reportFilename: String, dismiss: @escaping () -> Void) { init(reportText: String, reportFilename: String, dismiss: @escaping () -> Void) {
self.reportText = reportText self.reportText = reportText
self.reportFilename = reportFilename self.reportFilename = reportFilename
self.dismiss = dismiss self.doDismiss = dismiss
self.logDataTask = Task(priority: .userInitiated) { self.logDataTask = Task(priority: .userInitiated) {
return await withCheckedContinuation({ continuation in return await withCheckedContinuation({ continuation in
@ -118,6 +117,7 @@ class IssueReporterViewController: UIViewController {
@IBAction func sendReportTouchUpInside(_ sender: Any) { @IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true) updateSendReportButtonColor(lightened: false, animate: true)
sendReportButton.isEnabled = false
Task { Task {
let composeVC = MFMailComposeViewController() let composeVC = MFMailComposeViewController()
@ -128,12 +128,13 @@ class IssueReporterViewController: UIViewController {
let data = reportText.data(using: .utf8)! let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename) composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
if let logData = await logDataTask.value { if let (logData, name) = await getLogData() {
let timestamp = ISO8601DateFormatter().string(from: Date()) composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: name)
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: "Tusker-\(timestamp).log")
} }
self.present(composeVC, animated: true) self.present(composeVC, animated: true)
sendReportButton.isEnabled = true
} }
} }
@ -142,11 +143,24 @@ class IssueReporterViewController: UIViewController {
let url = dir.appendingPathComponent(reportFilename) let url = dir.appendingPathComponent(reportFilename)
try! reportText.data(using: .utf8)!.write(to: url) try! reportText.data(using: .utf8)!.write(to: url)
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil) let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
activityController.popoverPresentationController?.sourceView = sendReportButton
present(activityController, animated: true) present(activityController, animated: true)
} }
@IBAction func cancelPressed(_ sender: Any) { @IBAction func cancelPressed(_ sender: Any) {
dismiss() self.finishedReport()
doDismiss()
}
func getLogData() async -> (Data, String)? {
guard let data = await logDataTask.value else {
return nil
}
let timestamp = ISO8601DateFormatter().string(from: Date())
return (data, "Tusker-\(timestamp).log")
}
func finishedReport() {
} }
} }
@ -154,7 +168,12 @@ class IssueReporterViewController: UIViewController {
extension IssueReporterViewController: MFMailComposeViewControllerDelegate { extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) { controller.dismiss(animated: true) {
self.dismiss() if result == .cancelled {
// don't dismiss ourself, to allowe the user to send the report a different way
} else {
self.finishedReport()
self.doDismiss()
}
} }
} }
} }

View File

@ -147,7 +147,7 @@ extension ProfileDirectoryViewController {
} }
extension ProfileDirectoryViewController: TuskerNavigationDelegate { extension ProfileDirectoryViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension ProfileDirectoryViewController: ToastableViewController { extension ProfileDirectoryViewController: ToastableViewController {

View File

@ -120,7 +120,7 @@ extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
} }
extension TrendingHashtagsViewController: TuskerNavigationDelegate { extension TrendingHashtagsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension TrendingHashtagsViewController: ToastableViewController { extension TrendingHashtagsViewController: ToastableViewController {

View File

@ -100,13 +100,17 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
guard let hash = card.blurhash else { guard let hash = card.blurhash else {
return return
} }
let imageViewSize = self.thumbnailView.bounds.size
AttachmentView.queue.async { [weak self] in AttachmentView.queue.async { [weak self] in
let size: CGSize let size: CGSize
if let width = card.width, let height = card.height { if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height) let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else { } else {
size = imageViewSize size = CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
size = CGSize(width: 32, height: 32)
} }
guard let preview = UIImage(blurHash: hash, size: size) else { guard let preview = UIImage(blurHash: hash, size: size) else {

View File

@ -143,13 +143,17 @@ class TrendingLinkTableViewCell: UITableViewCell {
guard let hash = card.blurhash else { guard let hash = card.blurhash else {
return return
} }
let imageViewSize = self.thumbnailView.bounds.size
AttachmentView.queue.async { [weak self] in AttachmentView.queue.async { [weak self] in
let size: CGSize let size: CGSize
if let width = card.width, let height = card.height { if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height) let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else { } else {
size = imageViewSize size = CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
size = CGSize(width: 32, height: 32)
} }
guard let preview = UIImage(blurHash: hash, size: size) else { guard let preview = UIImage(blurHash: hash, size: size) else {

View File

@ -101,7 +101,7 @@ extension TrendingLinksViewController {
} }
extension TrendingLinksViewController: TuskerNavigationDelegate { extension TrendingLinksViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension TrendingLinksViewController: ToastableViewController { extension TrendingLinksViewController: ToastableViewController {

View File

@ -82,7 +82,7 @@ extension TrendingStatusesViewController {
} }
extension TrendingStatusesViewController: TuskerNavigationDelegate { extension TrendingStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension TrendingStatusesViewController: ToastableViewController { extension TrendingStatusesViewController: ToastableViewController {

View File

@ -37,8 +37,15 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
// MARK: - Interaction // MARK: - Interaction
@objc func cancelButtonPressed() { @objc func cancelButtonPressed() {
// when the search controller is active, dismiss exits it rather than dismissing ourself, so we have to dismiss twice
if searchController.isActive {
dismiss(animated: false) {
self.dismiss(animated: true)
}
} else {
dismiss(animated: true) dismiss(animated: true)
} }
}
} }

View File

@ -14,6 +14,7 @@ import VisionKit
protocol LargeImageContentView: UIView { protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get } var activityItemsForSharing: [Any] { get }
func setControlsVisible(_ controlsVisible: Bool)
func grayscaleStateChanged() func grayscaleStateChanged()
} }
@ -22,6 +23,9 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
@available(iOS 16.0, *) @available(iOS 16.0, *)
private static let analyzer = ImageAnalyzer() private static let analyzer = ImageAnalyzer()
private var _analysisInteraction: AnyObject?
@available(iOS 16.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
#endif #endif
var animationImage: UIImage? { image! } var animationImage: UIImage? { image! }
@ -45,6 +49,7 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
ImageAnalyzer.isSupported { ImageAnalyzer.isSupported {
let interaction = ImageAnalysisInteraction() let interaction = ImageAnalysisInteraction()
self._analysisInteraction = interaction
interaction.delegate = self interaction.delegate = self
interaction.preferredInteractionTypes = .automatic interaction.preferredInteractionTypes = .automatic
addInteraction(interaction) addInteraction(interaction)
@ -64,6 +69,17 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) {
#if !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *),
let analysisInteraction {
// note: passing animated: true here doesn't seem to do anything by itself as of iOS 16.2 (20C5032e)
// so the LargeImageViewController handles animating, but we still need to pass true here otherwise it doesn't animate
analysisInteraction.setSupplementaryInterfaceHidden(!controlsVisible, animated: true)
}
#endif
}
func grayscaleStateChanged() { func grayscaleStateChanged() {
guard let data = sourceData else { guard let data = sourceData else {
return return
@ -113,6 +129,9 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) {
}
func grayscaleStateChanged() { func grayscaleStateChanged() {
// todo // todo
} }
@ -151,6 +170,9 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) {
}
func grayscaleStateChanged() { func grayscaleStateChanged() {
// no-op, GifvAttachmentView observes the grayscale state itself // no-op, GifvAttachmentView observes the grayscale state itself
} }

View File

@ -174,6 +174,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
self.controlsVisible = controlsVisible self.controlsVisible = controlsVisible
if animated { if animated {
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.contentView.setControlsVisible(controlsVisible)
self.updateControlsView() self.updateControlsView()
} }
} else { } else {

View File

@ -174,7 +174,7 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
} }
extension EditListAccountsViewController: TuskerNavigationDelegate { extension EditListAccountsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension EditListAccountsViewController: ToastableViewController { extension EditListAccountsViewController: ToastableViewController {

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class ListTimelineViewController: TimelineTableViewController { class ListTimelineViewController: TimelineViewController {
let list: List let list: List
@ -57,8 +57,11 @@ class ListTimelineViewController: TimelineTableViewController {
@objc func editListDoneButtonPressed() { @objc func editListDoneButtonPressed() {
dismiss(animated: true) dismiss(animated: true)
// todo: show loading indicator // TODO: only reload if there were changes
reloadInitial() Task {
applyInitialSnapshot()
await controller.loadInitial()
}
} }
} }

View File

@ -111,6 +111,12 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
loadViewIfNeeded() loadViewIfNeeded()
root.presentPreferences(completion: completion) root.presentPreferences(completion: completion)
} }
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
loadViewIfNeeded()
// TODO: check if fast account switcher is being presented?
return root.handleStatusBarTapped(xPosition: xPosition)
}
} }
extension AccountSwitchingContainerViewController: BackgroundableViewController { extension AccountSwitchingContainerViewController: BackgroundableViewController {

View File

@ -456,6 +456,22 @@ extension MainSplitViewController: TuskerRootViewController {
func presentPreferences(completion: (() -> Void)?) { func presentPreferences(completion: (() -> Void)?) {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion) present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
} }
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
guard presentedViewController == nil else {
return .stop
}
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.handleStatusBarTapped(xPosition: xPosition)
} else {
let pointInSecondary = secondaryNavController.view.convert(CGPoint(x: xPosition, y: 0), from: view)
if secondaryNavController.view.bounds.contains(pointInSecondary) {
return secondaryNavController.handleStatusBarTapped(xPosition: pointInSecondary.x)
} else {
return .continue
}
}
}
} }
extension MainSplitViewController: BackgroundableViewController { extension MainSplitViewController: BackgroundableViewController {

View File

@ -285,6 +285,16 @@ extension MainTabBarViewController: TuskerRootViewController {
func presentPreferences(completion: (() -> Void)?) { func presentPreferences(completion: (() -> Void)?) {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion) present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
} }
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
guard presentedViewController == nil else {
return .stop
}
guard let vc = viewController(for: selectedTab) as? StatusBarTappableViewController else {
return .continue
}
return vc.handleStatusBarTapped(xPosition: xPosition)
}
} }
extension MainTabBarViewController: BackgroundableViewController { extension MainTabBarViewController: BackgroundableViewController {

View File

@ -8,7 +8,7 @@
import UIKit import UIKit
protocol TuskerRootViewController: UIViewController { protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController {
func presentCompose() func presentCompose()
func select(tab: MainTabBarViewController.Tab) func select(tab: MainTabBarViewController.Tab)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?

View File

@ -269,7 +269,7 @@ extension NotificationsTableViewController {
} }
extension NotificationsTableViewController: TuskerNavigationDelegate { extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension NotificationsTableViewController: MenuActionProvider { extension NotificationsTableViewController: MenuActionProvider {
@ -294,14 +294,4 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
} }
} }
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let group = dataSource.itemIdentifier(for: indexPath)?.group else { continue }
for notification in group.notifications {
guard let avatar = notification.account.avatar else { continue }
ImageCache.avatars.cancelWithoutCallback(avatar)
}
}
}
} }

View File

@ -18,6 +18,12 @@ fileprivate let instanceCell = "instanceCell"
class InstanceSelectorTableViewController: UITableViewController { class InstanceSelectorTableViewController: UITableViewController {
static var blocks: [NSRegularExpression] = {
guard let path = Bundle.main.path(forResource: "DomainBlocks", ofType: "plist"),
let array = NSArray(contentsOfFile: path) as? [String] else { return [] }
return array.compactMap { try? NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
weak var delegate: InstanceSelectorTableViewControllerDelegate? weak var delegate: InstanceSelectorTableViewControllerDelegate?
var dataSource: DataSource! var dataSource: DataSource!
@ -100,7 +106,7 @@ class InstanceSelectorTableViewController: UITableViewController {
loadRecommendedInstances() loadRecommendedInstances()
} }
private func parseURLComponents(input: String) -> URLComponents { private func parseURLComponents(input: String) -> URLComponents? {
// we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path // we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path
var input = input var input = input
var components = URLComponents() var components = URLComponents()
@ -125,13 +131,24 @@ class InstanceSelectorTableViewController: UITableViewController {
components.port = Int(parts.last!) components.port = Int(parts.last!)
} }
components.host = input components.host = input
if Self.blocks.contains(where: { $0.numberOfMatches(in: input, range: NSRange(location: 0, length: input.utf16.count)) > 0 }) {
return nil
}
return components return components
} }
private func updateSpecificInstance(domain: String) { private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating() activityIndicator.startAnimating()
let components = parseURLComponents(input: domain) guard let components = parseURLComponents(input: domain) else {
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteSections([.selected])
dataSource.apply(snapshot)
}
activityIndicator.stopAnimating()
return
}
let url = components.url! let url = components.url!
let client = Client(baseURL: url, session: .appDefault) let client = Client(baseURL: url, session: .appDefault)

View File

@ -17,12 +17,6 @@ protocol OnboardingViewControllerDelegate {
class OnboardingViewController: UINavigationController { class OnboardingViewController: UINavigationController {
static var blocks: [NSRegularExpression] = {
guard let path = Bundle.main.path(forResource: "DomainBlocks", ofType: "plist"),
let array = NSArray(contentsOfFile: path) as? [String] else { return [] }
return array.compactMap { try? NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
var onboardingDelegate: OnboardingViewControllerDelegate? var onboardingDelegate: OnboardingViewControllerDelegate?
var instanceSelector = InstanceSelectorTableViewController() var instanceSelector = InstanceSelectorTableViewController()

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import CoreData
struct AdvancedPrefsView : View { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -14,7 +15,7 @@ struct AdvancedPrefsView : View {
var body: some View { var body: some View {
List { List {
formattingSection formattingSection
automationSection errorReportingSection
cachingSection cachingSection
} }
.listStyle(InsetGroupedListStyle()) .listStyle(InsetGroupedListStyle())
@ -22,7 +23,17 @@ struct AdvancedPrefsView : View {
} }
var formattingFooter: some View { var formattingFooter: some View {
Text("This option is only supported for Pleroma and Mastodon instances with formatting enabled. On all other instances, formatting symbols will remain in the unformatted plain text.").lineLimit(nil) var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch or Hometown).\n"
if let account = LocalData.shared.getMostRecentAccount() {
let mastodonController = MastodonController.getForAccount(account)
// shouldn't need to load the instance here, because loading it is kicked off my the scene delegate
if !mastodonController.instanceFeatures.probablySupportsMarkdown {
var warning = AttributedString("\(account.instanceURL.host!) does not appear to support formatting. Using formatting symbols may not have an effect.")
warning[AttributeScopes.SwiftUIAttributes.FontAttribute.self] = .caption.bold()
s += warning
}
}
return Text(s).lineLimit(nil)
} }
var formattingSection: some View { var formattingSection: some View {
@ -36,10 +47,18 @@ struct AdvancedPrefsView : View {
} }
} }
var automationSection: some View { var errorReportingSection: some View {
Section(header: Text("Automation")) { Section {
NavigationLink(destination: SilentActionPrefs()) { Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)
Text("Silent Action Permissions") } footer: {
var privacyPolicy: AttributedString = "Privacy Policy"
let _ = privacyPolicy.link = URL(string: "https://vaccor.space/tusker#privacy")!
if preferences.reportErrorsAutomatically {
Text(AttributedString("App crashes and errors will be automatically reported to the developer. You may be prompted to add additional information.\n") + privacyPolicy)
.lineLimit(nil)
} else {
Text(AttributedString("Errors will not be reported automatically. When a crash occurs, you may be asked to report it manually.\n") + privacyPolicy)
.lineLimit(nil)
} }
} }
} }
@ -58,9 +77,16 @@ struct AdvancedPrefsView : View {
private func clearCache() { private func clearCache() {
for account in LocalData.shared.accounts { for account in LocalData.shared.accounts {
let controller = MastodonController.getForAccount(account) let controller = MastodonController.getForAccount(account)
let coordinator = controller.persistentContainer.persistentStoreCoordinator let container = controller.persistentContainer
for store in coordinator.persistentStores { do {
try! coordinator.destroyPersistentStore(at: store.url!, ofType: store.type, options: store.options) let statusesReq = NSBatchDeleteRequest(fetchRequest: StatusMO.fetchRequest())
try container.viewContext.execute(statusesReq)
let accountsReq = NSBatchDeleteRequest(fetchRequest: AccountMO.fetchRequest())
try container.viewContext.execute(accountsReq)
let relationshipsReq = NSBatchDeleteRequest(fetchRequest: RelationshipMO.fetchRequest())
try container.viewContext.execute(relationshipsReq)
} catch {
Logging.general.error("Error while clearing Mastodon cache: \(String(describing: error), privacy: .public)")
} }
} }
resetUI() resetUI()

View File

@ -1,44 +0,0 @@
// SilentActionPrefs.swift
// Tusker
//
// Created by Shadowfacts on 6/13/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import SwiftUI
struct SilentActionPrefs : View {
@ObservedObject var preferences = Preferences.shared
var body: some View {
List(Array(preferences.silentActions.keys), id: \.self) { source in
SilentActionPermissionCell(source: source)
}
.listStyle(InsetGroupedListStyle())
// .navigationBarTitle("Silent Action Permissions")
// see FB6838291
}
}
struct SilentActionPermissionCell: View {
@ObservedObject var preferences = Preferences.shared
let source: String
var body: some View {
Toggle(isOn: Binding(get: {
self.preferences.silentActions[self.source] == .accepted
}, set: {
self.preferences.silentActions[self.source] = $0 ? .accepted : .rejected
})) {
Text(verbatim: source)
}
}
}
#if DEBUG
struct SilentActionPrefs_Previews : PreviewProvider {
static var previews: some View {
SilentActionPrefs().environmentObject(Preferences.shared)
}
}
#endif

View File

@ -0,0 +1,70 @@
//
// ProfileHeaderCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/10/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class ProfileHeaderCollectionViewCell: UICollectionViewCell {
private var state: State = .unloaded
var view: ProfileHeaderView? {
if case .view(let view) = state {
return view
} else {
return nil
}
}
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .systemBackground
isOpaque = true
contentView.isOpaque = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addHeader(_ header: ProfileHeaderView) {
switch state {
case .unloaded, .placeholder(heightConstraint: _):
header.translatesAutoresizingMaskIntoConstraints = false
contentView.embedSubview(header)
self.state = .view(header)
case .view(_):
fatalError("profile header collection view cell already has view")
}
}
func addConstraint(height: CGFloat) -> ProfileHeaderView? {
switch state {
case .unloaded:
let constraint = contentView.heightAnchor.constraint(equalToConstant: height)
constraint.isActive = true
state = .placeholder(heightConstraint: constraint)
return nil
case .placeholder(let heightConstraint):
heightConstraint.constant = height
return nil
case .view(let header):
let constraint = contentView.heightAnchor.constraint(equalToConstant: height)
constraint.isActive = true
state = .placeholder(heightConstraint: constraint)
return header
}
}
enum State {
case unloaded
case placeholder(heightConstraint: NSLayoutConstraint)
case view(ProfileHeaderView)
}
}

View File

@ -2,287 +2,487 @@
// ProfileStatusesViewController.swift // ProfileStatusesViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 7/3/20. // Created by Shadowfacts on 10/6/22.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2022 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine
class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<ProfileStatusesViewController.Section, ProfileStatusesViewController.Item> { class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController {
weak var mastodonController: MastodonController!
private(set) var headerView: ProfileHeaderView!
var accountID: String!
weak var owner: ProfileViewController?
let mastodonController: MastodonController
private(set) var accountID: String!
let kind: Kind let kind: Kind
var initialHeaderMode: HeaderMode?
weak var profileHeaderDelegate: ProfileHeaderViewDelegate?
private var older: RequestRange? private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange?
private var cancellables = Set<AnyCancellable>()
init(accountID: String?, kind: Kind, mastodonController: MastodonController) { var collectionView: UICollectionView {
view as! UICollectionView
}
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell?
private var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
self.accountID = accountID self.accountID = accountID
self.kind = kind self.kind = kind
self.mastodonController = mastodonController self.owner = owner
self.mastodonController = owner.mastodonController
super.init() super.init(nibName: nil, bundle: nil)
dragEnabled = true self.controller = TimelineLikeController(delegate: self)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionSeparatorConfiguration
}
var config = sectionSeparatorConfiguration
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
registerTimelineLikeCells()
dataSource = createDataSource()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell") mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
// setup the initial snapshot with the sections in the right order, so we don't have to worry about order later .filter { [unowned self] in $0 == self.accountID }
var snapshot = Snapshot() .sink { [unowned self] id in
snapshot.appendSections([.pinned, .statuses]) switch state {
dataSource.apply(snapshot, animatingDifferences: false) case .unloaded:
Task {
await load()
} }
case .loading:
func updateUI(account: AccountMO) { break
if isViewLoaded { case .loaded, .setupInitialSnapshot:
reloadInitial() var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)])
dataSource.apply(snapshot, animatingDifferences: true)
} }
} }
.store(in: &cancellables)
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")
} }
// MARK: - DiffableTimelineLikeTableViewController private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
switch item {
case .loadingIndicator:
return self.loadingIndicatorCell(indexPath: indexPath)
case let .status(id: id, state: state, pinned: pinned):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self cell.delegate = self
cell.showPinned = pinned cell.showPinned = item.2
cell.updateUI(statusID: id, state: state) cell.updateUI(statusID: item.0, state: item.1)
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .header(let id):
if let headerCell = self.headerCell {
return headerCell
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell
switch self.initialHeaderMode {
case nil:
fatalError("missing initialHeaderMode")
case .createView:
let view = ProfileHeaderView.create()
view.delegate = self.profileHeaderDelegate
view.updateUI(for: id)
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
cell.addHeader(view)
case .placeholder(height: let height):
_ = cell.addConstraint(height: height)
}
self.headerCell = cell
return cell return cell
} }
case .status(id: let id, state: let state, pinned: let pinned):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
return confirmLoadMoreCell(for: indexPath)
}
}
} }
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) { override func viewWillAppear(_ animated: Bool) {
guard accountID != nil else { super.viewWillAppear(animated)
completion(.failure(.noClient))
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
Task {
if case .notLoadedInitial = await controller.state {
await load()
}
}
}
func setAccountID(_ id: String) {
self.accountID = id
// TODO: maybe this function should be async?
Task {
await load()
}
}
private func load() async {
guard isViewLoaded,
let accountID,
case .unloaded = state,
mastodonController.persistentContainer.account(for: accountID) != nil else {
return return
} }
getStatuses { (response) in state = .loading
guard self.state == .loadingInitial else {
return var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.header, .pinned, .statuses])
snapshot.appendItems([.header(accountID)], toSection: .header)
await apply(snapshot, animatingDifferences: false)
state = .setupInitialSnapshot
await controller.loadInitial()
await tryLoadPinned()
state = .loaded
} }
switch response { private func tryLoadPinned() async {
case let .failure(error): do {
completion(.failure(.client(error))) try await loadPinned()
} catch {
case let .success(statuses, _): let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
if !statuses.isEmpty { toast.dismissToast(animated: true)
self.newer = .after(id: statuses.first!.id, count: nil) await self.tryLoadPinned()
self.older = .before(id: statuses.last!.id, count: nil)
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
if self.kind == .statuses {
self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion)
} else {
completion(.success(snapshot))
}
}
}
} }
self.showToast(configuration: config, animated: true)
} }
} }
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { private func loadPinned() async throws {
guard kind == .statuses, guard case .statuses = kind,
mastodonController.instanceFeatures.profilePinnedStatuses else { mastodonController.instanceFeatures.profilePinnedStatuses else {
completion(.success(snapshot()))
return
}
getPinnedStatuses { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
completion(.success(snapshot))
}
}
}
}
}
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
return return
} }
getStatuses(for: older) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
guard !statuses.isEmpty else {
completion(.failure(.noOlder))
return
}
self.older = .before(id: statuses.last!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
completion(.success(snapshot))
}
}
}
}
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion(.failure(.noNewer))
return
}
getStatuses(for: newer) { (response) in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
self.newer = .after(id: statuses.first!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(items, beforeItem: first)
} else {
snapshot.appendItems(items, toSection: .statuses)
}
completion(.success(snapshot))
}
}
}
}
private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) {
let request: Request<[Status]>
switch kind {
case .statuses:
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
case .withReplies:
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
case .onlyMedia:
request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
}
mastodonController.run(request, completion: completion)
}
private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) {
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
mastodonController.run(request, completion: completion) let (statuses, _) = try await mastodonController.run(request)
}
override func refresh() { await withCheckedContinuation { continuation in
super.refresh() mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
// only refresh pinned if the super call actually succeded (put the state into .loadingNewer)
if state == .loadingNewer,
kind == .statuses {
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
switch result {
case .failure(_):
break
case let .success(snapshot):
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
}
}
} }
} }
var snapshot = dataSource.snapshot()
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) }
snapshot.appendItems(items, toSection: .pinned)
await apply(snapshot, animatingDifferences: true)
}
@objc func refresh() {
guard case .loaded = state else {
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
return
}
Task {
// TODO: coalesce these data source updates
// TODO: refresh profile
await controller.loadNewer()
await tryLoadPinned()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
}
}
extension ProfileStatusesViewController {
enum State {
case unloaded
case loading
case setupInitialSnapshot
case loaded
}
} }
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum Kind { enum Kind {
case statuses, withReplies, onlyMedia case statuses, withReplies, onlyMedia
} }
enum HeaderMode {
case createView, placeholder(height: CGFloat)
}
} }
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum Section: DiffableTimelineLikeSection { enum Section: TimelineLikeCollectionViewSection {
case loadingIndicator case header
case pinned case pinned
case statuses case statuses
} case footer
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState, pinned: Bool)
var id: String? { static var entries: Self { .statuses }
}
enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String
case header(String)
case status(id: String, state: StatusState, pinned: Bool)
case loadingIndicator
case confirmLoadMore
static func fromTimelineItem(_ item: String) -> Self {
return .status(id: item, state: .unknown, pinned: false)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.header(a), .header(b)):
return a == b
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
return a == b && ap == bp
case (.loadingIndicator, .loadingIndicator):
return true
case (.confirmLoadMore, .confirmLoadMore):
return true
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self { switch self {
case .header(let id):
hasher.combine(0)
hasher.combine(id)
case .status(id: let id, state: _, pinned: let pinned):
hasher.combine(1)
hasher.combine(id)
hasher.combine(pinned)
case .loadingIndicator: case .loadingIndicator:
return nil hasher.combine(2)
case .status(id: let id, state: _, pinned: _): case .confirmLoadMore:
return id hasher.combine(3)
} }
} }
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .confirmLoadMore:
return true
default:
return false
}
}
var isSelectable: Bool {
switch self {
case .status(id: _, state: _, pinned: _):
return true
default:
return false
}
}
}
}
extension ProfileStatusesViewController: TimelineLikeControllerDelegate {
typealias TimelineItem = String // status ID
private func request(for range: RequestRange = .default) -> Request<[Status]> {
switch kind {
case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
case .withReplies:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
case .onlyMedia:
return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
}
}
func loadInitial() async throws -> [String] {
let request = request()
let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil)
older = .before(id: statuses.last!.id, count: nil)
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadNewer() async throws -> [String] {
guard let newer else {
throw Error.noNewer
}
let request = request(for: newer)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
throw Error.allCaughtUp
}
self.newer = .after(id: statuses.first!.id, count: nil)
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadOlder() async throws -> [String] {
guard let older else {
throw Error.noOlder
}
let request = request(for: older)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
}
self.older = .before(id: statuses.last!.id, count: nil)
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
enum Error: TimelineLikeCollectionViewError {
case noNewer
case noOlder
case allCaughtUp
}
}
extension ProfileStatusesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 {
Task {
await controller.loadOlder()
}
}
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let status = mastodonController.persistentContainer.status(for: id)!
selected(status: status.reblog?.id ?? id, state: state.copy())
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension ProfileStatusesViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
(collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
} }
} }
extension ProfileStatusesViewController: TuskerNavigationDelegate { extension ProfileStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension ProfileStatusesViewController: StatusTableViewCellDelegate { extension ProfileStatusesViewController: MenuActionProvider {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { }
if #available(iOS 16.0, *) {
} else { extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
cellHeightChanged() 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)
} }
} }
} }
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching { extension ProfileStatusesViewController: TabBarScrollableViewController {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tabBarScrollToTop() {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id } collectionView.scrollToTop()
prefetchStatuses(with: ids) }
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { extension ProfileStatusesViewController: StatusBarTappableViewController {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id } func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
cancelPrefetchingStatuses(with: ids) collectionView.scrollToTop()
return .stop
} }
} }

View File

@ -2,8 +2,8 @@
// ProfileViewController.swift // ProfileViewController.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 7/3/20. // Created by Shadowfacts on 10/10/22.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2022 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
@ -18,39 +18,43 @@ class ProfileViewController: UIPageViewController {
// when first constructed. It should never be set to nil. // when first constructed. It should never be set to nil.
var accountID: String? { var accountID: String? {
willSet { willSet {
if newValue == nil { precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
fatalError("Do not set ProfileViewController.accountID to nil")
}
} }
didSet { didSet {
pageControllers.forEach { $0.accountID = accountID } pageControllers.forEach { $0.setAccountID(accountID!) }
loadAccount() Task {
await loadAccount()
}
} }
} }
private var accountUpdater: Cancellable?
private(set) var currentIndex: Int! private(set) var currentIndex: Int!
let pageControllers: [ProfileStatusesViewController] private var pageControllers: [ProfileStatusesViewController]!
var currentViewController: ProfileStatusesViewController { var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex] pageControllers[currentIndex]
} }
private var headerView: ProfileHeaderView! private var state: State = .idle
private var hasAppeared = false private var cancellables = Set<AnyCancellable>()
init(accountID: String?, mastodonController: MastodonController) { init(accountID: String?, mastodonController: MastodonController) {
self.accountID = accountID self.accountID = accountID
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.pageControllers = [ self.pageControllers = [
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController), .init(accountID: accountID, kind: .statuses, owner: self),
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController), .init(accountID: accountID, kind: .withReplies, owner: self),
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController) .init(accountID: accountID, kind: .onlyMedia, owner: self),
] ]
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) // try to update the account UI immediately if possible, to avoid the navigation title popping in later
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
updateAccountUI(account: account)
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -62,35 +66,36 @@ class ProfileViewController: UIPageViewController {
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
for pageController in pageControllers {
pageController.profileHeaderDelegate = self
}
selectPage(at: 0, animated: false)
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ composeButton.menu = UIMenu(children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
self?.composeDirectMentioning() self.composeDirectMentioning()
}) })
]) ])
composeButton.isEnabled = mastodonController.loggedIn composeButton.isEnabled = mastodonController.loggedIn
navigationItem.rightBarButtonItem = composeButton navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create()
headerView.delegate = self
selectPage(at: 0, animated: false)
currentViewController.tableView.tableHeaderView = headerView
NSLayoutConstraint.activate([
headerView.widthAnchor.constraint(equalTo: view.widthAnchor),
])
addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand) addKeyCommand(MenuController.nextSubTabCommand)
accountUpdater = mastodonController.persistentContainer.accountSubject mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID } .filter { [unowned self] in $0 == self.accountID }
.sink { [weak self] (_) in self?.updateAccountUI() } .sink { [unowned self] id in
let account = self.mastodonController.persistentContainer.account(for: id)!
self.updateAccountUI(account: account)
}
.store(in: &cancellables)
loadAccount() Task {
await loadAccount()
}
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions // disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController { if let nav = navigationController {
@ -100,182 +105,217 @@ class ProfileViewController: UIPageViewController {
} }
} }
override func viewDidAppear(_ animated: Bool) { private func loadAccount() async {
super.viewDidAppear(animated) guard let accountID else {
return
hasAppeared = true
} }
if let account = mastodonController.persistentContainer.account(for: accountID) {
private func loadAccount() { updateAccountUI(account: account)
guard let accountID = accountID else { return }
if mastodonController.persistentContainer.account(for: accountID) != nil {
updateAccountUI()
} else { } else {
do {
let req = Client.getAccount(id: accountID) let req = Client.getAccount(id: accountID)
mastodonController.run(req) { [weak self] (response) in let (account, _) = try await mastodonController.run(req)
guard let self = self else { return } let mo = await withCheckedContinuation { continuation in
switch response { mastodonController.persistentContainer.addOrUpdate(account: account, in: mastodonController.persistentContainer.viewContext) { (mo) in
case .success(let account, _): continuation.resume(returning: mo)
self.mastodonController.persistentContainer.addOrUpdate(account: account) { (account) in
DispatchQueue.main.async {
self.updateAccountUI()
} }
} }
self.updateAccountUI(account: mo)
case .failure(let error): } catch {
DispatchQueue.main.async { let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self.loadAccount() await self.loadAccount()
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
} }
} }
}
}
private func updateAccountUI() {
guard let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return
}
private func updateAccountUI(account: AccountMO) {
if let currentAccountID = mastodonController.accountInfo?.id { if let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
} }
// Optionally invoke updateUI on headerView because viewDidLoad may not have been called yet
headerView?.updateUI(for: accountID)
navigationItem.title = account.displayNameWithoutCustomEmoji navigationItem.title = account.displayNameWithoutCustomEmoji
// Only call updateUI on the individual page controllers if the account is loaded after the profile VC has appeared on screen.
// Otherwise, fi the page view controllers do something with the table view before they appear, the table view doesn't load
// its cells until the user begins to scroll.
if hasAppeared {
pageControllers.forEach {
$0.updateUI(account: account)
}
}
} }
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) { private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse guard case .idle = state else {
currentIndex = index return
}
headerView.pagesSegmentedControl.selectedSegmentIndex = index state = .animating
let direction: UIPageViewController.NavigationDirection
if currentIndex == nil || index - currentIndex > 0 {
direction = .forward
} else {
direction = .reverse
}
guard let old = viewControllers?.first as? ProfileStatusesViewController else { guard let old = viewControllers?.first as? ProfileStatusesViewController else {
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
// since it will be added in viewDidLoad pageControllers[index].initialHeaderMode = .createView
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion) setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
self.state = .idle
completion?(finished)
}
currentIndex = index
return return
} }
let new = pageControllers[index] let new = pageControllers[index]
let headerHeight = self.headerView.bounds.height currentIndex = index
// Store old's content offset so it can be transferred to new // TODO: old.headerCell could be nil if scrolled down and key command used
let prevOldContentOffset = old.tableView.contentOffset let oldHeaderCell = old.headerCell!
// Remove the header, inset the table content by the same amount, and adjust the offset so the cells don't move
old.tableView.tableHeaderView = nil
old.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
old.tableView.contentOffset.y -= headerHeight
// Add the header to ourself temporarily, and constrain it to the same position it was in // old header cell must have the header view
self.view.addSubview(self.headerView) let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top))
NSLayoutConstraint.activate([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
tempTopConstraint
])
// Setup the inset in new, in case it hasn't been already if new.isViewLoaded {
new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) _ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
// Match the scroll positions } else {
new.tableView.contentOffset = old.tableView.contentOffset new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
// Actually switch pages
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in
// Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation
DispatchQueue.main.async {
// Move the header to the new table view
new.tableView.tableHeaderView = self.headerView
// Remove the inset, and set the offset back to old's original one, prior to removing the header
new.tableView.contentInset = .zero
new.tableView.contentOffset = prevOldContentOffset
// Deactivate the top constraint, otherwise it sticks around
tempTopConstraint.isActive = false
// Re-add the width constraint since it was removed by re-parenting the view
// Why was the width constraint removed, but the top one not? Good question, I have no idea.
NSLayoutConstraint.activate([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor)
])
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
// if old was not scrolled all the way to the top
new.tableView.layoutIfNeeded()
let snapshot = new.dataSource.snapshot()
new.dataSource.apply(snapshot, animatingDifferences: false)
completion?(finished)
} }
// disable user interaction during animation, to avoid any potential weird race conditions
headerView.isUserInteractionEnabled = false
headerView.layer.zPosition = 100
view.addSubview(headerView)
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
// TODO: use safe area layout guide instead of manually adjusting this?
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
// hide scroll indicators during the transition because otherwise the show through the
// profile header, even though it has an opaque background
old.collectionView.showsVerticalScrollIndicator = false
if new.isViewLoaded {
new.collectionView.showsVerticalScrollIndicator = false
}
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
if animated,
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
// results in the collection view immediately removing cells that will be offscreen.
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
// rather than a gap appearing during it.
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
let origOldContentOffset = old.collectionView.contentOffset
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
snapshot.frame = old.collectionView.bounds
snapshot.frame.origin.y = 0
snapshot.layer.zPosition = 99
view.addSubview(snapshot)
// empirically, 0.3s seems to match the UIPageViewController animation
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
// animate the snapshot offscreen in the same direction as the old view
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
// animate the snapshot to be "scrolled" to top
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
if new.isViewLoaded {
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
}
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
} completion: { _ in
snapshot.removeFromSuperview()
}
} else if new.isViewLoaded {
new.collectionView.contentOffset = old.collectionView.contentOffset
}
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
// reenable scroll indicators after the switching animation is done
old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true
headerView.isUserInteractionEnabled = true
headerView.transform = .identity
headerView.layer.zPosition = 0
// move the header view into the new page controller's cell
// new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load
new.headerCell!.addHeader(headerView)
self.state = .idle
completion?(finished)
} }
} }
// MARK: Interaction // MARK: Interaction
@objc private func composeMentioning() { @objc private func composeMentioning() {
if let accountID = accountID, if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) { let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct) compose(mentioningAcct: account.acct)
} }
} }
private func composeDirectMentioning() { private func composeDirectMentioning() {
if let accountID = accountID, if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) { let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct) let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct draft.visibility = .direct
compose(editing: draft) compose(editing: draft)
} }
} }
}
extension ProfileViewController {
enum State {
case idle
case animating
}
} }
extension ProfileViewController: TuskerNavigationDelegate { extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
}
extension ProfileViewController: ToastableViewController {
} }
extension ProfileViewController: ProfileHeaderViewDelegate { extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) { func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
// disable user interaction on segmented control while switching pages to prevent guard case .idle = state else {
// race condition from trying to switch to multiple pages simultaneously return
view.pagesSegmentedControl.isUserInteractionEnabled = false
selectPage(at: newIndex, animated: true) { (finished) in
view.pagesSegmentedControl.isUserInteractionEnabled = true
} }
} selectPage(at: newIndex, animated: true)
}
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
pageControllers[currentIndex].tabBarScrollToTop()
} }
} }
extension ProfileViewController: TabbedPageViewController { extension ProfileViewController: TabbedPageViewController {
func selectNextPage() { func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return } guard currentIndex < pageControllers.count - 1 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
selectPage(at: currentIndex + 1, animated: true) selectPage(at: currentIndex + 1, animated: true)
} }
func selectPrevPage() { func selectPrevPage() {
guard currentIndex > 0 else { return } guard currentIndex > 0 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
selectPage(at: currentIndex - 1, animated: true) selectPage(at: currentIndex - 1, animated: true)
} }
} }
extension ProfileViewController: ToastableViewController { extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
currentViewController.tabBarScrollToTop()
}
}
extension ProfileViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
}
} }

View File

@ -289,6 +289,7 @@ extension SearchResultsViewController: UISearchBarDelegate {
} }
extension SearchResultsViewController: TuskerNavigationDelegate { extension SearchResultsViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
} }
extension SearchResultsViewController: ToastableViewController { extension SearchResultsViewController: ToastableViewController {
@ -298,7 +299,6 @@ extension SearchResultsViewController: MenuActionProvider {
} }
extension SearchResultsViewController: StatusTableViewCellDelegate { extension SearchResultsViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates() tableView.beginUpdates()
tableView.endUpdates() tableView.endUpdates()

View File

@ -319,7 +319,7 @@ extension SearchViewController: UICollectionViewDragDelegate {
} }
extension SearchViewController: TuskerNavigationDelegate { extension SearchViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension SearchViewController: ToastableViewController { extension SearchViewController: ToastableViewController {

View File

@ -145,6 +145,7 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
} }
extension StatusActionAccountListTableViewController: TuskerNavigationDelegate { extension StatusActionAccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
} }
extension StatusActionAccountListTableViewController: ToastableViewController { extension StatusActionAccountListTableViewController: ToastableViewController {
@ -154,7 +155,6 @@ extension StatusActionAccountListTableViewController: MenuActionProvider {
} }
extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate { extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights // causes the table view to recalculate the cell heights
tableView.beginUpdates() tableView.beginUpdates()

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class HashtagTimelineViewController: TimelineTableViewController { class HashtagTimelineViewController: TimelineViewController {
let hashtag: Hashtag let hashtag: Hashtag

View File

@ -7,13 +7,14 @@
// //
import UIKit import UIKit
import Pachyderm
protocol InstanceTimelineViewControllerDelegate: AnyObject { protocol InstanceTimelineViewControllerDelegate: AnyObject {
func didSaveInstance(url: URL) func didSaveInstance(url: URL)
func didUnsaveInstance(url: URL) func didUnsaveInstance(url: URL)
} }
class InstanceTimelineViewController: TimelineTableViewController { class InstanceTimelineViewController: TimelineViewController {
weak var delegate: InstanceTimelineViewControllerDelegate? weak var delegate: InstanceTimelineViewControllerDelegate?
@ -68,19 +69,15 @@ class InstanceTimelineViewController: TimelineTableViewController {
toggleSaveButton.title = toggleSaveButtonTitle toggleSaveButton.title = toggleSaveButtonTitle
} }
// MARK: - Table view data source override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath) as! TimelineStatusTableViewCell
cell.delegate = browsingEnabled ? self : nil cell.delegate = browsingEnabled ? self : nil
return cell cell.overrideMastodonController = mastodonController
cell.updateUI(statusID: id, state: state)
} }
// MARK: - Table view delegate override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard browsingEnabled else { return } guard browsingEnabled else { return }
super.tableView(tableView, didSelectRowAt: indexPath) super.collectionView(collectionView, didSelectItemAt: indexPath)
} }
// MARK: - Interaction // MARK: - Interaction

View File

@ -1,334 +0,0 @@
//
// TimelineTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 8/15/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
typealias TimelineEntry = (id: String, state: StatusState)
class TimelineTableViewController: DiffableTimelineLikeTableViewController<TimelineTableViewController.Section, TimelineTableViewController.Item> {
let timeline: Timeline
weak var mastodonController: MastodonController!
private var newer: RequestRange?
private var older: RequestRange?
private var didConfirmLoadMore = false
private var isShowingTimelineDescription = false
init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline
self.mastodonController = mastodonController
super.init()
dragEnabled = true
title = timeline.title
tabBarItem.image = timeline.tabBarImage
if let id = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: id)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ConfirmLoadMoreTableViewCell", bundle: .main), forCellReuseIdentifier: "confirmLoadMoreCell")
tableView.register(UINib(nibName: "PublicTimelineDescriptionTableViewCell", bundle: .main), forCellReuseIdentifier: "publicTimelineDescriptionCell")
if case let .public(local: local) = timeline,
(local && !Preferences.shared.hasShownLocalTimelineDescription) || (!local && !Preferences.shared.hasShownFederatedTimelineDescription) {
isShowingTimelineDescription = true
var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.header])
snapshot.appendItems([.publicTimelineDescription(local: local)], toSection: .header)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if case let .public(local: local) = timeline {
if local {
Preferences.shared.hasShownLocalTimelineDescription = true
} else {
Preferences.shared.hasShownFederatedTimelineDescription = true
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isShowingTimelineDescription {
isShowingTimelineDescription = false
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
// MARK: - DiffableTimelineLikeTableViewController
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh status command discoverability title")
}
override func timelineContentSections() -> [Section] {
return [.statuses]
}
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
switch item {
case .loadingIndicator:
return self.loadingIndicatorCell(indexPath: indexPath)
case let .status(id: id, state: state):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
case .confirmLoadMore:
let cell = tableView.dequeueReusableCell(withIdentifier: "confirmLoadMoreCell", for: indexPath) as! ConfirmLoadMoreTableViewCell
cell.confirmLoadMore = {
self.didConfirmLoadMore = true
self.loadOlder()
self.didConfirmLoadMore = false
}
return cell
case .publicTimelineDescription(local: let local):
let cell = tableView.dequeueReusableCell(withIdentifier: "publicTimelineDescriptionCell", for: indexPath) as! PublicTimelineDescriptionTableViewCell
cell.mastodonController = mastodonController
cell.local = local
cell.didDismiss = { [unowned self] in
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot)
}
return cell
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
guard let mastodonController = mastodonController else {
completion(.failure(.noClient))
return
}
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
if !statuses.isEmpty {
self.newer = .after(id: statuses.first!.id, count: nil)
self.older = .before(id: statuses.last!.id, count: nil)
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
if snapshot.sectionIdentifiers.contains(.loadingIndicator) {
snapshot.deleteSections([.loadingIndicator])
}
snapshot.deleteSections([.statuses, .footer])
snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
completion(.success(snapshot))
}
}
}
}
}
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
return
}
if Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
var snapshot = currentSnapshot()
guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
// todo: need something more accurate than "success"/"failure"
completion(.success(snapshot))
return
}
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
completion(.success(snapshot))
return
}
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
if !statuses.isEmpty {
self.older = .before(id: statuses.last!.id, count: nil)
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
snapshot.deleteItems([.confirmLoadMore])
completion(.success(snapshot))
}
}
}
}
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion(.failure(.noNewer))
return
}
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, _):
guard !statuses.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
self.newer = .after(id: statuses.first!.id, count: nil)
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot()
let newIdentifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(newIdentifiers, beforeItem: first)
} else {
snapshot.appendItems(newIdentifiers, toSection: .statuses)
}
completion(.success(snapshot))
}
}
}
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
super.scrollViewWillBeginDragging(scrollView)
if isShowingTimelineDescription {
var snapshot = self.dataSource.snapshot()
snapshot.deleteSections([.header])
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension TimelineTableViewController {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case header
case statuses
case footer
}
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState)
case confirmLoadMore
case publicTimelineDescription(local: Bool)
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
return a == b
case (.confirmLoadMore, .confirmLoadMore):
return true
case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)):
return a == b
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .loadingIndicator:
hasher.combine(0)
case let .status(id: id, state: _):
hasher.combine(1)
hasher.combine(id)
case .confirmLoadMore:
hasher.combine(2)
case let .publicTimelineDescription(local: local):
hasher.combine(3)
hasher.combine(local)
}
}
}
}
extension TimelineTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension TimelineTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}
extension TimelineTableViewController: MenuActionProvider {
}
extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
}
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
}
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -10,8 +10,6 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController {
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
@ -36,6 +34,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.controller = TimelineLikeController(delegate: self) self.controller = TimelineLikeController(delegate: self)
self.navigationItem.title = timeline.title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
} }
@ -61,8 +60,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} }
if case .status(_, _) = item { if case .status(_, _) = item {
config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
return config return config
} }
@ -86,16 +85,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
super.viewDidLoad() super.viewDidLoad()
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { // separate method because InstanceTimelineViewController needs to be able to customize it
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Item> { [unowned self] cell, indexPath, item in func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
guard case .status(id: let id, state: let state) = item,
let status = mastodonController.persistentContainer.status(for: id) else {
fatalError()
}
cell.mastodonController = mastodonController
cell.delegate = self cell.delegate = self
cell.updateUI(statusID: id, state: state) cell.updateUI(statusID: id, state: state)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1)
}
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else { guard case .public(let local) = timeline else {
fatalError() fatalError()
@ -108,8 +107,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case .status(_, _): case .status(id: let id, state: let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .loadingIndicator: case .loadingIndicator:
return loadingIndicatorCell(for: indexPath) return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore: case .confirmLoadMore:
@ -121,15 +120,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
private func applyInitialSnapshot() { // non-private, because ListTimelineViewController needs to be able to reload it from scratch
func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
if case .public(let local) = timeline, if case .public(let local) = timeline,
(local && !Preferences.shared.hasShownLocalTimelineDescription) || (local && !Preferences.shared.hasShownLocalTimelineDescription) ||
(!local && !Preferences.shared.hasShownFederatedTimelineDescription) { (!local && !Preferences.shared.hasShownFederatedTimelineDescription) {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.header]) snapshot.appendSections([.header])
snapshot.appendItems([.publicTimelineDescription], toSection: .header) snapshot.appendItems([.publicTimelineDescription], toSection: .header)
dataSource.apply(snapshot, animatingDifferences: false)
} }
dataSource.apply(snapshot, animatingDifferences: false)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -140,9 +140,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
Task { Task {
if case .notLoadedInitial = await controller.state {
await controller.loadInitial() await controller.loadInitial()
} }
} }
}
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
@ -160,7 +162,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
pruneOffscreenRows() // pruneOffscreenRows()
} }
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
@ -170,27 +172,28 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false isShowingTimelineDescription = false
} }
private func pruneOffscreenRows() { // private func pruneOffscreenRows() {
guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else { // guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
return // return
} // }
var snapshot = dataSource.snapshot() // var snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else { // guard snapshot.indexOfSection(.statuses) != nil else {
return // return
} // }
let items = snapshot.itemIdentifiers(inSection: .statuses) // let items = snapshot.itemIdentifiers(inSection: .statuses)
let pageSize = 20 // let pageSize = 20
let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize // let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize
if numberOfPagesToPrune > 0 { // if numberOfPagesToPrune > 0 {
let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize)) // let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
snapshot.deleteItems(itemsToRemove) // snapshot.deleteItems(itemsToRemove)
} //
dataSource.apply(snapshot, animatingDifferences: false) // dataSource.apply(snapshot, animatingDifferences: false)
//
if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last { // if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
older = .before(id: id, count: nil) // older = .before(id: id, count: nil)
} // }
} // }
// }
@objc func refresh() { @objc func refresh() {
Task { Task {
@ -254,7 +257,7 @@ extension TimelineViewController {
var hideSeparators: Bool { var hideSeparators: Bool {
switch self { switch self {
case .loadingIndicator, .publicTimelineDescription: case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore:
return true return true
default: default:
return false return false
@ -325,10 +328,12 @@ extension TimelineViewController {
let request = Client.getStatuses(timeline: timeline, range: older) let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty { guard !statuses.isEmpty else {
self.older = .before(id: statuses.last!.id, count: nil) return []
} }
self.older = .before(id: statuses.last!.id, count: nil)
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume(returning: statuses.map(\.id))
@ -346,8 +351,7 @@ extension TimelineViewController {
extension TimelineViewController: UICollectionViewDelegate { extension TimelineViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section), guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else {
return return
} }
@ -401,7 +405,7 @@ extension TimelineViewController: UICollectionViewDragDelegate {
} }
extension TimelineViewController: TuskerNavigationDelegate { extension TimelineViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController! { mastodonController }
} }
extension TimelineViewController: MenuActionProvider { extension TimelineViewController: MenuActionProvider {
@ -416,3 +420,16 @@ extension TimelineViewController: StatusCollectionViewCellDelegate {
} }
} }
} }
extension TimelineViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension TimelineViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -248,3 +248,12 @@ extension EnhancedNavigationViewController: BackgroundableViewController {
} }
} }
} }
extension EnhancedNavigationViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
if let topVC = topViewController as? StatusBarTappableViewController {
return topVC.handleStatusBarTapped(xPosition: xPosition)
}
return .continue
}
}

View File

@ -11,11 +11,6 @@ import SafariServices
class EnhancedTableViewController: UITableViewController { class EnhancedTableViewController: UITableViewController {
private var prevScrollToTopOffset: CGPoint? = nil
private(set) var isCurrentlyScrollingToTop = false
private var prevScrollViewContentOffset: CGPoint?
private(set) var scrollViewDirection: CGFloat = 0
var dragEnabled = false var dragEnabled = false
override func viewDidLoad() { override func viewDidLoad() {
@ -26,38 +21,6 @@ class EnhancedTableViewController: UITableViewController {
} }
} }
// MARK: Scroll View Delegate
override func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
if let offset = prevScrollToTopOffset {
tableView.setContentOffset(offset, animated: true)
prevScrollToTopOffset = nil
return false
} else {
prevScrollToTopOffset = tableView.contentOffset
isCurrentlyScrollingToTop = true
return true
}
}
override func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
isCurrentlyScrollingToTop = false
// add one so it's not technically scrolled all the way to the top,
// otherwise there's no way of detecting a status bar press to scroll back down
tableView.contentOffset.y -= 0.5
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
prevScrollToTopOffset = nil
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let prev = prevScrollViewContentOffset {
scrollViewDirection = scrollView.contentOffset.y - prev.y
}
prevScrollViewContentOffset = scrollView.contentOffset
}
// MARK: Table View Delegate // MARK: Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -117,10 +80,13 @@ extension EnhancedTableViewController: UITableViewDragDelegate {
extension EnhancedTableViewController: TabBarScrollableViewController { extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() { func tabBarScrollToTop() {
if scrollViewShouldScrollToTop(tableView) { tableView.scrollToTop()
let topOffset = CGPoint(x: 0, y: -tableView.adjustedContentInset.top) }
tableView.setContentOffset(topOffset, animated: true) }
scrollViewDidScrollToTop(tableView)
} extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
} }
} }

View File

@ -60,14 +60,25 @@ extension MenuActionProvider {
draft.visibility = .direct draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft) self.navigationDelegate?.compose(editing: draft)
}), }),
UIDeferredMenuElement.uncached({ (elementHandler) in UIDeferredMenuElement.uncached({ @MainActor [unowned self] elementHandler in
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID),
let action = self.followAction(for: mo, mastodonController: mastodonController) {
elementHandler([action])
} else {
Task { @MainActor in Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) { if let relationship = await relationship.value,
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
elementHandler([action]) elementHandler([action])
} else { } else {
elementHandler([]) elementHandler([])
} }
} }
}
}) })
] ]
@ -381,16 +392,13 @@ extension MenuActionProvider {
}) })
} }
private func followAction(for accountID: String, mastodonController: MastodonController) async -> UIMenuElement? { @MainActor
guard let ownAccount = try? await mastodonController.getOwnAccount(), private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
accountID != ownAccount.id else { guard let ownAccount = mastodonController.account,
return nil relationship.accountID != ownAccount.id else {
}
let request = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(request),
let relationship = relationships.first else {
return nil return nil
} }
let accountID = relationship.accountID
let following = relationship.following let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in
let request = (following ? Account.unfollow : Account.follow)(accountID) let request = (following ? Account.unfollow : Account.follow)(accountID)
@ -412,6 +420,19 @@ extension MenuActionProvider {
} }
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
let req = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(req),
let r = relationships.first else {
return nil
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addOrUpdate(relationship: r, in: mastodonController.persistentContainer.viewContext) { mo in
continuation.resume(returning: mo)
}
}
}
struct MenuPreviewHelper { struct MenuPreviewHelper {
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) { static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
if let viewController = animator.previewViewController { if let viewController = animator.previewViewController {

View File

@ -105,3 +105,12 @@ extension SegmentedPageViewController: BackgroundableViewController {
} }
} }
} }
extension SegmentedPageViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
if let current = pageControllers[currentIndex] as? StatusBarTappableViewController {
return current.handleStatusBarTapped(xPosition: xPosition)
}
return .continue
}
}

View File

@ -135,12 +135,6 @@ class SplitNavigationController: UIViewController {
updateSecondaryNavVisibility() updateSecondaryNavVisibility()
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
}
override func viewWillLayoutSubviews() { override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews() super.viewWillLayoutSubviews()
@ -245,7 +239,24 @@ class SplitNavigationController: UIViewController {
self.updateSecondaryNavVisibility() self.updateSecondaryNavVisibility()
} }
} }
}
extension SplitNavigationController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
let vcs = viewControllers
if !canShowSecondaryNav || vcs.count < 2 {
return (vcs.first! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
} else {
let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
if rootNav.view.bounds.contains(positionInRoot) {
return (rootNav.topViewController as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: positionInRoot.x) ?? .continue
} else if secondaryNav.view.bounds.contains(positionInSecondary) {
return (secondaryNav.topViewController as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: positionInRoot.x) ?? .continue
}
}
return .continue
}
} }
private class SplitRootNavigationController: UINavigationController { private class SplitRootNavigationController: UINavigationController {
@ -271,7 +282,8 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
override var next: UIResponder? { override var next: UIResponder? {
// 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
owner.viewControllers.first!.view // 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
} }
private func configureSecondarySplitCloseButton(for viewController: UIViewController) { private func configureSecondarySplitCloseButton(for viewController: UIViewController) {

View File

@ -0,0 +1,13 @@
//
// StatusBarTappableViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
protocol StatusBarTappableViewController: UIViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
}

View File

@ -30,22 +30,6 @@ extension StatusTablePrefetching {
} }
} }
func cancelPrefetchingStatuses(with ids: [String]) {
let context = apiController.persistentContainer.prefetchBackgroundContext
context.perform {
guard let statuses = getStatusesWith(ids: ids, in: context) else {
return
}
for status in statuses {
guard let avatar = status.account.avatar else { continue }
ImageCache.avatars.cancelWithoutCallback(avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
}
}
} }
fileprivate func getStatusesWith(ids: [String], in context: NSManagedObjectContext) -> [StatusMO]? { fileprivate func getStatusesWith(ids: [String], in context: NSManagedObjectContext) -> [StatusMO]? {

View File

@ -205,7 +205,7 @@ class UserActivityManager {
rootController.segmentedControl.selectedSegmentIndex = index rootController.segmentedControl.selectedSegmentIndex = index
rootController.selectPage(at: index, animated: false) rootController.selectPage(at: index, animated: false)
default: default:
let timeline = TimelineTableViewController(for: timeline, mastodonController: mastodonController) let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
navigationController.pushViewController(timeline, animated: false) navigationController.pushViewController(timeline, animated: false)
} }
} }

View File

@ -40,7 +40,7 @@ actor TimelineLikeController<Item> {
willSet { willSet {
guard state.canTransition(to: newValue) else { guard state.canTransition(to: newValue) else {
logger.error("State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)") logger.error("State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
preconditionFailure("cannot transition to state") fatalError("State \(state) cannot transition to \(newValue)")
} }
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)") logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
} }
@ -51,7 +51,7 @@ actor TimelineLikeController<Item> {
} }
func loadInitial() async { func loadInitial() async {
guard state == .notLoadedInitial else { guard state == .notLoadedInitial || state == .idle else {
return return
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
@ -65,6 +65,8 @@ actor TimelineLikeController<Item> {
await loadingIndicator.end() await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token)) await emit(event: .replaceAllItems(items, token))
state = .idle state = .idle
} catch is CancellationError {
return
} catch { } catch {
await loadingIndicator.end() await loadingIndicator.end()
await emit(event: .loadAllError(error, token)) await emit(event: .loadAllError(error, token))
@ -85,6 +87,8 @@ actor TimelineLikeController<Item> {
} }
await emit(event: .prependItems(items, token)) await emit(event: .prependItems(items, token))
state = .idle state = .idle
} catch is CancellationError {
return
} catch { } catch {
await emit(event: .loadNewerError(error, token)) await emit(event: .loadNewerError(error, token))
state = .idle state = .idle
@ -96,7 +100,11 @@ actor TimelineLikeController<Item> {
return return
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
guard await delegate.canLoadOlder() else { guard await delegate.canLoadOlder(),
// Make sure we're still in the idle state before continuing on, since that may have chnaged while waiting for user input.
// If the load more cell appears, then the users scrolls up and back down, the VC may kick off a second loadOlder task
// but we only want one to proceed. The actor prevents a data race, and this prevents multiple simultaneousl loadOlder tasks from running.
state == .idle else {
return return
} }
state = .loadingOlder(token, hasAddedLoadingIndicator: false) state = .loadingOlder(token, hasAddedLoadingIndicator: false)
@ -109,6 +117,8 @@ actor TimelineLikeController<Item> {
await loadingIndicator.end() await loadingIndicator.end()
await emit(event: .appendItems(items, token)) await emit(event: .appendItems(items, token))
state = .idle state = .idle
} catch is CancellationError {
return
} catch { } catch {
await loadingIndicator.end() await loadingIndicator.end()
await emit(event: .loadOlderError(error, token)) await emit(event: .loadOlderError(error, token))
@ -123,7 +133,7 @@ actor TimelineLikeController<Item> {
private func emit(event: Event) async { private func emit(event: Event) async {
guard state.canEmit(event: event) else { guard state.canEmit(event: event) else {
logger.error("State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)") logger.error("State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
preconditionFailure("state cannot emit event") fatalError("State \(state) cannot emit event: \(event)")
} }
switch event { switch event {
case .addLoadingIndicator: case .addLoadingIndicator:
@ -171,14 +181,14 @@ actor TimelineLikeController<Item> {
switch self { switch self {
case .notLoadedInitial: case .notLoadedInitial:
switch to { switch to {
case .loadingInitial(_, hasAddedLoadingIndicator: _): case .loadingInitial(_, _):
return true return true
default: default:
return false return false
} }
case .idle: case .idle:
switch to { switch to {
case .loadingNewer(_), .loadingOlder(_, _): case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
return true return true
default: default:
return false return false

View File

@ -11,7 +11,7 @@ import SafariServices
import Pachyderm import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController { get } var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
} }
@ -28,7 +28,7 @@ extension TuskerNavigationDelegate {
func selected(account accountID: String) { func selected(account accountID: String) {
// don't open if the account is the same as the current one // don't open if the account is the same as the current one
if let profileController = self as? ProfileViewController, if let profileController = self as? ProfileStatusesViewController,
profileController.accountID == accountID { profileController.accountID == accountID {
return return
} }
@ -104,7 +104,6 @@ extension TuskerNavigationDelegate {
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
DraftsManager.shared.add(draft)
compose(editing: draft) compose(editing: draft)
} }

View File

@ -2,17 +2,9 @@
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift /// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
import UIKit import UIKit
// blurhashes are disabled in debug builds because this code is hideously slow when not optimized by the compiler
#if DEBUG
fileprivate let blurHashesEnabled = ProcessInfo.processInfo.environment.keys.contains("DEBUG_BLUR_HASH")
#else
fileprivate let blurHashesEnabled = true
#endif
extension UIImage { extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHashesEnabled, guard blurHash.count >= 6 else { return nil }
blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83() let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1 let numY = (sizeFlag / 9) + 1

View File

@ -26,7 +26,6 @@ class AttachmentView: GIFImageView {
var attachment: Attachment! var attachment: Attachment!
var index: Int! var index: Int!
var expectedSize: CGSize!
private var attachmentRequest: ImageCache.Request? private var attachmentRequest: ImageCache.Request?
private var source: Source? private var source: Source?
@ -37,13 +36,12 @@ class AttachmentView: GIFImageView {
private var isGrayscale = false private var isGrayscale = false
init(attachment: Attachment, index: Int, expectedSize: CGSize) { init(attachment: Attachment, index: Int) {
super.init(image: nil) super.init(image: nil)
commonInit() commonInit()
self.attachment = attachment self.attachment = attachment
self.index = index self.index = index
self.expectedSize = expectedSize
loadAttachment() loadAttachment()
} }
@ -106,24 +104,14 @@ class AttachmentView: GIFImageView {
func loadAttachment() { func loadAttachment() {
guard AttachmentsContainerView.supportedAttachmentTypes.contains(attachment.kind) else { guard AttachmentsContainerView.supportedAttachmentTypes.contains(attachment.kind) else {
preconditionFailure("invalid attachment type") fatalError("invalid attachment type")
} }
if let hash = attachment.blurHash { if let hash = attachment.blurHash {
AttachmentView.queue.async { [weak self] in AttachmentView.queue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let size: CGSize
if let meta = self.attachment.meta,
let width = meta.width, let height = meta.height {
size = CGSize(width: width, height: height)
} else if let orig = self.attachment.meta?.original,
let width = orig.width, let height = orig.height {
size = CGSize(width: width, height: height)
} else {
size = self.expectedSize
}
guard var preview = UIImage(blurHash: hash, size: size) else { guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) else {
return return
} }
@ -149,7 +137,28 @@ class AttachmentView: GIFImageView {
case .gifv: case .gifv:
loadGifv() loadGifv()
default: default:
preconditionFailure("invalid attachment type") fatalError("invalid attachment type")
}
}
private func blurHashSize() -> CGSize {
if let meta = self.attachment.meta {
let aspectRatio: CGFloat
if let width = meta.width, let height = meta.height {
aspectRatio = CGFloat(width) / CGFloat(height)
} else if let orig = meta.original,
let width = orig.width, let height = orig.height {
aspectRatio = CGFloat(width) / CGFloat(height)
} else {
return CGSize(width: 32, height: 32)
}
if aspectRatio > 1 {
return CGSize(width: 32, height: 32 / aspectRatio)
} else {
return CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
return CGSize(width: 32, height: 32)
} }
} }

View File

@ -257,7 +257,7 @@ class AttachmentsContainerView: UIView {
} }
let size = CGSize(width: width, height: height) let size = CGSize(width: width, height: height)
let attachmentView = AttachmentView(attachment: attachments[index], index: index, expectedSize: size) let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate attachmentView.delegate = delegate
attachmentView.translatesAutoresizingMaskIntoConstraints = false attachmentView.translatesAutoresizingMaskIntoConstraints = false
attachmentView.accessibilityLabel = String(format: NSLocalizedString("Attachment %d", comment: "attachment at index accessiblity label"), index + 1) attachmentView.accessibilityLabel = String(format: NSLocalizedString("Attachment %d", comment: "attachment at index accessiblity label"), index + 1)

View File

@ -48,7 +48,7 @@ class InstanceTableViewCell: UITableViewCell {
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
adultLabel.isHidden = true adultLabel.isHidden = true
descriptionTextView.setTextFromHtml(instance.description) descriptionTextView.setTextFromHtml(instance.shortDescription ?? instance.description)
if let thumbnail = instance.thumbnail { if let thumbnail = instance.thumbnail {
updateThumbnail(url: thumbnail) updateThumbnail(url: thumbnail)

View File

@ -119,12 +119,12 @@ class ProfileHeaderView: UIView {
let request = Client.getRelationships(accounts: [accountID]) let request = Client.getRelationships(accounts: [accountID])
mastodonController.run(request) { [weak self] (response) in mastodonController.run(request) { [weak self] (response) in
guard let self = self, guard let mastodonController = self?.mastodonController,
case let .success(results, _) = response, case let .success(results, _) = response,
let relationship = results.first else { let relationship = results.first else {
return return
} }
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
} }
} }

View File

@ -168,16 +168,19 @@ class StatusCardView: UIView {
private func loadBlurHash() { private func loadBlurHash() {
guard let card = card, let hash = card.blurhash else { return } guard let card = card, let hash = card.blurhash else { return }
let imageViewSize = self.imageView.bounds.size
AttachmentView.queue.async { [weak self] in AttachmentView.queue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let size: CGSize let size: CGSize
if let width = card.width, let height = card.height { if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height) let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else { } else {
size = imageViewSize size = CGSize(width: 32 * aspectRatio, height: 32)
}
} else {
size = CGSize(width: 32, height: 32)
} }
guard let preview = UIImage(blurHash: hash, size: size) else { guard let preview = UIImage(blurHash: hash, size: size) else {

View File

@ -29,9 +29,8 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var reblogButton: UIButton { get } var reblogButton: UIButton { get }
var moreButton: UIButton { get } var moreButton: UIButton { get }
// TODO: why is one of these ! and the other ?
var mastodonController: MastodonController! { get }
var delegate: StatusCollectionViewCellDelegate? { get } var delegate: StatusCollectionViewCellDelegate? { get }
var mastodonController: MastodonController! { get }
var showStatusAutomatically: Bool { get } var showStatusAutomatically: Bool { get }
var showReplyIndicator: Bool { get } var showReplyIndicator: Bool { get }
@ -50,6 +49,8 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
extension StatusCollectionViewCell { extension StatusCollectionViewCell {
static var avatarImageViewSize: CGFloat { 50 } static var avatarImageViewSize: CGFloat { 50 }
var mastodonController: MastodonController! { delegate?.apiController }
func baseCreateObservers() { func baseCreateObservers() {
mastodonController.persistentContainer.statusSubject mastodonController.persistentContainer.statusSubject
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -75,8 +76,6 @@ extension StatusCollectionViewCell {
} }
func doUpdateUI(status: StatusMO) { func doUpdateUI(status: StatusMO) {
precondition(delegate != nil, "StatusCollectionViewCell must have delegate")
statusID = status.id statusID = status.id
accountID = status.account.id accountID = status.account.id

View File

@ -12,12 +12,15 @@ import Combine
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
// MARK: Subviews // MARK: Subviews
private lazy var reblogLabel = EmojiLabel().configure { private lazy var reblogLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel $0.textColor = .secondaryLabel
// this needs to have a higher priorty than the content container's zero height constraint // this needs to have a higher priorty than the content container's zero height constraint
$0.setContentHuggingPriority(.defaultHigh, for: .vertical) $0.setContentHuggingPriority(.defaultHigh, for: .vertical)
$0.isUserInteractionEnabled = true
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
} }
@ -51,6 +54,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.isUserInteractionEnabled = true $0.isUserInteractionEnabled = true
$0.addInteraction(UIContextMenuInteraction(delegate: self)) $0.addInteraction(UIContextMenuInteraction(delegate: self))
$0.addInteraction(UIDragInteraction(delegate: self)) $0.addInteraction(UIDragInteraction(delegate: self))
$0.addInteraction(UIPointerInteraction(delegate: self))
$0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed)))
} }
@ -113,6 +117,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
private(set) lazy var contentWarningLabel = EmojiLabel().configure { private(set) lazy var contentWarningLabel = EmojiLabel().configure {
$0.numberOfLines = 0
$0.textColor = .secondaryLabel $0.textColor = .secondaryLabel
$0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [ .traits: [
@ -190,21 +195,29 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private(set) lazy var replyButton = UIButton().configure { private(set) lazy var replyButton = UIButton().configure {
$0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal) $0.setImage(UIImage(systemName: "arrowshape.turn.up.left.fill"), for: .normal)
$0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(replyPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var favoriteButton = UIButton().configure { private(set) lazy var favoriteButton = UIButton().configure {
$0.setImage(UIImage(systemName: "star.fill"), for: .normal) $0.setImage(UIImage(systemName: "star.fill"), for: .normal)
$0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside) $0.addTarget(self, action: #selector(favoritePressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
} }
private(set) lazy var reblogButton = UIButton().configure { private(set) lazy var reblogButton = UIButton().configure {
$0.setImage(UIImage(systemName: "repeat"), for: .normal) $0.setImage(UIImage(systemName: "repeat"), for: .normal)
$0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(reblogPressed), for: .touchUpInside)
$0.addInteraction(UIPointerInteraction(delegate: self))
} }
let moreButton = UIButton().configure { private(set) lazy var moreButton = UIButton().configure {
$0.setImage(UIImage(systemName: "ellipsis"), for: .normal) $0.setImage(UIImage(systemName: "ellipsis"), for: .normal)
$0.showsMenuAsPrimaryAction = true $0.showsMenuAsPrimaryAction = true
$0.addInteraction(UIPointerInteraction(delegate: self))
}
private var actionButtons: [UIButton] {
[replyButton, favoriteButton, reblogButton, moreButton]
} }
// MARK: Cell state // MARK: Cell state
@ -214,7 +227,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private var mainContainerBottomToActionsConstraint: NSLayoutConstraint! private var mainContainerBottomToActionsConstraint: NSLayoutConstraint!
private var mainContainerBottomToSelfConstraint: NSLayoutConstraint! private var mainContainerBottomToSelfConstraint: NSLayoutConstraint!
weak var mastodonController: MastodonController! weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController! { overrideMastodonController ?? delegate?.apiController }
weak var delegate: StatusCollectionViewCellDelegate? weak var delegate: StatusCollectionViewCellDelegate?
var showStatusAutomatically: Bool { var showStatusAutomatically: Bool {
// TODO: needed once conversation controller refactored // TODO: needed once conversation controller refactored
@ -224,10 +238,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// TODO: needed once conversation controller refactored // TODO: needed once conversation controller refactored
true true
} }
var showPinned: Bool { var showPinned: Bool = false
// TODO: needed once profile controller refactored
false
}
// alas these need to be internal so they're accessible from the protocol extensions // alas these need to be internal so they're accessible from the protocol extensions
var statusID: String! var statusID: String!
@ -252,7 +263,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6) let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6)
@ -612,3 +623,31 @@ extension TimelineStatusCollectionViewCell: UIDragInteractionDelegate {
return dragItemsForAccount() return dragItemsForAccount()
} }
} }
extension TimelineStatusCollectionViewCell: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
if interaction.view === avatarImageView {
return defaultRegion
} else if let button = interaction.view as? UIButton,
actionButtons.contains(button) {
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
rect = rect.insetBy(dx: -24, dy: -24)
return UIPointerRegion(rect: rect)
}
return nil
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
if interaction.view === avatarImageView {
let preview = UITargetedPreview(view: avatarImageView)
return UIPointerStyle(effect: .lift(preview))
} else if let button = interaction.view as? UIButton,
actionButtons.contains(button) {
let preview = UITargetedPreview(view: button.imageView!)
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
rect = rect.insetBy(dx: -8, dy: -8)
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
}
return nil
}
}

View File

@ -1,13 +0,0 @@
//
// XCBSessionType.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
enum XCBSessionType {
case postStatus
}