Compare commits
No commits in common. "b38c24b3474821a0de050dfa4945c39e774147b2" and "22022f5ef65fb3aad8feb09dbdaaaf3302c426e2" have entirely different histories.
b38c24b347
...
22022f5ef6
|
@ -1,4 +1,3 @@
|
|||
Dist.xcconfig
|
||||
.DS_Store
|
||||
MyPlayground.playground/
|
||||
|
||||
|
|
40
CHANGELOG.md
40
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
private func setupCrashReporter() {
|
||||
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
|
||||
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
|
||||
|
||||
// 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
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
image?.prepareThumbnail(of: size, completionHandler: {
|
||||
completion(data, $0)
|
||||
})
|
||||
} else {
|
||||
image?.prepareForDisplay {
|
||||
completion(data, $0)
|
||||
}
|
||||
} else {
|
||||
image.prepareForDisplay {
|
||||
completion(data, $0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(data, image)
|
||||
}
|
||||
}
|
||||
} 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? {
|
||||
|
@ -107,10 +111,87 @@ class ImageCache {
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -65,7 +65,7 @@ class AccountListTableViewController: EnhancedTableViewController {
|
|||
}
|
||||
|
||||
extension AccountListTableViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension AccountListTableViewController: ToastableViewController {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -137,7 +137,8 @@ struct ComposeView: View {
|
|||
}
|
||||
|
||||
MainComposeTextView(
|
||||
draft: draft
|
||||
draft: draft,
|
||||
placeholder: Text("What's on your mind?")
|
||||
)
|
||||
|
||||
if let poll = draft.poll {
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,7 +147,7 @@ extension ProfileDirectoryViewController {
|
|||
}
|
||||
|
||||
extension ProfileDirectoryViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension ProfileDirectoryViewController: ToastableViewController {
|
||||
|
|
|
@ -120,7 +120,7 @@ extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
|
|||
}
|
||||
|
||||
extension TrendingHashtagsViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension TrendingHashtagsViewController: ToastableViewController {
|
||||
|
|
|
@ -100,17 +100,13 @@ 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)
|
||||
size = CGSize(width: width, height: height)
|
||||
} else {
|
||||
size = CGSize(width: 32 * aspectRatio, height: 32)
|
||||
}
|
||||
} else {
|
||||
size = CGSize(width: 32, height: 32)
|
||||
size = imageViewSize
|
||||
}
|
||||
|
||||
guard let preview = UIImage(blurHash: hash, size: size) else {
|
||||
|
|
|
@ -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)
|
||||
size = CGSize(width: width, height: height)
|
||||
} else {
|
||||
size = CGSize(width: 32 * aspectRatio, height: 32)
|
||||
}
|
||||
} else {
|
||||
size = CGSize(width: 32, height: 32)
|
||||
size = imageViewSize
|
||||
}
|
||||
|
||||
guard let preview = UIImage(blurHash: hash, size: size) else {
|
||||
|
|
|
@ -101,7 +101,7 @@ extension TrendingLinksViewController {
|
|||
}
|
||||
|
||||
extension TrendingLinksViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension TrendingLinksViewController: ToastableViewController {
|
||||
|
|
|
@ -82,7 +82,7 @@ extension TrendingStatusesViewController {
|
|||
}
|
||||
|
||||
extension TrendingStatusesViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension TrendingStatusesViewController: ToastableViewController {
|
||||
|
|
|
@ -37,15 +37,8 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -174,7 +174,7 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
|
|||
}
|
||||
|
||||
extension EditListAccountsViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension EditListAccountsViewController: ToastableViewController {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
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
|
||||
cell.showPinned = pinned
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
return cell
|
||||
}
|
||||
case .status(id: let id, state: let state, pinned: let pinned):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
|
||||
case .loadingIndicator:
|
||||
return loadingIndicatorCell(for: indexPath)
|
||||
case .confirmLoadMore:
|
||||
return confirmLoadMoreCell(for: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
collectionView.indexPathsForSelectedItems?.forEach {
|
||||
collectionView.deselectItem(at: $0, animated: true)
|
||||
}
|
||||
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setAccountID(_ id: String) {
|
||||
self.accountID = id
|
||||
// TODO: maybe this function should be async?
|
||||
Task {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard isViewLoaded,
|
||||
let accountID,
|
||||
case .unloaded = state,
|
||||
mastodonController.persistentContainer.account(for: accountID) != nil else {
|
||||
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||
guard accountID != nil else {
|
||||
completion(.failure(.noClient))
|
||||
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
|
||||
getStatuses { (response) in
|
||||
guard self.state == .loadingInitial else {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
getPinnedStatuses { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
DispatchQueue.main.async {
|
||||
var snapshot = snapshot()
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
|
||||
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||
guard let older = older else {
|
||||
completion(.failure(.noOlder))
|
||||
return
|
||||
}
|
||||
|
||||
getStatuses(for: older) { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.noOlder))
|
||||
return
|
||||
}
|
||||
|
||||
self.older = .before(id: statuses.last!.id, count: nil)
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
var snapshot = currentSnapshot()
|
||||
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||
guard let newer = newer else {
|
||||
completion(.failure(.noNewer))
|
||||
return
|
||||
}
|
||||
|
||||
getStatuses(for: newer) { (response) in
|
||||
switch response {
|
||||
case let .failure(error):
|
||||
completion(.failure(.client(error)))
|
||||
|
||||
case let .success(statuses, _):
|
||||
guard !statuses.isEmpty else {
|
||||
completion(.failure(.allCaughtUp))
|
||||
return
|
||||
}
|
||||
|
||||
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||
|
||||
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
var snapshot = currentSnapshot()
|
||||
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) }
|
||||
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
|
||||
snapshot.insertItems(items, beforeItem: first)
|
||||
} else {
|
||||
snapshot.appendItems(items, toSection: .statuses)
|
||||
}
|
||||
completion(.success(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) {
|
||||
let request: Request<[Status]>
|
||||
switch kind {
|
||||
case .statuses:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||
case .withReplies:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
|
||||
case .onlyMedia:
|
||||
request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
|
||||
}
|
||||
mastodonController.run(request, completion: completion)
|
||||
}
|
||||
|
||||
private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) {
|
||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
mastodonController.run(request, completion: completion)
|
||||
}
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
continuation.resume()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) }
|
||||
snapshot.appendItems(items, toSection: .pinned)
|
||||
await apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
guard case .loaded = state else {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
#endif
|
||||
return
|
||||
}
|
||||
Task {
|
||||
// TODO: coalesce these data source updates
|
||||
// TODO: refresh profile
|
||||
await controller.loadNewer()
|
||||
await tryLoadPinned()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum State {
|
||||
case unloaded
|
||||
case loading
|
||||
case setupInitialSnapshot
|
||||
case loaded
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum Kind {
|
||||
case statuses, withReplies, onlyMedia
|
||||
}
|
||||
enum HeaderMode {
|
||||
case createView, placeholder(height: CGFloat)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController {
|
||||
enum Section: 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: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
||||
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
|
||||
prefetchStatuses(with: ids)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileStatusesViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
||||
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
|
||||
cancelPrefetchingStatuses(with: ids)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,43 +18,39 @@ 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")
|
||||
if newValue == nil {
|
||||
fatalError("Do not set ProfileViewController.accountID to nil")
|
||||
}
|
||||
}
|
||||
didSet {
|
||||
pageControllers.forEach { $0.setAccountID(accountID!) }
|
||||
Task {
|
||||
await loadAccount()
|
||||
}
|
||||
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) {
|
||||
|
@ -66,36 +62,35 @@ class ProfileViewController: UIPageViewController {
|
|||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
for pageController in pageControllers {
|
||||
pageController.profileHeaderDelegate = self
|
||||
}
|
||||
|
||||
selectPage(at: 0, animated: false)
|
||||
|
||||
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
|
||||
composeButton.menu = UIMenu(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
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
hasAppeared = true
|
||||
}
|
||||
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||
updateAccountUI(account: account)
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
self.updateAccountUI(account: mo)
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadAccount()
|
||||
self.loadAccount()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAccountUI(account: AccountMO) {
|
||||
if let currentAccountID = mastodonController.accountInfo?.id {
|
||||
userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
|
||||
}
|
||||
}
|
||||
|
||||
navigationItem.title = account.displayNameWithoutCustomEmoji
|
||||
}
|
||||
|
||||
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
||||
guard case .idle = state else {
|
||||
private func updateAccountUI() {
|
||||
guard let accountID = accountID,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||
return
|
||||
}
|
||||
|
||||
state = .animating
|
||||
|
||||
let direction: UIPageViewController.NavigationDirection
|
||||
if currentIndex == nil || index - currentIndex > 0 {
|
||||
direction = .forward
|
||||
} else {
|
||||
direction = .reverse
|
||||
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) {
|
||||
let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse
|
||||
currentIndex = index
|
||||
|
||||
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)
|
||||
// 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
|
||||
|
||||
snapshot.frame = old.collectionView.bounds
|
||||
snapshot.frame.origin.y = 0
|
||||
snapshot.layer.zPosition = 99
|
||||
view.addSubview(snapshot)
|
||||
// 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)
|
||||
])
|
||||
|
||||
// empirically, 0.3s seems to match the UIPageViewController animation
|
||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||
// animate the snapshot offscreen in the same direction as the old view
|
||||
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
|
||||
// animate the snapshot to be "scrolled" to top
|
||||
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
|
||||
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
|
||||
if new.isViewLoaded {
|
||||
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
|
||||
}
|
||||
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
|
||||
} completion: { _ in
|
||||
snapshot.removeFromSuperview()
|
||||
}
|
||||
} else if new.isViewLoaded {
|
||||
new.collectionView.contentOffset = old.collectionView.contentOffset
|
||||
}
|
||||
// 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)
|
||||
|
||||
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 {
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -319,7 +319,7 @@ extension SearchViewController: UICollectionViewDragDelegate {
|
|||
}
|
||||
|
||||
extension SearchViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
var apiController: MastodonController { mastodonController }
|
||||
}
|
||||
|
||||
extension SearchViewController: ToastableViewController {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class HashtagTimelineViewController: TimelineViewController {
|
||||
class HashtagTimelineViewController: TimelineTableViewController {
|
||||
|
||||
let hashtag: Hashtag
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,16 +86,16 @@ 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) {
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||
guard case .status(id: let id, state: let state) = item,
|
||||
let status = mastodonController.persistentContainer.status(for: id) else {
|
||||
fatalError()
|
||||
}
|
||||
cell.mastodonController = mastodonController
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: id, state: state)
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||
}
|
||||
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||
guard case .public(let local) = timeline else {
|
||||
fatalError()
|
||||
|
@ -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,17 +121,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
@ -140,11 +140,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
await controller.loadInitial()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
@ -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,11 +325,9 @@ 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)
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,25 +60,14 @@ 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 {
|
||||
UIDeferredMenuElement.uncached({ (elementHandler) in
|
||||
Task { @MainActor in
|
||||
if let relationship = await relationship.value,
|
||||
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
|
||||
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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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]? {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -168,19 +168,16 @@ 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)
|
||||
size = CGSize(width: width, height: height)
|
||||
} else {
|
||||
size = CGSize(width: 32 * aspectRatio, height: 32)
|
||||
}
|
||||
} else {
|
||||
size = CGSize(width: 32, height: 32)
|
||||
size = imageViewSize
|
||||
}
|
||||
|
||||
guard let preview = UIImage(blurHash: hash, size: size) else {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue