Compare commits
No commits in common. "71a2029752c601b1de82e743656f2b4689f8dc11" and "2469d285bc58351aa9bac44a2792cf375db558c3" have entirely different histories.
71a2029752
...
2469d285bc
|
@ -36,15 +36,15 @@
|
||||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
||||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
||||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; };
|
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; };
|
||||||
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */; };
|
D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */; };
|
||||||
D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; };
|
D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; };
|
||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; };
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; };
|
||||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
|
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
|
||||||
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
|
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
|
||||||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
|
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
|
||||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
|
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
|
||||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
|
D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */; };
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
|
D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */; };
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||||
|
@ -95,11 +95,13 @@
|
||||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
|
||||||
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
|
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
|
||||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
|
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
|
||||||
|
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */; };
|
||||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; };
|
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; };
|
||||||
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
|
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
|
||||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
|
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
|
||||||
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
|
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
|
||||||
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
|
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
|
||||||
|
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; };
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
|
||||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
|
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
|
||||||
|
@ -390,14 +392,14 @@
|
||||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
||||||
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
|
D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.swift; sourceTree = "<group>"; };
|
||||||
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
|
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
|
||||||
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
|
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
|
||||||
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
|
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
|
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
|
||||||
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
|
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
|
||||||
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
|
D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileViewController.swift; sourceTree = "<group>"; };
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -448,11 +450,13 @@
|
||||||
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
|
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
|
||||||
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
|
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
|
||||||
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
|
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
|
||||||
|
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
|
||||||
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = "<group>"; };
|
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = "<group>"; };
|
||||||
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
|
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
|
||||||
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
|
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
|
||||||
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
|
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
|
||||||
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
|
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||||
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
|
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
|
||||||
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
|
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
|
||||||
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
|
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -943,10 +947,12 @@
|
||||||
D641C784213DD819004B4513 /* Profile */ = {
|
D641C784213DD819004B4513 /* Profile */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
|
||||||
|
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */,
|
||||||
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
|
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
|
||||||
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
|
D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */,
|
||||||
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
|
D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */,
|
||||||
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
|
D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = Profile;
|
path = Profile;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1811,6 +1817,7 @@
|
||||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||||
|
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
|
||||||
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
||||||
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
|
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
|
||||||
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
|
||||||
|
@ -1831,7 +1838,7 @@
|
||||||
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
|
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
|
||||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
||||||
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
|
D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */,
|
||||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
|
@ -1858,7 +1865,7 @@
|
||||||
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
|
||||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */,
|
||||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
||||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
||||||
|
@ -1954,7 +1961,7 @@
|
||||||
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
|
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
|
||||||
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
|
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
|
||||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
|
||||||
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
|
D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */,
|
||||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
|
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
|
||||||
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
|
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
|
||||||
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
|
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
|
||||||
|
@ -1974,6 +1981,7 @@
|
||||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||||
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
||||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
||||||
|
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
|
||||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
||||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||||
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
|
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
|
||||||
|
|
|
@ -35,9 +35,6 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
|
||||||
// would need to audit existing uses to make sure everything happens on the main thread
|
|
||||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
|
||||||
let statusSubject = PassthroughSubject<String, Never>()
|
let statusSubject = PassthroughSubject<String, Never>()
|
||||||
let accountSubject = PassthroughSubject<String, Never>()
|
let accountSubject = PassthroughSubject<String, Never>()
|
||||||
let relationshipSubject = PassthroughSubject<String, Never>()
|
let relationshipSubject = PassthroughSubject<String, Never>()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// ProfileHeaderCollectionViewCell.swift
|
// NewProfileHeaderCollectionViewCell.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/10/22.
|
// Created by Shadowfacts on 10/10/22.
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class ProfileHeaderCollectionViewCell: UICollectionViewCell {
|
class NewProfileHeaderCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
private var state: State = .unloaded
|
private var state: State = .unloaded
|
||||||
|
|
|
@ -0,0 +1,392 @@
|
||||||
|
//
|
||||||
|
// NewProfileStatusesViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/6/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController {
|
||||||
|
|
||||||
|
unowned var owner: NewProfileViewController
|
||||||
|
var mastodonController: MastodonController { owner.mastodonController }
|
||||||
|
private 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>()
|
||||||
|
|
||||||
|
var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
// var headerCell: NewProfileHeaderCollectionViewCell? {
|
||||||
|
// guard let accountID,
|
||||||
|
// isViewLoaded,
|
||||||
|
// let indexPath = dataSource.indexPath(for: .header(accountID)),
|
||||||
|
// let cell = collectionView.cellForItem(at: indexPath) as? NewProfileHeaderCollectionViewCell else {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// return cell
|
||||||
|
// }
|
||||||
|
private(set) var headerCell: NewProfileHeaderCollectionViewCell?
|
||||||
|
|
||||||
|
init(accountID: String?, kind: Kind, owner: NewProfileViewController) {
|
||||||
|
self.accountID = accountID
|
||||||
|
self.kind = kind
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
self.controller = TimelineLikeController(delegate: self)
|
||||||
|
|
||||||
|
mastodonController.persistentContainer.accountSubject
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.filter { [unowned self] in $0 == self.accountID }
|
||||||
|
.sink { [unowned self] id in
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.reconfigureItems([.header(id)])
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// TODO: refresh key command
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
// TODO: item separators
|
||||||
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
|
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
// TODO: drag delegate
|
||||||
|
|
||||||
|
registerTimelineLikeCells()
|
||||||
|
dataSource = createDataSource()
|
||||||
|
|
||||||
|
// TODO: refresh control
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
// let headerCell = UICollectionView.CellRegistration<NewProfileHeaderCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||||
|
// cell.header.delegate = self.profileHeaderDelegate
|
||||||
|
// cell.header.updateUI(for: item)
|
||||||
|
// cell.header.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0
|
||||||
|
// }
|
||||||
|
collectionView.register(NewProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
|
||||||
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
|
||||||
|
cell.delegate = self
|
||||||
|
cell.showPinned = item.2
|
||||||
|
cell.updateUI(statusID: item.0, state: item.1)
|
||||||
|
}
|
||||||
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .header(let id):
|
||||||
|
if let headerCell = self.headerCell {
|
||||||
|
return headerCell
|
||||||
|
} else {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! NewProfileHeaderCollectionViewCell
|
||||||
|
switch self.initialHeaderMode {
|
||||||
|
case nil:
|
||||||
|
fatalError("missing initialHeaderMode")
|
||||||
|
case .createView:
|
||||||
|
let view = ProfileHeaderView.create()
|
||||||
|
view.delegate = self.profileHeaderDelegate
|
||||||
|
view.updateUI(for: id)
|
||||||
|
view.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0
|
||||||
|
cell.addHeader(view)
|
||||||
|
case .placeholder(height: let height):
|
||||||
|
_ = cell.addConstraint(height: height)
|
||||||
|
}
|
||||||
|
self.headerCell = cell
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
case .status(id: let id, state: let state, pinned: let pinned):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
|
||||||
|
case .loadingIndicator:
|
||||||
|
return loadingIndicatorCell(for: indexPath)
|
||||||
|
case .confirmLoadMore:
|
||||||
|
return confirmLoadMoreCell(for: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
collectionView.indexPathsForSelectedItems?.forEach {
|
||||||
|
collectionView.deselectItem(at: $0, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
|
// TODO: prune offscreen rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: refreshing
|
||||||
|
|
||||||
|
func setAccountID(_ id: String) {
|
||||||
|
self.accountID = id
|
||||||
|
// TODO: maybe this function should be async?
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
guard accountID != nil,
|
||||||
|
await controller.state == .notLoadedInitial else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.header, .pinned, .statuses])
|
||||||
|
snapshot.appendItems([.header(accountID)], toSection: .header)
|
||||||
|
await apply(snapshot, animatingDifferences: false)
|
||||||
|
print("added header item")
|
||||||
|
|
||||||
|
await controller.loadInitial()
|
||||||
|
await tryLoadPinned()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tryLoadPinned() async {
|
||||||
|
do {
|
||||||
|
try await loadPinned()
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.tryLoadPinned()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPinned() async throws {
|
||||||
|
guard case .statuses = kind,
|
||||||
|
mastodonController.instanceFeatures.profilePinnedStatuses else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||||
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) }
|
||||||
|
snapshot.appendItems(items, toSection: .pinned)
|
||||||
|
await apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController {
|
||||||
|
enum Kind {
|
||||||
|
case statuses, withReplies, onlyMedia
|
||||||
|
}
|
||||||
|
enum HeaderMode {
|
||||||
|
case createView, placeholder(height: CGFloat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController {
|
||||||
|
enum Section: TimelineLikeCollectionViewSection {
|
||||||
|
case header
|
||||||
|
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)
|
||||||
|
case loadingIndicator
|
||||||
|
case confirmLoadMore
|
||||||
|
|
||||||
|
static func fromTimelineItem(_ item: String) -> Self {
|
||||||
|
return .status(id: item, state: .unknown, pinned: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case let (.header(a), .header(b)):
|
||||||
|
return a == b
|
||||||
|
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
|
||||||
|
return a == b && ap == bp
|
||||||
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
|
return true
|
||||||
|
case (.confirmLoadMore, .confirmLoadMore):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
switch self {
|
||||||
|
case .header(let id):
|
||||||
|
hasher.combine(0)
|
||||||
|
hasher.combine(id)
|
||||||
|
case .status(id: let id, state: _, pinned: let pinned):
|
||||||
|
hasher.combine(1)
|
||||||
|
hasher.combine(id)
|
||||||
|
hasher.combine(pinned)
|
||||||
|
case .loadingIndicator:
|
||||||
|
hasher.combine(2)
|
||||||
|
case .confirmLoadMore:
|
||||||
|
hasher.combine(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController: 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 NewProfileStatusesViewController: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: cell selection
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController: MenuActionProvider {
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileStatusesViewController: 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: false, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,245 @@
|
||||||
|
//
|
||||||
|
// NewProfileViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/10/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class NewProfileViewController: UIPageViewController {
|
||||||
|
|
||||||
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
|
// This property is optional because MyProfileViewController may not have the user's account ID
|
||||||
|
// when first constructed. It should never be set to nil.
|
||||||
|
var accountID: String? {
|
||||||
|
willSet {
|
||||||
|
precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
|
||||||
|
}
|
||||||
|
didSet {
|
||||||
|
pageControllers.forEach { $0.setAccountID(accountID!) }
|
||||||
|
Task {
|
||||||
|
await loadAccount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var currentIndex: Int!
|
||||||
|
private var pageControllers: [NewProfileStatusesViewController]!
|
||||||
|
var currentViewController: NewProfileStatusesViewController {
|
||||||
|
pageControllers[currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var state: State = .idle
|
||||||
|
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
for pageController in pageControllers {
|
||||||
|
pageController.profileHeaderDelegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPage(at: 0, animated: false)
|
||||||
|
|
||||||
|
// TODO: compose button
|
||||||
|
|
||||||
|
// TODO: key commands
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await loadAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: configure nav controller appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAccount() async {
|
||||||
|
guard let accountID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||||
|
updateAccountUI(account: account)
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
let req = Client.getAccount(id: accountID)
|
||||||
|
let (account, _) = try await mastodonController.run(req)
|
||||||
|
let mo = await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addOrUpdate(account: account) { (mo) in
|
||||||
|
continuation.resume(returning: mo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.updateAccountUI(account: mo)
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.loadAccount()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAccountUI(account: AccountMO) {
|
||||||
|
if let currentAccountID = mastodonController.accountInfo?.id {
|
||||||
|
userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
navigationItem.title = account.displayNameWithoutCustomEmoji
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
||||||
|
guard case .idle = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .animating
|
||||||
|
|
||||||
|
let direction: UIPageViewController.NavigationDirection
|
||||||
|
if currentIndex == nil || index - currentIndex > 0 {
|
||||||
|
direction = .forward
|
||||||
|
} else {
|
||||||
|
direction = .reverse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let old = viewControllers?.first as? NewProfileStatusesViewController 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
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let new = pageControllers[index]
|
||||||
|
|
||||||
|
currentIndex = index
|
||||||
|
|
||||||
|
// TODO: old.headerCell could be nil if scrolled down and key command used
|
||||||
|
let oldHeaderCell = old.headerCell!
|
||||||
|
|
||||||
|
// old header cell must have the header view
|
||||||
|
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
||||||
|
|
||||||
|
if new.isViewLoaded {
|
||||||
|
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
|
||||||
|
} else {
|
||||||
|
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disable user interaction during animation, to avoid any potential weird race conditions
|
||||||
|
headerView.isUserInteractionEnabled = false
|
||||||
|
headerView.layer.zPosition = 100
|
||||||
|
view.addSubview(headerView)
|
||||||
|
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
|
||||||
|
// TODO: use safe area layout guide instead of manually adjusting this?
|
||||||
|
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
|
||||||
|
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
// hide scroll indicators during the transition because otherwise the show through the
|
||||||
|
// profile header, even though it has an opaque background
|
||||||
|
old.collectionView.showsVerticalScrollIndicator = false
|
||||||
|
if new.isViewLoaded {
|
||||||
|
new.collectionView.showsVerticalScrollIndicator = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
|
||||||
|
if animated,
|
||||||
|
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
||||||
|
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
|
||||||
|
// results in the collection view immediately removing cells that will be offscreen.
|
||||||
|
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
|
||||||
|
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
|
||||||
|
// rather than a gap appearing during it.
|
||||||
|
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
|
||||||
|
let origOldContentOffset = old.collectionView.contentOffset
|
||||||
|
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
|
||||||
|
|
||||||
|
snapshot.frame = old.collectionView.bounds
|
||||||
|
snapshot.frame.origin.y = 0
|
||||||
|
snapshot.layer.zPosition = 99
|
||||||
|
view.addSubview(snapshot)
|
||||||
|
|
||||||
|
// empirically, 0.3s seems to match the UIPageViewController animation
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||||
|
// animate the snapshot offscreen in the same direction as the old view
|
||||||
|
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
|
||||||
|
// animate the snapshot to be "scrolled" to top
|
||||||
|
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
|
||||||
|
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
|
||||||
|
if new.isViewLoaded {
|
||||||
|
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
|
||||||
|
}
|
||||||
|
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
|
||||||
|
} completion: { _ in
|
||||||
|
snapshot.removeFromSuperview()
|
||||||
|
}
|
||||||
|
} else if new.isViewLoaded {
|
||||||
|
new.collectionView.contentOffset = old.collectionView.contentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
||||||
|
// reenable scroll indicators after the switching animation is done
|
||||||
|
old.collectionView.showsVerticalScrollIndicator = true
|
||||||
|
new.collectionView.showsVerticalScrollIndicator = true
|
||||||
|
|
||||||
|
headerView.isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
headerView.transform = .identity
|
||||||
|
headerView.layer.zPosition = 0
|
||||||
|
// move the header view into the new page controller's cell
|
||||||
|
// new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load
|
||||||
|
new.headerCell!.addHeader(headerView)
|
||||||
|
|
||||||
|
self.state = .idle
|
||||||
|
completion?(finished)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case idle
|
||||||
|
case animating
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileViewController: ToastableViewController {
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewProfileViewController: ProfileHeaderViewDelegate {
|
||||||
|
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
||||||
|
guard case .idle = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectPage(at: newIndex, animated: true)
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,222 +2,234 @@
|
||||||
// ProfileStatusesViewController.swift
|
// ProfileStatusesViewController.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/6/22.
|
// Created by Shadowfacts on 7/3/20.
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
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!
|
||||||
|
|
||||||
unowned var owner: ProfileViewController
|
|
||||||
var mastodonController: MastodonController { owner.mastodonController }
|
|
||||||
private(set) var accountID: String!
|
|
||||||
let kind: Kind
|
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 older: RequestRange?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var newer: RequestRange?
|
||||||
|
|
||||||
var collectionView: UICollectionView {
|
init(accountID: String?, kind: Kind, mastodonController: MastodonController) {
|
||||||
view as! UICollectionView
|
|
||||||
}
|
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
|
||||||
|
|
||||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
|
||||||
|
|
||||||
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
|
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.owner = owner
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init()
|
||||||
|
|
||||||
self.controller = TimelineLikeController(delegate: self)
|
dragEnabled = true
|
||||||
|
|
||||||
mastodonController.persistentContainer.accountSubject
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.filter { [unowned self] in $0 == self.accountID }
|
|
||||||
.sink { [unowned self] id in
|
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
snapshot.reconfigureItems([.header(id)])
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func loadView() {
|
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
|
||||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
|
||||||
}
|
|
||||||
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
|
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
|
|
||||||
}
|
|
||||||
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
|
|
||||||
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
|
|
||||||
return sectionSeparatorConfiguration
|
|
||||||
}
|
|
||||||
var config = sectionSeparatorConfiguration
|
|
||||||
if item.hideSeparators {
|
|
||||||
config.topSeparatorVisibility = .hidden
|
|
||||||
config.bottomSeparatorVisibility = .hidden
|
|
||||||
}
|
|
||||||
if case .status(_, _, _) = item {
|
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
|
||||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
||||||
collectionView.delegate = self
|
|
||||||
collectionView.dragDelegate = self
|
|
||||||
|
|
||||||
registerTimelineLikeCells()
|
|
||||||
dataSource = createDataSource()
|
|
||||||
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
collectionView.refreshControl = UIRefreshControl()
|
|
||||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
|
||||||
|
|
||||||
|
// 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> {
|
func updateUI(account: AccountMO) {
|
||||||
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
|
if isViewLoaded {
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
|
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.delegate = self
|
||||||
cell.showPinned = item.2
|
cell.showPinned = pinned
|
||||||
cell.updateUI(statusID: item.0, state: item.1)
|
cell.updateUI(statusID: id, state: state)
|
||||||
}
|
|
||||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
|
||||||
switch itemIdentifier {
|
|
||||||
case .header(let id):
|
|
||||||
if let headerCell = self.headerCell {
|
|
||||||
return headerCell
|
|
||||||
} else {
|
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell
|
|
||||||
switch self.initialHeaderMode {
|
|
||||||
case nil:
|
|
||||||
fatalError("missing initialHeaderMode")
|
|
||||||
case .createView:
|
|
||||||
let view = ProfileHeaderView.create()
|
|
||||||
view.delegate = self.profileHeaderDelegate
|
|
||||||
view.updateUI(for: id)
|
|
||||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0
|
|
||||||
cell.addHeader(view)
|
|
||||||
case .placeholder(height: let height):
|
|
||||||
_ = cell.addConstraint(height: height)
|
|
||||||
}
|
|
||||||
self.headerCell = cell
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
case .status(id: let id, state: let state, pinned: let pinned):
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
|
|
||||||
case .loadingIndicator:
|
|
||||||
return loadingIndicatorCell(for: indexPath)
|
|
||||||
case .confirmLoadMore:
|
|
||||||
return confirmLoadMoreCell(for: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
|
||||||
super.viewWillAppear(animated)
|
guard accountID != nil else {
|
||||||
|
completion(.failure(.noClient))
|
||||||
collectionView.indexPathsForSelectedItems?.forEach {
|
|
||||||
collectionView.deselectItem(at: $0, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAccountID(_ id: String) {
|
|
||||||
self.accountID = id
|
|
||||||
// TODO: maybe this function should be async?
|
|
||||||
Task {
|
|
||||||
await load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func load() async {
|
|
||||||
guard accountID != nil,
|
|
||||||
await controller.state == .notLoadedInitial,
|
|
||||||
isViewLoaded else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
getStatuses { (response) in
|
||||||
snapshot.appendSections([.header, .pinned, .statuses])
|
guard self.state == .loadingInitial else {
|
||||||
snapshot.appendItems([.header(accountID)], toSection: .header)
|
return
|
||||||
await apply(snapshot, animatingDifferences: false)
|
|
||||||
print("added header item")
|
|
||||||
|
|
||||||
await controller.loadInitial()
|
|
||||||
await tryLoadPinned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryLoadPinned() async {
|
switch response {
|
||||||
do {
|
case let .failure(error):
|
||||||
try await loadPinned()
|
completion(.failure(.client(error)))
|
||||||
} catch {
|
|
||||||
let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
|
case let .success(statuses, _):
|
||||||
toast.dismissToast(animated: true)
|
if !statuses.isEmpty {
|
||||||
await self.tryLoadPinned()
|
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 {
|
private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
guard case .statuses = kind,
|
guard kind == .statuses,
|
||||||
mastodonController.instanceFeatures.profilePinnedStatuses else {
|
mastodonController.instanceFeatures.profilePinnedStatuses else {
|
||||||
|
completion(.success(snapshot()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getPinnedStatuses { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(statuses, _):
|
||||||
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
var snapshot = snapshot()
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
|
||||||
|
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned)
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
|
guard let older = older else {
|
||||||
|
completion(.failure(.noOlder))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStatuses(for: older) { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(statuses, _):
|
||||||
|
guard !statuses.isEmpty else {
|
||||||
|
completion(.failure(.noOlder))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.older = .before(id: statuses.last!.id, count: nil)
|
||||||
|
|
||||||
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
var snapshot = currentSnapshot()
|
||||||
|
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses)
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) {
|
||||||
|
guard let newer = newer else {
|
||||||
|
completion(.failure(.noNewer))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatuses(for: newer) { (response) in
|
||||||
|
switch response {
|
||||||
|
case let .failure(error):
|
||||||
|
completion(.failure(.client(error)))
|
||||||
|
|
||||||
|
case let .success(statuses, _):
|
||||||
|
guard !statuses.isEmpty else {
|
||||||
|
completion(.failure(.allCaughtUp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||||
|
|
||||||
|
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
var snapshot = currentSnapshot()
|
||||||
|
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) }
|
||||||
|
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
|
||||||
|
snapshot.insertItems(items, beforeItem: first)
|
||||||
|
} else {
|
||||||
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
|
}
|
||||||
|
completion(.success(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) {
|
||||||
|
let request: Request<[Status]>
|
||||||
|
switch kind {
|
||||||
|
case .statuses:
|
||||||
|
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||||
|
case .withReplies:
|
||||||
|
request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
|
||||||
|
case .onlyMedia:
|
||||||
|
request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
|
||||||
|
}
|
||||||
|
mastodonController.run(request, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) {
|
||||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
mastodonController.run(request, completion: completion)
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = dataSource.snapshot()
|
override func refresh() {
|
||||||
let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) }
|
super.refresh()
|
||||||
snapshot.appendItems(items, toSection: .pinned)
|
|
||||||
await apply(snapshot, animatingDifferences: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func refresh() {
|
// only refresh pinned if the super call actually succeded (put the state into .loadingNewer)
|
||||||
Task {
|
if state == .loadingNewer,
|
||||||
// TODO: coalesce these data source updates
|
kind == .statuses {
|
||||||
// TODO: refresh profile
|
loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in
|
||||||
await controller.loadNewer()
|
switch result {
|
||||||
await tryLoadPinned()
|
case .failure(_):
|
||||||
#if !targetEnvironment(macCatalyst)
|
break
|
||||||
collectionView.refreshControl?.endRefreshing()
|
|
||||||
#endif
|
case let .success(snapshot):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,200 +239,26 @@ extension ProfileStatusesViewController {
|
||||||
enum Kind {
|
enum Kind {
|
||||||
case statuses, withReplies, onlyMedia
|
case statuses, withReplies, onlyMedia
|
||||||
}
|
}
|
||||||
enum HeaderMode {
|
|
||||||
case createView, placeholder(height: CGFloat)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileStatusesViewController {
|
extension ProfileStatusesViewController {
|
||||||
enum Section: TimelineLikeCollectionViewSection {
|
enum Section: DiffableTimelineLikeSection {
|
||||||
case header
|
case loadingIndicator
|
||||||
case pinned
|
case pinned
|
||||||
case statuses
|
case statuses
|
||||||
case footer
|
|
||||||
|
|
||||||
static var entries: Self { .statuses }
|
|
||||||
}
|
}
|
||||||
enum Item: TimelineLikeCollectionViewItem {
|
enum Item: DiffableTimelineLikeItem {
|
||||||
typealias TimelineItem = String
|
|
||||||
|
|
||||||
case header(String)
|
|
||||||
case status(id: String, state: StatusState, pinned: Bool)
|
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case status(id: String, state: StatusState, pinned: Bool)
|
||||||
|
|
||||||
static func fromTimelineItem(_ item: String) -> Self {
|
var id: String? {
|
||||||
return .status(id: item, state: .unknown, pinned: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case let (.header(a), .header(b)):
|
|
||||||
return a == b
|
|
||||||
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
|
|
||||||
return a == b && ap == bp
|
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
|
||||||
return true
|
|
||||||
case (.confirmLoadMore, .confirmLoadMore):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
switch self {
|
switch self {
|
||||||
case .header(let id):
|
|
||||||
hasher.combine(0)
|
|
||||||
hasher.combine(id)
|
|
||||||
case .status(id: let id, state: _, pinned: let pinned):
|
|
||||||
hasher.combine(1)
|
|
||||||
hasher.combine(id)
|
|
||||||
hasher.combine(pinned)
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
hasher.combine(2)
|
return nil
|
||||||
case .confirmLoadMore:
|
case .status(id: let id, state: _, pinned: _):
|
||||||
hasher.combine(3)
|
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) ?? []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -428,15 +266,23 @@ extension ProfileStatusesViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController { mastodonController }
|
var apiController: MastodonController { mastodonController }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileStatusesViewController: MenuActionProvider {
|
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
|
||||||
|
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
} else {
|
||||||
|
cellHeightChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
|
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
||||||
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
if let indexPath = collectionView.indexPath(for: cell) {
|
let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id }
|
||||||
var snapshot = dataSource.snapshot()
|
prefetchStatuses(with: ids)
|
||||||
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: false, completion: completion)
|
|
||||||
}
|
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
|
// ProfileViewController.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 10/10/22.
|
// Created by Shadowfacts on 7/3/20.
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
@ -18,43 +18,39 @@ class ProfileViewController: UIPageViewController {
|
||||||
// when first constructed. It should never be set to nil.
|
// when first constructed. It should never be set to nil.
|
||||||
var accountID: String? {
|
var accountID: String? {
|
||||||
willSet {
|
willSet {
|
||||||
precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
|
if newValue == nil {
|
||||||
|
fatalError("Do not set ProfileViewController.accountID to nil")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
didSet {
|
didSet {
|
||||||
pageControllers.forEach { $0.setAccountID(accountID!) }
|
pageControllers.forEach { $0.accountID = accountID }
|
||||||
Task {
|
loadAccount()
|
||||||
await loadAccount()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var accountUpdater: Cancellable?
|
||||||
|
|
||||||
private(set) var currentIndex: Int!
|
private(set) var currentIndex: Int!
|
||||||
private var pageControllers: [ProfileStatusesViewController]!
|
let pageControllers: [ProfileStatusesViewController]
|
||||||
var currentViewController: ProfileStatusesViewController {
|
var currentViewController: ProfileStatusesViewController {
|
||||||
pageControllers[currentIndex]
|
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) {
|
init(accountID: String?, mastodonController: MastodonController) {
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
|
||||||
|
|
||||||
self.pageControllers = [
|
self.pageControllers = [
|
||||||
.init(accountID: accountID, kind: .statuses, owner: self),
|
ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController),
|
||||||
.init(accountID: accountID, kind: .withReplies, owner: self),
|
ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController),
|
||||||
.init(accountID: accountID, kind: .onlyMedia, owner: self),
|
ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController)
|
||||||
]
|
]
|
||||||
|
|
||||||
// try to update the account UI immediately if possible, to avoid the navigation title popping in later
|
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||||
if let accountID,
|
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) {
|
|
||||||
updateAccountUI(account: account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -66,36 +62,35 @@ class ProfileViewController: UIPageViewController {
|
||||||
|
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
for pageController in pageControllers {
|
|
||||||
pageController.profileHeaderDelegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
selectPage(at: 0, animated: false)
|
|
||||||
|
|
||||||
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
|
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
|
||||||
composeButton.menu = UIMenu(children: [
|
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
||||||
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
|
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in
|
||||||
self.composeDirectMentioning()
|
self?.composeDirectMentioning()
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
composeButton.isEnabled = mastodonController.loggedIn
|
composeButton.isEnabled = mastodonController.loggedIn
|
||||||
navigationItem.rightBarButtonItem = composeButton
|
navigationItem.rightBarButtonItem = composeButton
|
||||||
|
|
||||||
|
headerView = ProfileHeaderView.create()
|
||||||
|
headerView.delegate = self
|
||||||
|
|
||||||
|
selectPage(at: 0, animated: false)
|
||||||
|
|
||||||
|
currentViewController.tableView.tableHeaderView = headerView
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
headerView.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
addKeyCommand(MenuController.prevSubTabCommand)
|
addKeyCommand(MenuController.prevSubTabCommand)
|
||||||
addKeyCommand(MenuController.nextSubTabCommand)
|
addKeyCommand(MenuController.nextSubTabCommand)
|
||||||
|
|
||||||
mastodonController.persistentContainer.accountSubject
|
accountUpdater = mastodonController.persistentContainer.accountSubject
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.filter { [unowned self] in $0 == self.accountID }
|
.filter { [weak self] in $0 == self?.accountID }
|
||||||
.sink { [unowned self] id in
|
.sink { [weak self] (_) in self?.updateAccountUI() }
|
||||||
let account = self.mastodonController.persistentContainer.account(for: id)!
|
|
||||||
self.updateAccountUI(account: account)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
Task {
|
loadAccount()
|
||||||
await loadAccount()
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
|
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
|
||||||
if let nav = navigationController {
|
if let nav = navigationController {
|
||||||
|
@ -105,192 +100,168 @@ class ProfileViewController: UIPageViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAccount() async {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
guard let accountID else {
|
super.viewDidAppear(animated)
|
||||||
return
|
|
||||||
|
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 {
|
} else {
|
||||||
do {
|
|
||||||
let req = Client.getAccount(id: accountID)
|
let req = Client.getAccount(id: accountID)
|
||||||
let (account, _) = try await mastodonController.run(req)
|
mastodonController.run(req) { [weak self] (response) in
|
||||||
let mo = await withCheckedContinuation { continuation in
|
guard let self = self else { return }
|
||||||
mastodonController.persistentContainer.addOrUpdate(account: account) { (mo) in
|
switch response {
|
||||||
continuation.resume(returning: mo)
|
case .success(let account, _):
|
||||||
|
self.mastodonController.persistentContainer.addOrUpdate(account: account) { (account) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateAccountUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.updateAccountUI(account: mo)
|
|
||||||
} catch {
|
case .failure(let error):
|
||||||
let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
|
DispatchQueue.main.async {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
await self.loadAccount()
|
self.loadAccount()
|
||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
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 updateAccountUI() {
|
||||||
}
|
guard let accountID = accountID,
|
||||||
|
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||||
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
|
||||||
guard case .idle = state else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state = .animating
|
if let currentAccountID = mastodonController.accountInfo?.id {
|
||||||
|
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
|
||||||
let direction: UIPageViewController.NavigationDirection
|
|
||||||
if currentIndex == nil || index - currentIndex > 0 {
|
|
||||||
direction = .forward
|
|
||||||
} else {
|
|
||||||
direction = .reverse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
guard let old = viewControllers?.first as? ProfileStatusesViewController else {
|
||||||
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
||||||
pageControllers[index].initialHeaderMode = .createView
|
// since it will be added in viewDidLoad
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion)
|
||||||
self.state = .idle
|
|
||||||
completion?(finished)
|
|
||||||
}
|
|
||||||
currentIndex = index
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let new = pageControllers[index]
|
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
|
// Store old's content offset so it can be transferred to new
|
||||||
let oldHeaderCell = old.headerCell!
|
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
|
// Add the header to ourself temporarily, and constrain it to the same position it was in
|
||||||
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
self.view.addSubview(self.headerView)
|
||||||
|
let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top))
|
||||||
if new.isViewLoaded {
|
|
||||||
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
|
|
||||||
} else {
|
|
||||||
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable user interaction during animation, to avoid any potential weird race conditions
|
|
||||||
headerView.isUserInteractionEnabled = false
|
|
||||||
headerView.layer.zPosition = 100
|
|
||||||
view.addSubview(headerView)
|
|
||||||
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
|
|
||||||
// TODO: use safe area layout guide instead of manually adjusting this?
|
|
||||||
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
|
self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor),
|
||||||
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
tempTopConstraint
|
||||||
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// hide scroll indicators during the transition because otherwise the show through the
|
// Setup the inset in new, in case it hasn't been already
|
||||||
// profile header, even though it has an opaque background
|
new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0)
|
||||||
old.collectionView.showsVerticalScrollIndicator = false
|
// Match the scroll positions
|
||||||
if new.isViewLoaded {
|
new.tableView.contentOffset = old.tableView.contentOffset
|
||||||
new.collectionView.showsVerticalScrollIndicator = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
|
// Actually switch pages
|
||||||
if animated,
|
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in
|
||||||
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
// Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation
|
||||||
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
|
DispatchQueue.main.async {
|
||||||
// results in the collection view immediately removing cells that will be offscreen.
|
// Move the header to the new table view
|
||||||
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
|
new.tableView.tableHeaderView = self.headerView
|
||||||
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
|
// Remove the inset, and set the offset back to old's original one, prior to removing the header
|
||||||
// rather than a gap appearing during it.
|
new.tableView.contentInset = .zero
|
||||||
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
|
new.tableView.contentOffset = prevOldContentOffset
|
||||||
let origOldContentOffset = old.collectionView.contentOffset
|
|
||||||
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
|
|
||||||
|
|
||||||
snapshot.frame = old.collectionView.bounds
|
// Deactivate the top constraint, otherwise it sticks around
|
||||||
snapshot.frame.origin.y = 0
|
tempTopConstraint.isActive = false
|
||||||
snapshot.layer.zPosition = 99
|
// Re-add the width constraint since it was removed by re-parenting the view
|
||||||
view.addSubview(snapshot)
|
// 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
|
// Layout and update the table view, otherwise the content jumps around when first scrolling it,
|
||||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
// if old was not scrolled all the way to the top
|
||||||
// animate the snapshot offscreen in the same direction as the old view
|
new.tableView.layoutIfNeeded()
|
||||||
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
|
let snapshot = new.dataSource.snapshot()
|
||||||
// animate the snapshot to be "scrolled" to top
|
new.dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
|
|
||||||
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
|
|
||||||
if new.isViewLoaded {
|
|
||||||
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
|
|
||||||
}
|
|
||||||
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
|
|
||||||
} completion: { _ in
|
|
||||||
snapshot.removeFromSuperview()
|
|
||||||
}
|
|
||||||
} else if new.isViewLoaded {
|
|
||||||
new.collectionView.contentOffset = old.collectionView.contentOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
|
||||||
// reenable scroll indicators after the switching animation is done
|
|
||||||
old.collectionView.showsVerticalScrollIndicator = true
|
|
||||||
new.collectionView.showsVerticalScrollIndicator = true
|
|
||||||
|
|
||||||
headerView.isUserInteractionEnabled = true
|
|
||||||
|
|
||||||
headerView.transform = .identity
|
|
||||||
headerView.layer.zPosition = 0
|
|
||||||
// move the header view into the new page controller's cell
|
|
||||||
// new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load
|
|
||||||
new.headerCell!.addHeader(headerView)
|
|
||||||
|
|
||||||
self.state = .idle
|
|
||||||
completion?(finished)
|
completion?(finished)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: Interaction
|
||||||
|
|
||||||
@objc private func composeMentioning() {
|
@objc private func composeMentioning() {
|
||||||
if let accountID,
|
if let accountID = accountID,
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) {
|
let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||||
compose(mentioningAcct: account.acct)
|
compose(mentioningAcct: account.acct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func composeDirectMentioning() {
|
private func composeDirectMentioning() {
|
||||||
if let accountID,
|
if let accountID = accountID,
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) {
|
let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||||
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
||||||
draft.visibility = .direct
|
draft.visibility = .direct
|
||||||
compose(editing: draft)
|
compose(editing: draft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension ProfileViewController {
|
|
||||||
enum State {
|
|
||||||
case idle
|
|
||||||
case animating
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: TuskerNavigationDelegate {
|
extension ProfileViewController: TuskerNavigationDelegate {
|
||||||
var apiController: MastodonController { mastodonController }
|
var apiController: MastodonController { mastodonController }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: ToastableViewController {
|
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||||
|
func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
||||||
|
// disable user interaction on segmented control while switching pages to prevent
|
||||||
|
// race condition from trying to switch to multiple pages simultaneously
|
||||||
|
view.pagesSegmentedControl.isUserInteractionEnabled = false
|
||||||
|
selectPage(at: newIndex, animated: true) { (finished) in
|
||||||
|
view.pagesSegmentedControl.isUserInteractionEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
extension ProfileViewController: TabBarScrollableViewController {
|
||||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
func tabBarScrollToTop() {
|
||||||
guard case .idle = state else {
|
pageControllers[currentIndex].tabBarScrollToTop()
|
||||||
return
|
|
||||||
}
|
|
||||||
selectPage(at: newIndex, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -305,3 +276,6 @@ extension ProfileViewController: TabbedPageViewController {
|
||||||
selectPage(at: currentIndex - 1, animated: true)
|
selectPage(at: currentIndex - 1, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProfileViewController: ToastableViewController {
|
||||||
|
}
|
||||||
|
|
|
@ -61,8 +61,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
}
|
}
|
||||||
if case .status(_, _) = item {
|
if case .status(_, _) = item {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
@ -250,7 +250,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var hideSeparators: Bool {
|
var hideSeparators: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .loadingIndicator, .publicTimelineDescription, .confirmLoadMore:
|
case .loadingIndicator, .publicTimelineDescription:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -28,12 +28,13 @@ extension TuskerNavigationDelegate {
|
||||||
|
|
||||||
func selected(account accountID: String) {
|
func selected(account accountID: String) {
|
||||||
// don't open if the account is the same as the current one
|
// don't open if the account is the same as the current one
|
||||||
if let profileController = self as? ProfileStatusesViewController,
|
if let profileController = self as? ProfileViewController,
|
||||||
profileController.accountID == accountID {
|
profileController.accountID == accountID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self)
|
// show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self)
|
||||||
|
show(NewProfileViewController(accountID: accountID, mastodonController: apiController), sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selected(mention: Mention) {
|
func selected(mention: Mention) {
|
||||||
|
|
|
@ -12,8 +12,6 @@ import Combine
|
||||||
|
|
||||||
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
|
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
|
||||||
|
|
||||||
static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
|
|
||||||
|
|
||||||
// MARK: Subviews
|
// MARK: Subviews
|
||||||
|
|
||||||
private lazy var reblogLabel = EmojiLabel().configure {
|
private lazy var reblogLabel = EmojiLabel().configure {
|
||||||
|
|
Loading…
Reference in New Issue