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
MyPlayground.playground/

View File

@ -1,5 +1,45 @@
# 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)
Bugfixes:
- Fix selecting reblogged statuses in the timeline

View File

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

View File

@ -10,7 +10,6 @@
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041160FE22B442870030A9B7 /* LoadingLargeImageViewController.swift */; };
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033722B30F5F000D31B6 /* BehaviorPrefsView.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 */; };
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.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 */; };
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.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 */; };
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.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 */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.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 */; };
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; };
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
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 */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.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 */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
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 */; };
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
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 */; };
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
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 */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.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 */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.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 */; };
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.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 */; };
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -380,7 +380,6 @@
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>"; };
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>"; };
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>"; };
@ -391,11 +390,14 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -446,13 +448,15 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -475,7 +479,6 @@
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>"; };
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>"; };
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>"; };
@ -682,10 +685,10 @@
buildActionMask = 2147483647;
files = (
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -877,6 +880,17 @@
path = CoreData;
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 */ = {
isa = PBXGroup;
children = (
@ -911,7 +925,6 @@
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
);
@ -943,9 +956,10 @@
D641C784213DD819004B4513 /* Profile */ = {
isa = PBXGroup;
children = (
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */,
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -1019,7 +1033,6 @@
D68015412401A74600D6103B /* MediaPrefsView.swift */,
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */,
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
);
path = Preferences;
@ -1141,6 +1154,7 @@
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1318,6 +1332,7 @@
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
@ -1343,6 +1358,7 @@
D6D4DDC3212518A000E1C4BB = {
isa = PBXGroup;
children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
@ -1372,14 +1388,11 @@
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */,
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
@ -1397,6 +1410,7 @@
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D63CC70A2910AAC6000E19DE /* Scenes */,
D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
@ -1469,7 +1483,6 @@
D6F2E960249E772F005846BB /* Crash Reporter */ = {
isa = PBXGroup;
children = (
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */,
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */,
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */,
);
@ -1501,7 +1514,7 @@
D6F953E52125197500CF0F2B /* Embed Frameworks */,
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */,
D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */,
D6F1F9E127B0677000CB7D88 /* ShellScript */,
D63CC704290EC913000E19DE /* ShellScript */,
);
buildRules = (
);
@ -1510,11 +1523,11 @@
);
name = Tusker;
packageProductDependencies = (
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */,
);
productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1621,10 +1634,10 @@
);
mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = (
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
);
productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */;
projectDirPath = "";
@ -1701,6 +1714,26 @@
/* End PBXResourcesBuildPhase 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 */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@ -1723,24 +1756,6 @@
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";
};
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 */
/* Begin PBXSourcesBuildPhase section */
@ -1781,6 +1796,7 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
@ -1811,7 +1827,7 @@
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
@ -1832,6 +1848,7 @@
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
@ -1858,6 +1875,7 @@
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
@ -1873,6 +1891,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
@ -1953,6 +1972,7 @@
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
@ -1972,13 +1992,10 @@
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
@ -1991,7 +2008,6 @@
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6114E0927F3EA3D0080E273 /* CrashReporterViewController.swift in Sources */,
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
@ -2087,6 +2103,161 @@
/* End PBXVariantGroup 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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -2217,7 +2388,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2246,7 +2417,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2356,7 +2527,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2383,7 +2554,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2411,6 +2582,7 @@
buildConfigurations = (
D6D4DDF2212518A200E1C4BB /* Debug */,
D6D4DDF3212518A200E1C4BB /* Release */,
D63CC705290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2420,6 +2592,7 @@
buildConfigurations = (
D6D4DDF5212518A200E1C4BB /* Debug */,
D6D4DDF6212518A200E1C4BB /* Release */,
D63CC706290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2429,6 +2602,7 @@
buildConfigurations = (
D6D4DDF8212518A200E1C4BB /* Debug */,
D6D4DDF9212518A200E1C4BB /* Release */,
D63CC707290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2438,6 +2612,7 @@
buildConfigurations = (
D6D4DDFB212518A200E1C4BB /* Debug */,
D6D4DDFC212518A200E1C4BB /* Release */,
D63CC708290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2447,6 +2622,7 @@
buildConfigurations = (
D6E343B7265AAD6B00C4AA01 /* Debug */,
D6E343B8265AAD6B00C4AA01 /* Release */,
D63CC709290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2462,6 +2638,14 @@
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" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kylebshr/ScreenCorners";
@ -2478,14 +2662,6 @@
kind = branch;
};
};
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/microsoft/plcrashreporter";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 1.8.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -2498,6 +2674,11 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
D63CC701290EC0B8000E19DE /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
D6552366289870790048A653 /* ScreenCorners */ = {
isa = XCSwiftPackageProductDependency;
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
@ -2512,11 +2693,6 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
D69CCBBE249E6EFD000AF167 /* CrashReporter */ = {
isa = XCSwiftPackageProductDependency;
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;
productName = CrashReporter;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View File

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

View File

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

View File

@ -68,7 +68,7 @@ class MastodonController: ObservableObject {
}
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
switch response {
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.

View File

@ -7,22 +7,19 @@
//
import UIKit
import CrashReporter
import CoreData
import OSLog
import Sentry
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
static private(set) var crashReporter: PLCrashReporter!
static var pendingCrashReport: PLCrashReport?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if !DEBUG
setupCrashReporter()
#endif
configureSentry()
swizzleStatusBar()
AppShortcutItem.createItems(for: application)
@ -52,18 +49,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
private func setupCrashReporter() {
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
if AppDelegate.crashReporter.hasPendingCrashReport(),
let data = try? AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError(),
let report = try? PLCrashReport(data: data) {
AppDelegate.crashReporter.purgePendingCrashReport()
AppDelegate.pendingCrashReport = report
private func configureSentry() {
guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String,
!dsn.isEmpty else {
return
}
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) {
@ -113,4 +129,28 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
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 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 {
self.defaultExpiry = defaultExpiry
@ -117,7 +117,9 @@ class DiskCache<T> {
func removeAll() throws {
try fileManager.removeItem(atPath: path)
try createDirectory()
fileStates.removeAll()
fileStates.withLock { dict in
dict.removeAll()
}
}
}

View File

@ -24,10 +24,6 @@ class ImageCache {
private let cache: ImageDataCache
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) {
// 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))
@ -41,15 +37,19 @@ class ImageCache {
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion {
wrappedCompletion = { (data, image) in
if !loadOriginal,
let size = self.desiredPixelSize {
image?.prepareThumbnail(of: size, completionHandler: {
completion(data, $0)
})
} else {
image?.prepareForDisplay {
completion(data, $0)
if let image {
if !loadOriginal,
let size = self.desiredPixelSize {
image.prepareThumbnail(of: size) {
completion(data, $0)
}
} else {
image.prepareForDisplay {
completion(data, $0)
}
}
} else {
completion(data, image)
}
}
} else {
@ -61,14 +61,9 @@ class ImageCache {
wrappedCompletion?(entry.data, entry.image)
return nil
} else {
if let group = groups[url] {
return group.addCallback(wrappedCompletion)
} else {
let group = createGroup(url: url)
let request = group.addCallback(wrappedCompletion)
group.run()
return request
}
let task = dataTask(url: url, completion: wrappedCompletion)
task.resume()
return task
}
}
@ -85,22 +80,23 @@ class ImageCache {
// if caching is disabled, don't bother fetching since nothing will be done with the result
guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false),
!groups.contains(key: url) {
let group = createGroup(url: url)
group.run()
if !((try? cache.has(url.absoluteString)) ?? false) {
let task = dataTask(url: url) { data, image in
guard let data else { return }
try? self.cache.set(url.absoluteString, data: data, image: image)
}
task.resume()
}
}
private func createGroup(url: URL) -> RequestGroup {
let group = RequestGroup(url: url) { (data, image) in
if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image)
private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask {
return URLSession.shared.dataTask(with: url) { data, response, error in
guard error == nil,
let data else {
return
}
_ = self.groups.removeValue(forKey: url)
completion?(data, UIImage(data: data))
}
groups[url] = group
return group
}
func getData(_ url: URL) -> Data? {
@ -111,87 +107,10 @@ class ImageCache {
return try? cache.get(url.absoluteString, loadOriginal: loadOriginal)
}
func cancelWithoutCallback(_ url: URL) {
groups[url]?.cancelWithoutCallback()
}
func reset() throws {
try cache.removeAll()
}
private class RequestGroup {
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()
}
}
typealias Request = URLSessionDataTask
}

View File

@ -11,6 +11,7 @@ import CoreData
import Pachyderm
import Combine
import OSLog
import Sentry
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
@ -35,6 +36,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
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 accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>()
@ -70,6 +74,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
try context.save()
} catch {
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")
}
}
@ -144,19 +151,20 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
@discardableResult
private func upsert(account: Account) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
if let accountMO = self.account(for: account.id, in: context) {
accountMO.updateFrom(apiAccount: account, container: self)
return accountMO
} 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) {
backgroundContext.perform {
let accountMO = self.upsert(account: account)
self.save(context: self.backgroundContext)
func addOrUpdate(account: Account, in context: NSManagedObjectContext? = nil, completion: ((AccountMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let accountMO = self.upsert(account: account, in: context)
self.save(context: context)
completion?(accountMO)
self.accountSubject.send(account.id)
}
@ -175,20 +183,21 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
@discardableResult
private func upsert(relationship: Relationship) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) {
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext)
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: context)
return relationshipMO
}
}
func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) {
backgroundContext.perform {
let relationshipMO = self.upsert(relationship: relationship)
self.save(context: self.backgroundContext)
func addOrUpdate(relationship: Relationship, in context: NSManagedObjectContext? = nil, completion: ((RelationshipMO) -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let relationshipMO = self.upsert(relationship: relationship, in: context)
self.save(context: context)
completion?(relationshipMO)
self.relationshipSubject.send(relationship.id)
}
@ -196,7 +205,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0) }
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext)
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
@ -210,7 +219,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
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)
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
@ -224,7 +233,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
var updatedStatuses = [String]()
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 })
}, { (statuses) in
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">
<plist version="1.0">
<array>
<string>counter\.social</string>
<string>gab\..+</string>
</array>
</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">
<plist version="1.0">
<dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>OSLogPreferences</key>
<dict>
<key>space.vaccor.Tusker</key>

View File

@ -17,12 +17,13 @@ struct Logging {
static func getLogData() -> Data? {
do {
let store = try OSLogStore(scope: .currentProcessIdentifier)
// past hour
let position = store.position(date: Date().addingTimeInterval(-60 * 60))
let entries = try store.getEntries(at: position, matching: NSPredicate(format: "subsystem = %@", Bundle.main.bundleIdentifier!))
// do the filtering ourself, passing position/predicate into getEntries is far slower (priority inversion, I think)
let entries = try store.getEntries()
var data = Data()
let subsystem = Bundle.main.bundleIdentifier!
for entry in entries {
guard let entry = entry as? OSLogEntryLog else {
guard let entry = entry as? OSLogEntryLog,
entry.subsystem == subsystem else {
continue
}
data.append(contentsOf: entry.date.formatted(.iso8601).utf8)
@ -39,4 +40,23 @@ struct Logging {
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.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
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.initialText = try container.decode(String.self, forKey: .initialText)

View File

@ -34,21 +34,39 @@ class DraftsManager: Codable {
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] {
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
}
func add(_ draft: Draft) {
drafts.append(draft)
drafts[draft.id] = draft
}
func remove(_ draft: Draft) {
drafts.removeAll { $0 == draft }
drafts.removeValue(forKey: draft.id)
}
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]>
init() {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: [:])
self.lock = LockHolder(withLock: lock.withLock(_:))
} else {
let lock = UnfairLock(initialState: [:])
self.lock = LockHolder(withLock: lock.withLock(_:))
}
self.lock = LockHolder(initialState: [:])
}
subscript(key: Key) -> Value? {
@ -66,6 +60,16 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
// see #178
fileprivate struct LockHolder<State> {
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
init(initialState: State) {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} else {
let lock = UnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
}
}
}
// 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.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.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(hideDiscover, forKey: .hideDiscover)
try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
@ -150,8 +148,8 @@ class Preferences: Codable, ObservableObject {
@Published var hideDiscover = false
// MARK: Advanced
@Published var silentActions: [String: Permission] = [:]
@Published var statusContentType: StatusContentType = .plain
@Published var reportErrorsAutomatically = true
// MARK:
@Published var hasShownLocalTimelineDescription = false
@ -188,7 +186,6 @@ class Preferences: Codable, ObservableObject {
case disableInfiniteScrolling
case hideDiscover
case silentActions
case statusContentType
case hasShownLocalTimelineDescription
@ -197,10 +194,4 @@ class Preferences: Codable, ObservableObject {
}
extension Preferences {
enum Permission: String, Codable {
case undecided, accepted, rejected
}
}
extension UIUserInterfaceStyle: Codable {}

View File

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

View File

@ -8,11 +8,10 @@
import UIKit
import Pachyderm
import CrashReporter
import MessageUI
import CoreData
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
var window: UIWindow?
@ -32,15 +31,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
if let report = AppDelegate.pendingCrashReport {
AppDelegate.pendingCrashReport = nil
handlePendingCrashReport(report, session: session)
} else {
showAppOrOnboardingUI(session: session)
if connectionOptions.urlContexts.count > 0 {
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
}
}
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) {
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 {
var apiController: MastodonController { mastodonController }
var apiController: MastodonController! { mastodonController }
}
extension AccountListTableViewController: ToastableViewController {

View File

@ -154,6 +154,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
}
extension BookmarksTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension BookmarksTableViewController: ToastableViewController {
@ -163,8 +164,6 @@ extension BookmarksTableViewController: MenuActionProvider {
}
extension BookmarksTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates()
tableView.endUpdates()
@ -176,14 +175,4 @@ extension BookmarksTableViewController: UITableViewDataSourcePrefetching, Status
let ids = indexPaths.map { statuses[$0.row].id }
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
func body(content: Content) -> some View {
let _ = print("isPresented: \(isPresented)")
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {

View File

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

View File

@ -11,7 +11,23 @@ import Pachyderm
struct MainComposeTextView: View {
@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
@State private var height: CGFloat?

View File

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

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 CrashReporter
import MessageUI
import OSLog
@ -26,7 +25,7 @@ class IssueReporterViewController: UIViewController {
let reportText: String
let reportFilename: String
private let dismiss: () -> Void
private let doDismiss: () -> Void
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."
@ -43,7 +42,7 @@ class IssueReporterViewController: UIViewController {
init(reportText: String, reportFilename: String, dismiss: @escaping () -> Void) {
self.reportText = reportText
self.reportFilename = reportFilename
self.dismiss = dismiss
self.doDismiss = dismiss
self.logDataTask = Task(priority: .userInitiated) {
return await withCheckedContinuation({ continuation in
@ -118,6 +117,7 @@ class IssueReporterViewController: UIViewController {
@IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
sendReportButton.isEnabled = false
Task {
let composeVC = MFMailComposeViewController()
@ -128,12 +128,13 @@ class IssueReporterViewController: UIViewController {
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
if let logData = await logDataTask.value {
let timestamp = ISO8601DateFormatter().string(from: Date())
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: "Tusker-\(timestamp).log")
if let (logData, name) = await getLogData() {
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: name)
}
self.present(composeVC, animated: true)
sendReportButton.isEnabled = true
}
}
@ -142,11 +143,24 @@ class IssueReporterViewController: UIViewController {
let url = dir.appendingPathComponent(reportFilename)
try! reportText.data(using: .utf8)!.write(to: url)
let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil)
activityController.popoverPresentationController?.sourceView = sendReportButton
present(activityController, animated: true)
}
@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 {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
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 {
var apiController: MastodonController { mastodonController }
var apiController: MastodonController! { mastodonController }
}
extension ProfileDirectoryViewController: ToastableViewController {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,14 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
// MARK: - Interaction
@objc func cancelButtonPressed() {
dismiss(animated: true)
// 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)
}
}
}

View File

@ -14,6 +14,7 @@ import VisionKit
protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get }
func setControlsVisible(_ controlsVisible: Bool)
func grayscaleStateChanged()
}
@ -22,6 +23,9 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
#if !targetEnvironment(macCatalyst)
@available(iOS 16.0, *)
private static let analyzer = ImageAnalyzer()
private var _analysisInteraction: AnyObject?
@available(iOS 16.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
#endif
var animationImage: UIImage? { image! }
@ -45,6 +49,7 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
if #available(iOS 16.0, *),
ImageAnalyzer.isSupported {
let interaction = ImageAnalysisInteraction()
self._analysisInteraction = interaction
interaction.delegate = self
interaction.preferredInteractionTypes = .automatic
addInteraction(interaction)
@ -64,6 +69,17 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
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() {
guard let data = sourceData else {
return
@ -113,6 +129,9 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
}
func grayscaleStateChanged() {
// todo
}
@ -151,6 +170,9 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
}
func grayscaleStateChanged() {
// no-op, GifvAttachmentView observes the grayscale state itself
}

View File

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

View File

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

View File

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

View File

@ -111,6 +111,12 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
loadViewIfNeeded()
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 {

View File

@ -456,6 +456,22 @@ extension MainSplitViewController: TuskerRootViewController {
func presentPreferences(completion: (() -> Void)?) {
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 {

View File

@ -285,6 +285,16 @@ extension MainTabBarViewController: TuskerRootViewController {
func presentPreferences(completion: (() -> Void)?) {
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 {

View File

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

View File

@ -269,7 +269,7 @@ extension NotificationsTableViewController {
}
extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
var apiController: MastodonController! { mastodonController }
}
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 {
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?
var dataSource: DataSource!
@ -100,7 +106,7 @@ class InstanceSelectorTableViewController: UITableViewController {
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
var input = input
var components = URLComponents()
@ -125,13 +131,24 @@ class InstanceSelectorTableViewController: UITableViewController {
components.port = Int(parts.last!)
}
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
}
private func updateSpecificInstance(domain: String) {
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 client = Client(baseURL: url, session: .appDefault)

View File

@ -17,12 +17,6 @@ protocol OnboardingViewControllerDelegate {
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 instanceSelector = InstanceSelectorTableViewController()

View File

@ -7,6 +7,7 @@
import SwiftUI
import Pachyderm
import CoreData
struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared
@ -14,7 +15,7 @@ struct AdvancedPrefsView : View {
var body: some View {
List {
formattingSection
automationSection
errorReportingSection
cachingSection
}
.listStyle(InsetGroupedListStyle())
@ -22,7 +23,17 @@ struct AdvancedPrefsView : 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 {
@ -36,10 +47,18 @@ struct AdvancedPrefsView : View {
}
}
var automationSection: some View {
Section(header: Text("Automation")) {
NavigationLink(destination: SilentActionPrefs()) {
Text("Silent Action Permissions")
var errorReportingSection: some View {
Section {
Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)
} 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() {
for account in LocalData.shared.accounts {
let controller = MastodonController.getForAccount(account)
let coordinator = controller.persistentContainer.persistentStoreCoordinator
for store in coordinator.persistentStores {
try! coordinator.destroyPersistentStore(at: store.url!, ofType: store.type, options: store.options)
let container = controller.persistentContainer
do {
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()

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
// Tusker
//
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 10/6/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<ProfileStatusesViewController.Section, ProfileStatusesViewController.Item> {
weak var mastodonController: MastodonController!
private(set) var headerView: ProfileHeaderView!
var accountID: String!
class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController {
weak var owner: ProfileViewController?
let mastodonController: MastodonController
private(set) var accountID: String!
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 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.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) {
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() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
// setup the initial snapshot with the sections in the right order, so we don't have to worry about order later
var snapshot = Snapshot()
snapshot.appendSections([.pinned, .statuses])
dataSource.apply(snapshot, animatingDifferences: false)
}
func updateUI(account: AccountMO) {
if isViewLoaded {
reloadInitial()
}
}
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")
}
// MARK: - DiffableTimelineLikeTableViewController
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, pinned: pinned):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
cell.showPinned = pinned
cell.updateUI(statusID: id, state: state)
return cell
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
guard accountID != nil else {
completion(.failure(.noClient))
return
}
getStatuses { (response) in
guard self.state == .loadingInitial else {
return
}
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()
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))
}
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] id in
switch state {
case .unloaded:
Task {
await load()
}
}
}
}
}
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard kind == .statuses,
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
}
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)
mastodonController.run(request, completion: completion)
}
override func refresh() {
super.refresh()
// 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(_):
case .loading:
break
case let .success(snapshot):
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
case .loaded, .setupInitialSnapshot:
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
.store(in: &cancellables)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.showPinned = item.2
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
}
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 viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
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
}
state = .loading
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
}
private func tryLoadPinned() async {
do {
try await loadPinned()
} catch {
let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
toast.dismissToast(animated: true)
await self.tryLoadPinned()
}
self.showToast(configuration: config, animated: true)
}
}
private func loadPinned() async throws {
guard case .statuses = kind,
mastodonController.instanceFeatures.profilePinnedStatuses else {
return
}
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
let (statuses, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
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 {
enum Kind {
case statuses, withReplies, onlyMedia
}
enum HeaderMode {
case createView, placeholder(height: CGFloat)
}
}
extension ProfileStatusesViewController {
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
enum Section: TimelineLikeCollectionViewSection {
case header
case pinned
case statuses
}
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case status(id: String, state: StatusState, pinned: Bool)
case footer
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 {
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:
return nil
case .status(id: let id, state: _, pinned: _):
return id
hasher.combine(2)
case .confirmLoadMore:
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: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
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: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
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 {
var apiController: MastodonController! { mastodonController }
}
extension ProfileStatusesViewController: MenuActionProvider {
}
extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
}
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
cancelPrefetchingStatuses(with: ids)
extension ProfileStatusesViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension ProfileStatusesViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -2,8 +2,8 @@
// ProfileViewController.swift
// Tusker
//
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 10/10/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
@ -18,39 +18,43 @@ class ProfileViewController: UIPageViewController {
// when first constructed. It should never be set to nil.
var accountID: String? {
willSet {
if newValue == nil {
fatalError("Do not set ProfileViewController.accountID to nil")
}
precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
}
didSet {
pageControllers.forEach { $0.accountID = accountID }
loadAccount()
pageControllers.forEach { $0.setAccountID(accountID!) }
Task {
await loadAccount()
}
}
}
private var accountUpdater: Cancellable?
private(set) var currentIndex: Int!
let pageControllers: [ProfileStatusesViewController]
private var pageControllers: [ProfileStatusesViewController]!
var currentViewController: ProfileStatusesViewController {
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) {
self.accountID = accountID
self.mastodonController = mastodonController
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.pageControllers = [
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController)
.init(accountID: accountID, kind: .statuses, owner: self),
.init(accountID: accountID, kind: .withReplies, owner: self),
.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) {
@ -62,35 +66,36 @@ class ProfileViewController: UIPageViewController {
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))
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in
self?.composeDirectMentioning()
composeButton.menu = UIMenu(children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
self.composeDirectMentioning()
})
])
composeButton.isEnabled = mastodonController.loggedIn
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.nextSubTabCommand)
accountUpdater = mastodonController.persistentContainer.accountSubject
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateAccountUI() }
.filter { [unowned self] in $0 == self.accountID }
.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
if let nav = navigationController {
@ -100,182 +105,217 @@ class ProfileViewController: UIPageViewController {
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
hasAppeared = true
}
private func loadAccount() {
guard let accountID = accountID else { return }
if mastodonController.persistentContainer.account(for: accountID) != nil {
updateAccountUI()
} else {
let req = Client.getAccount(id: accountID)
mastodonController.run(req) { [weak self] (response) in
guard let self = self else { return }
switch response {
case .success(let account, _):
self.mastodonController.persistentContainer.addOrUpdate(account: account) { (account) in
DispatchQueue.main.async {
self.updateAccountUI()
}
}
case .failure(let error):
DispatchQueue.main.async {
let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
toast.dismissToast(animated: true)
self.loadAccount()
}
self.showToast(configuration: config, animated: true)
}
}
}
}
}
private func updateAccountUI() {
guard let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
private func loadAccount() async {
guard let accountID else {
return
}
if let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
}
// Optionally invoke updateUI on headerView because viewDidLoad may not have been called yet
headerView?.updateUI(for: accountID)
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)
if let account = mastodonController.persistentContainer.account(for: accountID) {
updateAccountUI(account: account)
} else {
do {
let req = Client.getAccount(id: accountID)
let (account, _) = try await mastodonController.run(req)
let mo = await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addOrUpdate(account: account, in: mastodonController.persistentContainer.viewContext) { (mo) in
continuation.resume(returning: mo)
}
}
self.updateAccountUI(account: mo)
} catch {
let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadAccount()
}
self.showToast(configuration: config, animated: true)
}
}
}
private func updateAccountUI(account: AccountMO) {
if let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
}
navigationItem.title = account.displayNameWithoutCustomEmoji
}
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse
currentIndex = index
guard case .idle = state else {
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 {
// 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
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion)
pageControllers[index].initialHeaderMode = .createView
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
self.state = .idle
completion?(finished)
}
currentIndex = index
return
}
let new = pageControllers[index]
let headerHeight = self.headerView.bounds.height
currentIndex = index
// Store old's content offset so it can be transferred to new
let prevOldContentOffset = old.tableView.contentOffset
// 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
// TODO: old.headerCell could be nil if scrolled down and key command used
let oldHeaderCell = old.headerCell!
// Add the header to ourself temporarily, and constrain it to the same position it was in
self.view.addSubview(self.headerView)
let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top))
// old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
if new.isViewLoaded {
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
} else {
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
}
// 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([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
tempTopConstraint
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
// Setup the inset in new, in case it hasn't been already
new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
// Match the scroll positions
new.tableView.contentOffset = old.tableView.contentOffset
// 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
}
// 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
// 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)
// 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)
])
snapshot.frame = old.collectionView.bounds
snapshot.frame.origin.y = 0
snapshot.layer.zPosition = 99
view.addSubview(snapshot)
// 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)
// 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
@objc private func composeMentioning() {
if let accountID = accountID,
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let accountID = accountID,
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)
}
}
}
extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
// disable user interaction on segmented control while switching pages to prevent
// race condition from trying to switch to multiple pages simultaneously
view.pagesSegmentedControl.isUserInteractionEnabled = false
selectPage(at: newIndex, animated: true) { (finished) in
view.pagesSegmentedControl.isUserInteractionEnabled = true
}
extension ProfileViewController {
enum State {
case idle
case animating
}
}
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
pageControllers[currentIndex].tabBarScrollToTop()
extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ProfileViewController: ToastableViewController {
}
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
guard case .idle = state else {
return
}
selectPage(at: newIndex, animated: true)
}
}
extension ProfileViewController: TabbedPageViewController {
func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
selectPage(at: currentIndex + 1, animated: true)
}
func selectPrevPage() {
guard currentIndex > 0 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
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 {
var apiController: MastodonController! { mastodonController }
}
extension SearchResultsViewController: ToastableViewController {
@ -298,7 +299,6 @@ extension SearchResultsViewController: MenuActionProvider {
}
extension SearchResultsViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates()
tableView.endUpdates()

View File

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

View File

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

View File

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

View File

@ -7,13 +7,14 @@
//
import UIKit
import Pachyderm
protocol InstanceTimelineViewControllerDelegate: AnyObject {
func didSaveInstance(url: URL)
func didUnsaveInstance(url: URL)
}
class InstanceTimelineViewController: TimelineTableViewController {
class InstanceTimelineViewController: TimelineViewController {
weak var delegate: InstanceTimelineViewControllerDelegate?
@ -68,19 +69,15 @@ class InstanceTimelineViewController: TimelineTableViewController {
toggleSaveButton.title = toggleSaveButtonTitle
}
// MARK: - Table view data source
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = super.tableView(tableView, cellForRowAt: indexPath) as! TimelineStatusTableViewCell
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
cell.delegate = browsingEnabled ? self : nil
return cell
cell.overrideMastodonController = mastodonController
cell.updateUI(statusID: id, state: state)
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard browsingEnabled else { return }
super.tableView(tableView, didSelectRowAt: indexPath)
super.collectionView(collectionView, didSelectItemAt: indexPath)
}
// 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 Combine
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
@ -36,6 +34,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.controller = TimelineLikeController(delegate: self)
self.navigationItem.title = timeline.title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
}
@ -61,8 +60,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _) = item {
config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
@ -86,15 +85,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
super.viewDidLoad()
}
// separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
cell.delegate = self
cell.updateUI(statusID: id, state: state)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
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.updateUI(statusID: id, state: state)
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
guard case .public(let local) = timeline else {
@ -108,8 +107,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(_, _):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier)
case .status(id: let id, state: let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
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,
(local && !Preferences.shared.hasShownLocalTimelineDescription) ||
(!local && !Preferences.shared.hasShownFederatedTimelineDescription) {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.header])
snapshot.appendItems([.publicTimelineDescription], toSection: .header)
dataSource.apply(snapshot, animatingDifferences: false)
}
dataSource.apply(snapshot, animatingDifferences: false)
}
override func viewWillAppear(_ animated: Bool) {
@ -140,7 +140,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
Task {
await controller.loadInitial()
if case .notLoadedInitial = await controller.state {
await controller.loadInitial()
}
}
}
@ -160,7 +162,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pruneOffscreenRows()
// pruneOffscreenRows()
}
private func removeTimelineDescriptionCell() {
@ -170,27 +172,28 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false
}
private func pruneOffscreenRows() {
guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
return
}
var snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else {
return
}
let items = snapshot.itemIdentifiers(inSection: .statuses)
let pageSize = 20
let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize
if numberOfPagesToPrune > 0 {
let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
snapshot.deleteItems(itemsToRemove)
}
dataSource.apply(snapshot, animatingDifferences: false)
if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
older = .before(id: id, count: nil)
}
}
// private func pruneOffscreenRows() {
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
// return
// }
// var snapshot = dataSource.snapshot()
// guard snapshot.indexOfSection(.statuses) != nil else {
// return
// }
// let items = snapshot.itemIdentifiers(inSection: .statuses)
// let pageSize = 20
// let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize
// if numberOfPagesToPrune > 0 {
// let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
// snapshot.deleteItems(itemsToRemove)
//
// dataSource.apply(snapshot, animatingDifferences: false)
//
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
// older = .before(id: id, count: nil)
// }
// }
// }
@objc func refresh() {
Task {
@ -254,7 +257,7 @@ extension TimelineViewController {
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .publicTimelineDescription:
case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore:
return true
default:
return false
@ -325,10 +328,12 @@ extension TimelineViewController {
let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty {
self.older = .before(id: statuses.last!.id, count: nil)
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))
@ -346,8 +351,7 @@ extension TimelineViewController {
extension TimelineViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section),
case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
@ -401,7 +405,7 @@ extension TimelineViewController: UICollectionViewDragDelegate {
}
extension TimelineViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
var apiController: MastodonController! { mastodonController }
}
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 {
private var prevScrollToTopOffset: CGPoint? = nil
private(set) var isCurrentlyScrollingToTop = false
private var prevScrollViewContentOffset: CGPoint?
private(set) var scrollViewDirection: CGFloat = 0
var dragEnabled = false
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
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -117,10 +80,13 @@ extension EnhancedTableViewController: UITableViewDragDelegate {
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
if scrollViewShouldScrollToTop(tableView) {
let topOffset = CGPoint(x: 0, y: -tableView.adjustedContentInset.top)
tableView.setContentOffset(topOffset, animated: true)
scrollViewDidScrollToTop(tableView)
}
tableView.scrollToTop()
}
}
extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
}
}

View File

@ -60,12 +60,23 @@ extension MenuActionProvider {
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
}),
UIDeferredMenuElement.uncached({ (elementHandler) in
Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
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
if let relationship = await relationship.value,
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
}
}
})
@ -381,16 +392,13 @@ extension MenuActionProvider {
})
}
private func followAction(for accountID: String, mastodonController: MastodonController) async -> UIMenuElement? {
guard let ownAccount = try? await mastodonController.getOwnAccount(),
accountID != ownAccount.id else {
return nil
}
let request = Client.getRelationships(accounts: [accountID])
guard let (relationships, _) = try? await mastodonController.run(request),
let relationship = relationships.first else {
@MainActor
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
guard let ownAccount = mastodonController.account,
relationship.accountID != ownAccount.id else {
return nil
}
let accountID = relationship.accountID
let following = relationship.following
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)
@ -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 {
static func willPerformPreviewAction(animator: UIContextMenuInteractionCommitAnimating, presenter: UIViewController) {
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()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
@ -245,7 +239,24 @@ class SplitNavigationController: UIViewController {
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 {
@ -271,7 +282,8 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
override var next: UIResponder? {
// 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
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) {

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]? {

View File

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

View File

@ -40,7 +40,7 @@ actor TimelineLikeController<Item> {
willSet {
guard state.canTransition(to: newValue) else {
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)")
}
@ -51,7 +51,7 @@ actor TimelineLikeController<Item> {
}
func loadInitial() async {
guard state == .notLoadedInitial else {
guard state == .notLoadedInitial || state == .idle else {
return
}
let token = LoadAttemptToken()
@ -65,6 +65,8 @@ actor TimelineLikeController<Item> {
await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token))
state = .idle
} catch is CancellationError {
return
} catch {
await loadingIndicator.end()
await emit(event: .loadAllError(error, token))
@ -85,6 +87,8 @@ actor TimelineLikeController<Item> {
}
await emit(event: .prependItems(items, token))
state = .idle
} catch is CancellationError {
return
} catch {
await emit(event: .loadNewerError(error, token))
state = .idle
@ -96,7 +100,11 @@ actor TimelineLikeController<Item> {
return
}
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
}
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
@ -109,6 +117,8 @@ actor TimelineLikeController<Item> {
await loadingIndicator.end()
await emit(event: .appendItems(items, token))
state = .idle
} catch is CancellationError {
return
} catch {
await loadingIndicator.end()
await emit(event: .loadOlderError(error, token))
@ -123,7 +133,7 @@ actor TimelineLikeController<Item> {
private func emit(event: Event) async {
guard state.canEmit(event: event) else {
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 {
case .addLoadingIndicator:
@ -171,14 +181,14 @@ actor TimelineLikeController<Item> {
switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, hasAddedLoadingIndicator: _):
case .loadingInitial(_, _):
return true
default:
return false
}
case .idle:
switch to {
case .loadingNewer(_), .loadingOlder(_, _):
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
return true
default:
return false

View File

@ -11,7 +11,7 @@ import SafariServices
import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController { get }
var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
}
@ -28,7 +28,7 @@ extension TuskerNavigationDelegate {
func selected(account accountID: String) {
// 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 {
return
}
@ -104,7 +104,6 @@ extension TuskerNavigationDelegate {
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
DraftsManager.shared.add(draft)
compose(editing: draft)
}

View File

@ -2,17 +2,9 @@
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
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 {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHashesEnabled,
blurHash.count >= 6 else { return nil }
guard blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1

View File

@ -26,7 +26,6 @@ class AttachmentView: GIFImageView {
var attachment: Attachment!
var index: Int!
var expectedSize: CGSize!
private var attachmentRequest: ImageCache.Request?
private var source: Source?
@ -37,13 +36,12 @@ class AttachmentView: GIFImageView {
private var isGrayscale = false
init(attachment: Attachment, index: Int, expectedSize: CGSize) {
init(attachment: Attachment, index: Int) {
super.init(image: nil)
commonInit()
self.attachment = attachment
self.index = index
self.expectedSize = expectedSize
loadAttachment()
}
@ -106,24 +104,14 @@ class AttachmentView: GIFImageView {
func loadAttachment() {
guard AttachmentsContainerView.supportedAttachmentTypes.contains(attachment.kind) else {
preconditionFailure("invalid attachment type")
fatalError("invalid attachment type")
}
if let hash = attachment.blurHash {
AttachmentView.queue.async { [weak self] in
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
}
@ -149,7 +137,28 @@ class AttachmentView: GIFImageView {
case .gifv:
loadGifv()
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 attachmentView = AttachmentView(attachment: attachments[index], index: index, expectedSize: size)
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate
attachmentView.translatesAutoresizingMaskIntoConstraints = false
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
adultLabel.isHidden = true
descriptionTextView.setTextFromHtml(instance.description)
descriptionTextView.setTextFromHtml(instance.shortDescription ?? instance.description)
if let thumbnail = instance.thumbnail {
updateThumbnail(url: thumbnail)

View File

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

View File

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

View File

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

View File

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