Compare commits

..

No commits in common. "b38c24b3474821a0de050dfa4945c39e774147b2" and "22022f5ef65fb3aad8feb09dbdaaaf3302c426e2" have entirely different histories.

80 changed files with 1329 additions and 1681 deletions

1
.gitignore vendored
View File

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

View File

@ -1,45 +1,5 @@
# 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,7 +12,6 @@ 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]
@ -41,7 +40,6 @@ 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) {
@ -74,7 +72,6 @@ public class Instance: Decodable {
case uri
case title
case description
case shortDescription = "short_description"
case email
case version
case urls

View File

@ -10,6 +10,7 @@
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 */; };
@ -25,6 +26,7 @@
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 */; };
@ -35,15 +37,12 @@
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 */; };
@ -94,15 +93,13 @@
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 */; };
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 */; };
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.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 */; };
@ -125,6 +122,7 @@
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 */; };
@ -197,6 +195,7 @@
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 */; };
@ -366,6 +365,7 @@
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,6 +380,7 @@
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>"; };
@ -390,14 +391,11 @@
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>"; };
@ -448,15 +446,13 @@
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>"; };
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>"; };
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.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>"; };
@ -479,6 +475,7 @@
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>"; };
@ -685,10 +682,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;
};
@ -880,17 +877,6 @@
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 = (
@ -925,6 +911,7 @@
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
);
@ -956,10 +943,9 @@
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>";
@ -1033,6 +1019,7 @@
D68015412401A74600D6103B /* MediaPrefsView.swift */,
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */,
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
);
path = Preferences;
@ -1154,7 +1141,6 @@
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1332,7 +1318,6 @@
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
@ -1358,7 +1343,6 @@
D6D4DDC3212518A000E1C4BB = {
isa = PBXGroup;
children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
@ -1388,11 +1372,14 @@
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 */,
@ -1410,7 +1397,6 @@
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D63CC70A2910AAC6000E19DE /* Scenes */,
D641C780213DD7C4004B4513 /* Screens */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
@ -1483,6 +1469,7 @@
D6F2E960249E772F005846BB /* Crash Reporter */ = {
isa = PBXGroup;
children = (
D6114E0827F3EA3D0080E273 /* CrashReporterViewController.swift */,
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */,
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */,
);
@ -1514,7 +1501,7 @@
D6F953E52125197500CF0F2B /* Embed Frameworks */,
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */,
D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */,
D63CC704290EC913000E19DE /* ShellScript */,
D6F1F9E127B0677000CB7D88 /* ShellScript */,
);
buildRules = (
);
@ -1523,11 +1510,11 @@
);
name = Tusker;
packageProductDependencies = (
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */,
);
productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1634,10 +1621,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 = "";
@ -1714,26 +1701,6 @@
/* 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;
@ -1756,6 +1723,24 @@
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 */
@ -1796,7 +1781,6 @@
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 */,
@ -1827,7 +1811,7 @@
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
@ -1848,7 +1832,6 @@
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 */,
@ -1875,7 +1858,6 @@
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 */,
@ -1891,7 +1873,6 @@
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 */,
@ -1972,7 +1953,6 @@
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 */,
@ -1992,10 +1972,13 @@
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 */,
@ -2008,6 +1991,7 @@
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 */,
@ -2103,161 +2087,6 @@
/* 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 = {
@ -2388,7 +2217,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2417,7 +2246,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2527,7 +2356,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2554,7 +2383,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2582,7 +2411,6 @@
buildConfigurations = (
D6D4DDF2212518A200E1C4BB /* Debug */,
D6D4DDF3212518A200E1C4BB /* Release */,
D63CC705290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2592,7 +2420,6 @@
buildConfigurations = (
D6D4DDF5212518A200E1C4BB /* Debug */,
D6D4DDF6212518A200E1C4BB /* Release */,
D63CC706290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2602,7 +2429,6 @@
buildConfigurations = (
D6D4DDF8212518A200E1C4BB /* Debug */,
D6D4DDF9212518A200E1C4BB /* Release */,
D63CC707290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2612,7 +2438,6 @@
buildConfigurations = (
D6D4DDFB212518A200E1C4BB /* Debug */,
D6D4DDFC212518A200E1C4BB /* Release */,
D63CC708290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2622,7 +2447,6 @@
buildConfigurations = (
D6E343B7265AAD6B00C4AA01 /* Debug */,
D6E343B8265AAD6B00C4AA01 /* Release */,
D63CC709290ECE77000E19DE /* Dist */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -2638,14 +2462,6 @@
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";
@ -2662,6 +2478,14 @@
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 */
@ -2674,11 +2498,6 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
D63CC701290EC0B8000E19DE /* Sentry */ = {
isa = XCSwiftPackageProductDependency;
package = D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
productName = Sentry;
};
D6552366289870790048A653 /* ScreenCorners */ = {
isa = XCSwiftPackageProductDependency;
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
@ -2693,6 +2512,11 @@
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;
};
D69CCBBE249E6EFD000AF167 /* CrashReporter */ = {
isa = XCSwiftPackageProductDependency;
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;
productName = CrashReporter;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View File

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

View File

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

View File

@ -7,19 +7,22 @@
//
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 {
configureSentry()
swizzleStatusBar()
#if !DEBUG
setupCrashReporter()
#endif
AppShortcutItem.createItems(for: application)
@ -49,37 +52,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
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
// 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
}
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
}
AppDelegate.crashReporter.enable()
}
override func buildMenu(with builder: UIMenuBuilder) {
@ -129,28 +113,4 @@ 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

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

View File

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

View File

@ -24,6 +24,10 @@ 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))
@ -37,19 +41,15 @@ class ImageCache {
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion {
wrappedCompletion = { (data, image) in
if let image {
if !loadOriginal,
let size = self.desiredPixelSize {
image.prepareThumbnail(of: size) {
completion(data, $0)
}
} else {
image.prepareForDisplay {
completion(data, $0)
}
}
if !loadOriginal,
let size = self.desiredPixelSize {
image?.prepareThumbnail(of: size, completionHandler: {
completion(data, $0)
})
} else {
completion(data, image)
image?.prepareForDisplay {
completion(data, $0)
}
}
}
} else {
@ -61,9 +61,14 @@ class ImageCache {
wrappedCompletion?(entry.data, entry.image)
return nil
} else {
let task = dataTask(url: url, completion: wrappedCompletion)
task.resume()
return task
if let group = groups[url] {
return group.addCallback(wrappedCompletion)
} else {
let group = createGroup(url: url)
let request = group.addCallback(wrappedCompletion)
group.run()
return request
}
}
}
@ -80,23 +85,22 @@ 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) {
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()
if !((try? cache.has(url.absoluteString)) ?? false),
!groups.contains(key: url) {
let group = createGroup(url: url)
group.run()
}
}
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
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)
}
completion?(data, UIImage(data: data))
_ = self.groups.removeValue(forKey: url)
}
groups[url] = group
return group
}
func getData(_ url: URL) -> Data? {
@ -106,11 +110,88 @@ class ImageCache {
func get(_ url: URL, loadOriginal: Bool = false) -> ImageDataCache.Entry? {
return try? cache.get(url.absoluteString, loadOriginal: loadOriginal)
}
func cancelWithoutCallback(_ url: URL) {
groups[url]?.cancelWithoutCallback()
}
func reset() throws {
try cache.removeAll()
}
typealias Request = URLSessionDataTask
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()
}
}
}

View File

@ -11,7 +11,6 @@ import CoreData
import Pachyderm
import Combine
import OSLog
import Sentry
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
@ -36,9 +35,6 @@ 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>()
@ -74,9 +70,6 @@ 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")
}
}
@ -151,20 +144,19 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
@discardableResult
private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
if let accountMO = self.account(for: account.id, in: context) {
private func upsert(account: Account) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self)
return accountMO
} else {
return AccountMO(apiAccount: account, container: self, context: context)
return AccountMO(apiAccount: account, container: self, 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)
func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform {
let accountMO = self.upsert(account: account)
self.save(context: self.backgroundContext)
completion?(accountMO)
self.accountSubject.send(account.id)
}
@ -183,21 +175,20 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
@discardableResult
private func upsert(relationship: Relationship, in context: NSManagedObjectContext) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: context) {
private func upsert(relationship: Relationship) -> RelationshipMO {
if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) {
relationshipMO.updateFrom(apiRelationship: relationship, container: self)
return relationshipMO
} else {
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: context)
let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext)
return relationshipMO
}
}
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)
func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) {
backgroundContext.perform {
let relationshipMO = self.upsert(relationship: relationship)
self.save(context: self.backgroundContext)
completion?(relationshipMO)
self.relationshipSubject.send(relationship.id)
}
@ -205,7 +196,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0) }
self.save(context: self.backgroundContext)
completion?()
accounts.forEach { self.accountSubject.send($0.id) }
@ -219,7 +210,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, in: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0) }
self.save(context: self.backgroundContext)
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
@ -233,7 +224,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0) }
updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }

View File

@ -2,7 +2,6 @@
<!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

@ -1,45 +0,0 @@
//
// 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,8 +2,6 @@
<!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,13 +17,12 @@ struct Logging {
static func getLogData() -> Data? {
do {
let store = try OSLogStore(scope: .currentProcessIdentifier)
// do the filtering ourself, passing position/predicate into getEntries is far slower (priority inversion, I think)
let entries = try store.getEntries()
// 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!))
var data = Data()
let subsystem = Bundle.main.bundleIdentifier!
for entry in entries {
guard let entry = entry as? OSLogEntryLog,
entry.subsystem == subsystem else {
guard let entry = entry as? OSLogEntryLog else {
continue
}
data.append(contentsOf: entry.date.formatted(.iso8601).utf8)
@ -40,23 +39,4 @@ 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

@ -8,10 +8,11 @@
import UIKit
import Pachyderm
import CrashReporter
import MessageUI
import CoreData
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
@ -31,10 +32,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
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()
@ -136,6 +142,19 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
}
}
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

@ -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,39 +34,21 @@ class DraftsManager: Codable {
private init() {}
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 drafts: [Draft] = []
var sorted: [Draft] {
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
return drafts.sorted(by: { $0.lastModified > $1.lastModified })
}
func add(_ draft: Draft) {
drafts[draft.id] = draft
drafts.append(draft)
}
func remove(_ draft: Draft) {
drafts.removeValue(forKey: draft.id)
drafts.removeAll { $0 == draft }
}
func getBy(id: UUID) -> Draft? {
return drafts[id]
}
enum CodingKeys: String, CodingKey {
case drafts
return drafts.first { $0.id == id }
}
}

View File

@ -16,7 +16,13 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: LockHolder<[AnyHashable: Any]>
init() {
self.lock = LockHolder(initialState: [:])
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(_:))
}
}
subscript(key: Key) -> Value? {
@ -60,16 +66,6 @@ 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,6 +67,7 @@ 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
@ -106,6 +107,7 @@ 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)
@ -148,8 +150,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
@ -186,6 +188,7 @@ class Preferences: Codable, ObservableObject {
case disableInfiniteScrolling
case hideDiscover
case silentActions
case statusContentType
case hasShownLocalTimelineDescription
@ -194,4 +197,10 @@ class Preferences: Codable, ObservableObject {
}
extension Preferences {
enum Permission: String, Codable {
case undecided, accepted, rejected
}
}
extension UIUserInterfaceStyle: Codable {}

View File

@ -1,30 +0,0 @@
//
// 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,7 +154,6 @@ class BookmarksTableViewController: EnhancedTableViewController {
}
extension BookmarksTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension BookmarksTableViewController: ToastableViewController {
@ -164,6 +163,8 @@ extension BookmarksTableViewController: MenuActionProvider {
}
extension BookmarksTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates()
tableView.endUpdates()
@ -175,4 +176,14 @@ 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,6 +234,7 @@ 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,7 +137,8 @@ struct ComposeView: View {
}
MainComposeTextView(
draft: draft
draft: draft,
placeholder: Text("What's on your mind?")
)
if let poll = draft.poll {

View File

@ -11,23 +11,7 @@ import Pachyderm
struct MainComposeTextView: View {
@ObservedObject var draft: Draft
@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 placeholder: Text
let minHeight: CGFloat = 150
@State private var height: CGFloat?

View File

@ -441,8 +441,6 @@ 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
@ -455,6 +453,7 @@ 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()
@ -467,6 +466,11 @@ 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

@ -0,0 +1,47 @@
//
// 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,6 +7,7 @@
//
import UIKit
import CrashReporter
import MessageUI
import OSLog
@ -25,7 +26,7 @@ class IssueReporterViewController: UIViewController {
let reportText: String
let reportFilename: String
private let doDismiss: () -> Void
private let dismiss: () -> 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."
@ -42,7 +43,7 @@ class IssueReporterViewController: UIViewController {
init(reportText: String, reportFilename: String, dismiss: @escaping () -> Void) {
self.reportText = reportText
self.reportFilename = reportFilename
self.doDismiss = dismiss
self.dismiss = dismiss
self.logDataTask = Task(priority: .userInitiated) {
return await withCheckedContinuation({ continuation in
@ -117,7 +118,6 @@ class IssueReporterViewController: UIViewController {
@IBAction func sendReportTouchUpInside(_ sender: Any) {
updateSendReportButtonColor(lightened: false, animate: true)
sendReportButton.isEnabled = false
Task {
let composeVC = MFMailComposeViewController()
@ -128,13 +128,12 @@ class IssueReporterViewController: UIViewController {
let data = reportText.data(using: .utf8)!
composeVC.addAttachmentData(data, mimeType: "text/plain", fileName: reportFilename)
if let (logData, name) = await getLogData() {
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: name)
if let logData = await logDataTask.value {
let timestamp = ISO8601DateFormatter().string(from: Date())
composeVC.addAttachmentData(logData, mimeType: "text/plain", fileName: "Tusker-\(timestamp).log")
}
self.present(composeVC, animated: true)
sendReportButton.isEnabled = true
}
}
@ -143,24 +142,11 @@ 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) {
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() {
dismiss()
}
}
@ -168,12 +154,7 @@ class IssueReporterViewController: UIViewController {
extension IssueReporterViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true) {
if result == .cancelled {
// don't dismiss ourself, to allowe the user to send the report a different way
} else {
self.finishedReport()
self.doDismiss()
}
self.dismiss()
}
}
}

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,19 +100,15 @@ 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 {
let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else {
size = CGSize(width: 32 * aspectRatio, height: 32)
}
size = CGSize(width: width, height: height)
} else {
size = CGSize(width: 32, height: 32)
size = imageViewSize
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}

View File

@ -143,17 +143,13 @@ 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 {
let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else {
size = CGSize(width: 32 * aspectRatio, height: 32)
}
size = CGSize(width: width, height: height)
} else {
size = CGSize(width: 32, height: 32)
size = imageViewSize
}
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,14 +37,7 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
// MARK: - Interaction
@objc func cancelButtonPressed() {
// when the search controller is active, dismiss exits it rather than dismissing ourself, so we have to dismiss twice
if searchController.isActive {
dismiss(animated: false) {
self.dismiss(animated: true)
}
} else {
dismiss(animated: true)
}
dismiss(animated: true)
}
}

View File

@ -14,7 +14,6 @@ import VisionKit
protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get }
func setControlsVisible(_ controlsVisible: Bool)
func grayscaleStateChanged()
}
@ -23,9 +22,6 @@ 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! }
@ -49,7 +45,6 @@ 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)
@ -69,17 +64,6 @@ 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
@ -129,9 +113,6 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
}
func grayscaleStateChanged() {
// todo
}
@ -170,9 +151,6 @@ 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,7 +174,6 @@ 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: TimelineViewController {
class ListTimelineViewController: TimelineTableViewController {
let list: List
@ -57,11 +57,8 @@ class ListTimelineViewController: TimelineViewController {
@objc func editListDoneButtonPressed() {
dismiss(animated: true)
// TODO: only reload if there were changes
Task {
applyInitialSnapshot()
await controller.loadInitial()
}
// todo: show loading indicator
reloadInitial()
}
}

View File

@ -111,12 +111,6 @@ 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,22 +456,6 @@ 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,16 +285,6 @@ 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, StatusBarTappableViewController {
protocol TuskerRootViewController: UIViewController {
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,4 +294,14 @@ 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,12 +18,6 @@ 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!
@ -106,7 +100,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()
@ -131,24 +125,13 @@ 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()
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 components = parseURLComponents(input: domain)
let url = components.url!
let client = Client(baseURL: url, session: .appDefault)

View File

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

View File

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

@ -1,70 +0,0 @@
//
// 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,487 +2,287 @@
// ProfileStatusesViewController.swift
// Tusker
//
// Created by Shadowfacts on 10/6/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController {
class ProfileStatusesViewController: DiffableTimelineLikeTableViewController<ProfileStatusesViewController.Section, ProfileStatusesViewController.Item> {
weak var mastodonController: MastodonController!
private(set) var headerView: ProfileHeaderView!
var accountID: String!
weak var owner: ProfileViewController?
let mastodonController: MastodonController
private(set) var accountID: String!
let kind: Kind
var initialHeaderMode: HeaderMode?
weak var profileHeaderDelegate: ProfileHeaderViewDelegate?
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange?
private var older: RequestRange?
private var cancellables = Set<AnyCancellable>()
private var newer: RequestRange?
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) {
init(accountID: String?, kind: Kind, mastodonController: MastodonController) {
self.accountID = accountID
self.kind = kind
self.owner = owner
self.mastodonController = owner.mastodonController
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
super.init()
self.controller = TimelineLikeController(delegate: self)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
dragEnabled = true
}
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()
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()
}
case .loading:
break
case .loaded, .setupInitialSnapshot:
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
.store(in: &cancellables)
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)
}
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
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 = item.2
cell.updateUI(statusID: item.0, state: item.1)
cell.showPinned = pinned
cell.updateUI(statusID: id, state: state)
return cell
}
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
}
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))
}
}
}
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,
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard kind == .statuses,
mastodonController.instanceFeatures.profilePinnedStatuses else {
completion(.success(snapshot()))
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()
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))
}
}
}
}
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
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion(.failure(.noOlder))
return
}
Task {
// TODO: coalesce these data source updates
// TODO: refresh profile
await controller.loadNewer()
await tryLoadPinned()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
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))
}
}
}
}
}
extension ProfileStatusesViewController {
enum State {
case unloaded
case loading
case setupInitialSnapshot
case loaded
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(_):
break
case let .success(snapshot):
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
}
}
}
}
}
extension ProfileStatusesViewController {
enum Kind {
case statuses, withReplies, onlyMedia
}
enum HeaderMode {
case createView, placeholder(height: CGFloat)
}
}
extension ProfileStatusesViewController {
enum Section: TimelineLikeCollectionViewSection {
case header
enum Section: DiffableTimelineLikeSection {
case loadingIndicator
case pinned
case statuses
case footer
static var entries: Self { .statuses }
}
enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String
case header(String)
case status(id: String, state: StatusState, pinned: Bool)
enum Item: DiffableTimelineLikeItem {
case loadingIndicator
case confirmLoadMore
case status(id: String, state: StatusState, pinned: Bool)
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) {
var id: String? {
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:
hasher.combine(2)
case .confirmLoadMore:
hasher.combine(3)
return nil
case .status(id: let id, state: _, pinned: _):
return id
}
}
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .confirmLoadMore:
return true
default:
return false
}
}
var isSelectable: Bool {
switch self {
case .status(id: _, state: _, pinned: _):
return true
default:
return false
}
}
}
}
extension ProfileStatusesViewController: TimelineLikeControllerDelegate {
typealias TimelineItem = String // status ID
private func request(for range: RequestRange = .default) -> Request<[Status]> {
switch kind {
case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
case .withReplies:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
case .onlyMedia:
return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
}
}
func loadInitial() async throws -> [String] {
let request = request()
let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil)
older = .before(id: statuses.last!.id, count: nil)
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadNewer() async throws -> [String] {
guard let newer else {
throw Error.noNewer
}
let request = request(for: newer)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
throw Error.allCaughtUp
}
self.newer = .after(id: statuses.first!.id, count: nil)
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
func loadOlder() async throws -> [String] {
guard let older else {
throw Error.noOlder
}
let request = request(for: older)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
}
self.older = .before(id: statuses.last!.id, count: nil)
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id))
}
}
}
enum Error: TimelineLikeCollectionViewError {
case noNewer
case noOlder
case allCaughtUp
}
}
extension ProfileStatusesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 {
Task {
await controller.loadOlder()
}
}
}
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else {
return
}
let status = mastodonController.persistentContainer.status(for: id)!
selected(status: status.reblog?.id ?? id, state: state.copy())
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension ProfileStatusesViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
(collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
}
}
extension ProfileStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
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: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}
extension ProfileStatusesViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension ProfileStatusesViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
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)
}
}

View File

@ -2,8 +2,8 @@
// ProfileViewController.swift
// Tusker
//
// Created by Shadowfacts on 10/10/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 7/3/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
@ -18,84 +18,79 @@ class ProfileViewController: UIPageViewController {
// when first constructed. It should never be set to nil.
var accountID: String? {
willSet {
precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
}
didSet {
pageControllers.forEach { $0.setAccountID(accountID!) }
Task {
await loadAccount()
if newValue == nil {
fatalError("Do not set ProfileViewController.accountID to nil")
}
}
didSet {
pageControllers.forEach { $0.accountID = accountID }
loadAccount()
}
}
private var accountUpdater: Cancellable?
private(set) var currentIndex: Int!
private var pageControllers: [ProfileStatusesViewController]!
let pageControllers: [ProfileStatusesViewController]
var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex]
}
private var state: State = .idle
private var headerView: ProfileHeaderView!
private var cancellables = Set<AnyCancellable>()
private var hasAppeared = false
init(accountID: String?, mastodonController: MastodonController) {
self.accountID = accountID
self.mastodonController = mastodonController
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.pageControllers = [
.init(accountID: accountID, kind: .statuses, owner: self),
.init(accountID: accountID, kind: .withReplies, owner: self),
.init(accountID: accountID, kind: .onlyMedia, owner: self),
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController),
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController)
]
// 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)
}
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
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(children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
self.composeDirectMentioning()
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.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)
mastodonController.persistentContainer.accountSubject
accountUpdater = mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.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)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateAccountUI() }
Task {
await loadAccount()
}
loadAccount()
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
@ -105,217 +100,182 @@ class ProfileViewController: UIPageViewController {
}
}
private func loadAccount() async {
guard let accountID else {
return
}
if let account = mastodonController.persistentContainer.account(for: accountID) {
updateAccountUI(account: account)
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 {
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)
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)
}
}
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)
private func updateAccountUI() {
guard let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: 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)
}
}
}
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
guard case .idle = state else {
return
}
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse
currentIndex = index
state = .animating
let direction: UIPageViewController.NavigationDirection
if currentIndex == nil || index - currentIndex > 0 {
direction = .forward
} else {
direction = .reverse
}
headerView.pagesSegmentedControl.selectedSegmentIndex = index
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
pageControllers[index].initialHeaderMode = .createView
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
self.state = .idle
completion?(finished)
}
currentIndex = index
// since it will be added in viewDidLoad
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion)
return
}
let new = pageControllers[index]
currentIndex = index
let headerHeight = self.headerView.bounds.height
// TODO: old.headerCell could be nil if scrolled down and key command used
let oldHeaderCell = old.headerCell!
// 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
// 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
// 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))
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
tempTopConstraint
])
// 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
}
// 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
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
if animated,
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
// results in the collection view immediately removing cells that will be offscreen.
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
// rather than a gap appearing during it.
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
let origOldContentOffset = old.collectionView.contentOffset
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
snapshot.frame = old.collectionView.bounds
snapshot.frame.origin.y = 0
snapshot.layer.zPosition = 99
view.addSubview(snapshot)
// empirically, 0.3s seems to match the UIPageViewController animation
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
// animate the snapshot offscreen in the same direction as the old view
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
// animate the snapshot to be "scrolled" to top
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
if new.isViewLoaded {
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
}
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
} completion: { _ in
snapshot.removeFromSuperview()
// Actually switch pages
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in
// Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation
DispatchQueue.main.async {
// Move the header to the new table view
new.tableView.tableHeaderView = self.headerView
// Remove the inset, and set the offset back to old's original one, prior to removing the header
new.tableView.contentInset = .zero
new.tableView.contentOffset = prevOldContentOffset
// Deactivate the top constraint, otherwise it sticks around
tempTopConstraint.isActive = false
// Re-add the width constraint since it was removed by re-parenting the view
// Why was the width constraint removed, but the top one not? Good question, I have no idea.
NSLayoutConstraint.activate([
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor)
])
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
// if old was not scrolled all the way to the top
new.tableView.layoutIfNeeded()
let snapshot = new.dataSource.snapshot()
new.dataSource.apply(snapshot, animatingDifferences: false)
completion?(finished)
}
} 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,
if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let accountID,
if let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)
}
}
}
extension ProfileViewController {
enum State {
case idle
case animating
}
}
extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ProfileViewController: ToastableViewController {
var apiController: MastodonController { mastodonController }
}
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
guard case .idle = state else {
return
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
}
selectPage(at: newIndex, animated: true)
}
}
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
pageControllers[currentIndex].tabBarScrollToTop()
}
}
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: TabBarScrollableViewController {
func tabBarScrollToTop() {
currentViewController.tabBarScrollToTop()
}
}
extension ProfileViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
}
extension ProfileViewController: ToastableViewController {
}

View File

@ -289,7 +289,6 @@ extension SearchResultsViewController: UISearchBarDelegate {
}
extension SearchResultsViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension SearchResultsViewController: ToastableViewController {
@ -299,6 +298,7 @@ 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,7 +145,6 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
}
extension StatusActionAccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension StatusActionAccountListTableViewController: ToastableViewController {
@ -155,6 +154,7 @@ 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: TimelineViewController {
class HashtagTimelineViewController: TimelineTableViewController {
let hashtag: Hashtag

View File

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

View File

@ -0,0 +1,334 @@
//
// 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,6 +10,8 @@ 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!
@ -34,7 +36,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.controller = TimelineLikeController(delegate: self)
self.navigationItem.title = timeline.title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
}
@ -60,8 +61,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
}
return config
}
@ -85,15 +86,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, (String, StatusState)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1)
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 timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else {
@ -107,8 +108,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(id: let id, state: let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .status(_, _):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: itemIdentifier)
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
@ -120,16 +121,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
}
// non-private, because ListTimelineViewController needs to be able to reload it from scratch
func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
private func applyInitialSnapshot() {
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,9 +140,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
Task {
if case .notLoadedInitial = await controller.state {
await controller.loadInitial()
}
await controller.loadInitial()
}
}
@ -162,7 +160,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// pruneOffscreenRows()
pruneOffscreenRows()
}
private func removeTimelineDescriptionCell() {
@ -172,28 +170,27 @@ 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 {
@ -257,7 +254,7 @@ extension TimelineViewController {
var hideSeparators: Bool {
switch self {
case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore:
case .loadingIndicator, .publicTimelineDescription:
return true
default:
return false
@ -328,12 +325,10 @@ extension TimelineViewController {
let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
if !statuses.isEmpty {
self.older = .before(id: statuses.last!.id, count: nil)
}
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))
@ -351,7 +346,8 @@ extension TimelineViewController {
extension TimelineViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section),
case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else {
return
}
@ -405,7 +401,7 @@ extension TimelineViewController: UICollectionViewDragDelegate {
}
extension TimelineViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
var apiController: MastodonController { mastodonController }
}
extension TimelineViewController: MenuActionProvider {
@ -420,16 +416,3 @@ 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,12 +248,3 @@ 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,6 +11,11 @@ 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() {
@ -21,6 +26,38 @@ 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) {
@ -80,13 +117,10 @@ extension EnhancedTableViewController: UITableViewDragDelegate {
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
tableView.scrollToTop()
}
}
extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
if scrollViewShouldScrollToTop(tableView) {
let topOffset = CGPoint(x: 0, y: -tableView.adjustedContentInset.top)
tableView.setContentOffset(topOffset, animated: true)
scrollViewDidScrollToTop(tableView)
}
}
}

View File

@ -60,23 +60,12 @@ extension MenuActionProvider {
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
}),
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([])
}
UIDeferredMenuElement.uncached({ (elementHandler) in
Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
}
})
@ -392,13 +381,16 @@ extension MenuActionProvider {
})
}
@MainActor
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
guard let ownAccount = mastodonController.account,
relationship.accountID != ownAccount.id else {
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 {
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)
@ -420,19 +412,6 @@ 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,12 +105,3 @@ 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,6 +135,12 @@ class SplitNavigationController: UIViewController {
updateSecondaryNavVisibility()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
@ -239,24 +245,7 @@ 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 {
@ -282,8 +271,7 @@ 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
// 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
owner.viewControllers.first!.view
}
private func configureSecondarySplitCloseButton(for viewController: UIViewController) {

View File

@ -1,13 +0,0 @@
//
// 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,6 +30,22 @@ 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 = TimelineViewController(for: timeline, mastodonController: mastodonController)
let timeline = TimelineTableViewController(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)")
fatalError("State \(state) cannot transition to \(newValue)")
preconditionFailure("cannot transition to state")
}
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 || state == .idle else {
guard state == .notLoadedInitial else {
return
}
let token = LoadAttemptToken()
@ -65,8 +65,6 @@ 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))
@ -87,8 +85,6 @@ actor TimelineLikeController<Item> {
}
await emit(event: .prependItems(items, token))
state = .idle
} catch is CancellationError {
return
} catch {
await emit(event: .loadNewerError(error, token))
state = .idle
@ -100,11 +96,7 @@ actor TimelineLikeController<Item> {
return
}
let token = LoadAttemptToken()
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 {
guard await delegate.canLoadOlder() else {
return
}
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
@ -117,8 +109,6 @@ 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))
@ -133,7 +123,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)")
fatalError("State \(state) cannot emit event: \(event)")
preconditionFailure("state cannot emit event")
}
switch event {
case .addLoadingIndicator:
@ -181,14 +171,14 @@ actor TimelineLikeController<Item> {
switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, _):
case .loadingInitial(_, hasAddedLoadingIndicator: _):
return true
default:
return false
}
case .idle:
switch to {
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
case .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? ProfileStatusesViewController,
if let profileController = self as? ProfileViewController,
profileController.accountID == accountID {
return
}
@ -104,6 +104,7 @@ 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,9 +2,17 @@
/// 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 blurHash.count >= 6 else { return nil }
guard blurHashesEnabled,
blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1

View File

@ -26,6 +26,7 @@ class AttachmentView: GIFImageView {
var attachment: Attachment!
var index: Int!
var expectedSize: CGSize!
private var attachmentRequest: ImageCache.Request?
private var source: Source?
@ -36,12 +37,13 @@ class AttachmentView: GIFImageView {
private var isGrayscale = false
init(attachment: Attachment, index: Int) {
init(attachment: Attachment, index: Int, expectedSize: CGSize) {
super.init(image: nil)
commonInit()
self.attachment = attachment
self.index = index
self.expectedSize = expectedSize
loadAttachment()
}
@ -104,14 +106,24 @@ class AttachmentView: GIFImageView {
func loadAttachment() {
guard AttachmentsContainerView.supportedAttachmentTypes.contains(attachment.kind) else {
fatalError("invalid attachment type")
preconditionFailure("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: self.blurHashSize()) else {
guard var preview = UIImage(blurHash: hash, size: size) else {
return
}
@ -137,28 +149,7 @@ class AttachmentView: GIFImageView {
case .gifv:
loadGifv()
default:
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)
preconditionFailure("invalid attachment type")
}
}

View File

@ -257,7 +257,7 @@ class AttachmentsContainerView: UIView {
}
let size = CGSize(width: width, height: height)
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
let attachmentView = AttachmentView(attachment: attachments[index], index: index, expectedSize: size)
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.shortDescription ?? instance.description)
descriptionTextView.setTextFromHtml(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 mastodonController = self?.mastodonController,
guard let self = self,
case let .success(results, _) = response,
let relationship = results.first else {
return
}
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}

View File

@ -168,21 +168,18 @@ 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 {
let aspectRatio = CGFloat(width) / CGFloat(height)
if aspectRatio > 1 {
size = CGSize(width: 32, height: 32 / aspectRatio)
} else {
size = CGSize(width: 32 * aspectRatio, height: 32)
}
size = CGSize(width: width, height: height)
} else {
size = CGSize(width: 32, height: 32)
size = imageViewSize
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}

View File

@ -29,8 +29,9 @@ protocol StatusCollectionViewCell: UICollectionViewCell, AttachmentViewDelegate
var reblogButton: UIButton { get }
var moreButton: UIButton { get }
var delegate: StatusCollectionViewCellDelegate? { get }
// TODO: why is one of these ! and the other ?
var mastodonController: MastodonController! { get }
var delegate: StatusCollectionViewCellDelegate? { get }
var showStatusAutomatically: Bool { get }
var showReplyIndicator: Bool { get }
@ -49,8 +50,6 @@ 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)
@ -76,6 +75,8 @@ extension StatusCollectionViewCell {
}
func doUpdateUI(status: StatusMO) {
precondition(delegate != nil, "StatusCollectionViewCell must have delegate")
statusID = status.id
accountID = status.account.id

View File

@ -12,15 +12,12 @@ 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)))
}
@ -54,7 +51,6 @@ 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)))
}
@ -117,7 +113,6 @@ 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: [
@ -195,29 +190,21 @@ 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))
}
private(set) lazy var moreButton = UIButton().configure {
let 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
@ -227,8 +214,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private var mainContainerBottomToActionsConstraint: NSLayoutConstraint!
private var mainContainerBottomToSelfConstraint: NSLayoutConstraint!
weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController! { overrideMastodonController ?? delegate?.apiController }
weak var mastodonController: MastodonController!
weak var delegate: StatusCollectionViewCellDelegate?
var showStatusAutomatically: Bool {
// TODO: needed once conversation controller refactored
@ -238,7 +224,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// TODO: needed once conversation controller refactored
true
}
var showPinned: Bool = false
var showPinned: Bool {
// TODO: needed once profile controller refactored
false
}
// alas these need to be internal so they're accessible from the protocol extensions
var statusID: String!
@ -263,7 +252,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, constant: -4)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6)
@ -623,31 +612,3 @@ 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

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