Initial TimelineLikeController + TimelineViewController implementation
This commit is contained in:
parent
5c09b1910f
commit
426b31d46c
|
@ -420,6 +420,10 @@ extension Client {
|
||||||
public let requestEndpoint: Endpoint
|
public let requestEndpoint: Endpoint
|
||||||
public let type: ErrorType
|
public let type: ErrorType
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse)
|
||||||
|
#endif
|
||||||
|
|
||||||
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
|
init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
|
||||||
self.requestMethod = request.method
|
self.requestMethod = request.method
|
||||||
self.requestEndpoint = request.endpoint
|
self.requestEndpoint = request.endpoint
|
||||||
|
|
|
@ -33,6 +33,8 @@
|
||||||
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
|
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
|
||||||
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
|
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
|
||||||
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
|
||||||
|
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
|
||||||
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.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 */; };
|
||||||
|
@ -140,6 +142,7 @@
|
||||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
|
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */; };
|
||||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
||||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
||||||
|
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||||
|
@ -162,6 +165,7 @@
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
|
||||||
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
||||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
|
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
|
||||||
|
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; };
|
||||||
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
||||||
|
@ -374,6 +378,8 @@
|
||||||
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
|
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
|
||||||
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
|
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
|
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
|
||||||
|
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.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>"; };
|
||||||
|
@ -483,6 +489,7 @@
|
||||||
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
|
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTableViewController.swift; sourceTree = "<group>"; };
|
||||||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||||
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
||||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||||
|
@ -505,6 +512,7 @@
|
||||||
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
|
||||||
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
|
||||||
|
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
|
||||||
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -882,10 +890,11 @@
|
||||||
D641C781213DD7DD004B4513 /* Timeline */ = {
|
D641C781213DD7DD004B4513 /* Timeline */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
|
||||||
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
|
|
||||||
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
|
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
|
||||||
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
|
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
|
||||||
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
||||||
|
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
|
||||||
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||||
);
|
);
|
||||||
path = Timeline;
|
path = Timeline;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1241,6 +1250,7 @@
|
||||||
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
|
||||||
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
|
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||||
|
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
|
||||||
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
||||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||||
|
@ -1347,6 +1357,7 @@
|
||||||
D6B81F432560390300F6E31D /* MenuController.swift */,
|
D6B81F432560390300F6E31D /* MenuController.swift */,
|
||||||
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
|
||||||
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
|
||||||
|
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
|
||||||
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
|
||||||
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
|
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
|
||||||
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
|
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
|
||||||
|
@ -1399,6 +1410,7 @@
|
||||||
children = (
|
children = (
|
||||||
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
|
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
|
||||||
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
|
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
|
||||||
|
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = "Confirm Load More Cell";
|
path = "Confirm Load More Cell";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1790,6 +1802,7 @@
|
||||||
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
|
||||||
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
|
||||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||||
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
|
@ -1837,6 +1850,7 @@
|
||||||
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
|
||||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||||
|
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
|
||||||
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
||||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||||
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
|
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
|
||||||
|
@ -1869,6 +1883,7 @@
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||||
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
||||||
|
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
|
||||||
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
|
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
|
||||||
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
|
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
|
||||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
|
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
|
||||||
|
@ -1882,6 +1897,7 @@
|
||||||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
||||||
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
||||||
|
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
||||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||||
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||||
|
|
|
@ -0,0 +1,396 @@
|
||||||
|
//
|
||||||
|
// TimelineViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/20/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
import SwiftSoup
|
||||||
|
|
||||||
|
// TODO: gonna need a thing to replicate all of EnhancedTableViewController
|
||||||
|
|
||||||
|
class TimelineViewController: UIViewController {
|
||||||
|
|
||||||
|
let timeline: Timeline
|
||||||
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
|
private var controller: TimelineLikeController<TimelineItem>!
|
||||||
|
private var confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
|
private var newer: RequestRange?
|
||||||
|
private var older: RequestRange?
|
||||||
|
|
||||||
|
private var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
|
self.timeline = timeline
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
self.controller = TimelineLikeController(delegate: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
|
// TODO: swipe actions
|
||||||
|
// config.trailingSwipeActionsConfigurationProvider =
|
||||||
|
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
|
||||||
|
if let item = self.dataSource.itemIdentifier(for: indexPath),
|
||||||
|
item.hideSeparators {
|
||||||
|
var config = sectionSeparatorConfiguration
|
||||||
|
config.topSeparatorVisibility = .hidden
|
||||||
|
config.bottomSeparatorVisibility = .hidden
|
||||||
|
return config
|
||||||
|
} else {
|
||||||
|
return sectionSeparatorConfiguration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
|
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
// TODO: delegates
|
||||||
|
collectionView.delegate = self
|
||||||
|
// collectionView.dragDelegate = self
|
||||||
|
|
||||||
|
dataSource = createDataSource()
|
||||||
|
applyInitialSnapshot()
|
||||||
|
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
|
Task {
|
||||||
|
await self.controller.loadNewer()
|
||||||
|
self.collectionView.refreshControl!.endRefreshing()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
collectionView.refreshControl = refreshControl
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
// TODO: refresh key command
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [unowned self] cell, indexPath, item in
|
||||||
|
guard case .status(id: let id, state: _) = item,
|
||||||
|
let status = mastodonController.persistentContainer.status(for: id) else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
var config = cell.defaultContentConfiguration()
|
||||||
|
let doc = try! SwiftSoup.parseBodyFragment(status.content)
|
||||||
|
config.text = try! doc.text()
|
||||||
|
cell.contentConfiguration = config
|
||||||
|
}
|
||||||
|
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
|
||||||
|
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")
|
||||||
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .status(_, _):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier)
|
||||||
|
case .loadingIndicator:
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell
|
||||||
|
cell.indicator.startAnimating()
|
||||||
|
return cell
|
||||||
|
case .confirmLoadMore:
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
||||||
|
cell.confirmLoadMore = self.confirmLoadMore
|
||||||
|
Task {
|
||||||
|
if case .loadingOlder(_, _) = await controller.state {
|
||||||
|
cell.isLoading = true
|
||||||
|
} else {
|
||||||
|
cell.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyInitialSnapshot() {
|
||||||
|
// TODO: this might not be necessary
|
||||||
|
// TODO: yes it is, for public timeline descriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await controller.loadInitial()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineViewController {
|
||||||
|
enum Section: Hashable {
|
||||||
|
case header
|
||||||
|
case statuses
|
||||||
|
case footer
|
||||||
|
}
|
||||||
|
enum Item: Hashable {
|
||||||
|
case status(id: String, state: StatusState)
|
||||||
|
case loadingIndicator
|
||||||
|
case confirmLoadMore
|
||||||
|
// // TODO: remove local param from this
|
||||||
|
// case publicTimelineDescription(local: Bool)
|
||||||
|
|
||||||
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||||
|
return a == b
|
||||||
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
|
return true
|
||||||
|
case (.confirmLoadMore, .confirmLoadMore):
|
||||||
|
return true
|
||||||
|
// case let (.publicTimelineDescription(local: a), .publicTimelineDescription(local: b)):
|
||||||
|
// return a == b
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
switch self {
|
||||||
|
case .status(id: let id, state: _):
|
||||||
|
hasher.combine(0)
|
||||||
|
hasher.combine(id)
|
||||||
|
case .loadingIndicator:
|
||||||
|
hasher.combine(1)
|
||||||
|
case .confirmLoadMore:
|
||||||
|
hasher.combine(2)
|
||||||
|
// case .publicTimelineDescription(local: let local):
|
||||||
|
// hasher.combine(3)
|
||||||
|
// hasher.combine(local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hideSeparators: Bool {
|
||||||
|
switch self {
|
||||||
|
case .loadingIndicator:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineViewController: TimelineLikeControllerDelegate {
|
||||||
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
|
guard let mastodonController else {
|
||||||
|
throw Error.noClient
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||||
|
|
||||||
|
let request = Client.getStatuses(timeline: timeline)
|
||||||
|
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 -> [TimelineItem] {
|
||||||
|
guard let newer else {
|
||||||
|
throw Error.noNewer
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Client.getStatuses(timeline: timeline, range: 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 -> [TimelineItem] {
|
||||||
|
guard let older else {
|
||||||
|
throw Error.noOlder
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||||
|
|
||||||
|
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||||
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
|
if !statuses.isEmpty {
|
||||||
|
self.older = .before(id: statuses.last!.id, count: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canLoadOlder() async -> Bool {
|
||||||
|
if Preferences.shared.disableInfiniteScrolling {
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if !snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
||||||
|
if !snapshot.sectionIdentifiers.contains(.footer) {
|
||||||
|
snapshot.appendSections([.footer])
|
||||||
|
}
|
||||||
|
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
for await _ in self.confirmLoadMore.values {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
fatalError("unreachable")
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async {
|
||||||
|
switch event {
|
||||||
|
case .addLoadingIndicator:
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if !snapshot.sectionIdentifiers.contains(.footer) {
|
||||||
|
snapshot.appendSections([.footer])
|
||||||
|
}
|
||||||
|
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
||||||
|
snapshot.reconfigureItems([.confirmLoadMore])
|
||||||
|
} else {
|
||||||
|
snapshot.appendItems([.loadingIndicator], toSection: .footer)
|
||||||
|
}
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
case .removeLoadingIndicator:
|
||||||
|
let oldContentOffset = collectionView.contentOffset
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
snapshot.deleteSections([.footer])
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
collectionView.contentOffset = oldContentOffset
|
||||||
|
|
||||||
|
case .loadAllError(let error, _):
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast: ToastView) in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
Task {
|
||||||
|
await self?.controller.loadInitial()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
case .replaceAllItems(let ids, _):
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.sectionIdentifiers.contains(.statuses) {
|
||||||
|
snapshot.deleteSections([.statuses])
|
||||||
|
}
|
||||||
|
snapshot.appendSections([.statuses])
|
||||||
|
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
case .loadNewerError(Error.allCaughtUp, _):
|
||||||
|
var config = ToastConfiguration(title: "You're all caught up")
|
||||||
|
config.edge = .top
|
||||||
|
config.dismissAutomaticallyAfter = 2
|
||||||
|
config.action = { (toast) in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
case .loadNewerError(let error, _):
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast: ToastView) in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
Task {
|
||||||
|
await self?.controller.loadNewer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
case .prependItems(let ids, _):
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
let items = ids.map { Item.status(id: $0, state: .unknown) }
|
||||||
|
let first = snapshot.itemIdentifiers(inSection: .statuses).first
|
||||||
|
if let first {
|
||||||
|
snapshot.insertItems(items, beforeItem: first)
|
||||||
|
} else {
|
||||||
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
|
}
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
|
if let first,
|
||||||
|
let indexPath = dataSource.indexPath(for: first) {
|
||||||
|
// TODO: i can't tell if this actually works or not
|
||||||
|
// maintain the current position in the list (don't scroll to top)
|
||||||
|
collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loadOlderError(let error, _):
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast: ToastView) in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
Task {
|
||||||
|
await self?.controller.loadOlder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
case .appendItems(let ids, _):
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.itemIdentifiers.contains(.confirmLoadMore) {
|
||||||
|
snapshot.deleteItems([.confirmLoadMore])
|
||||||
|
}
|
||||||
|
snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
|
||||||
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error: Swift.Error {
|
||||||
|
case noClient
|
||||||
|
case noNewer
|
||||||
|
case noOlder
|
||||||
|
case allCaughtUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||||
|
guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section),
|
||||||
|
case .status(_, _) = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
|
||||||
|
if indexPath.row == itemsInSection - 1 {
|
||||||
|
Task {
|
||||||
|
await controller.loadOlder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineViewController: ToastableViewController {
|
||||||
|
}
|
|
@ -19,7 +19,7 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
let home = TimelineTableViewController(for: .home, mastodonController: mastodonController)
|
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
|
||||||
home.title = homeTitle
|
home.title = homeTitle
|
||||||
|
|
||||||
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController)
|
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController)
|
||||||
|
|
|
@ -0,0 +1,255 @@
|
||||||
|
//
|
||||||
|
// TimelineLikeController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/19/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
|
associatedtype TimelineItem
|
||||||
|
|
||||||
|
func loadInitial() async throws -> [TimelineItem]
|
||||||
|
|
||||||
|
func loadNewer() async throws -> [TimelineItem]
|
||||||
|
|
||||||
|
func loadOlder() async throws -> [TimelineItem]
|
||||||
|
|
||||||
|
func canLoadOlder() async -> Bool
|
||||||
|
|
||||||
|
func handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) async
|
||||||
|
}
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||||
|
|
||||||
|
actor TimelineLikeController<Item> {
|
||||||
|
|
||||||
|
unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
|
||||||
|
private(set) var state = State.idle {
|
||||||
|
willSet {
|
||||||
|
precondition(state.canTransition(to: newValue))
|
||||||
|
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(delegate: any TimelineLikeControllerDelegate<Item>) {
|
||||||
|
self.delegate = delegate
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInitial() async {
|
||||||
|
guard state == .idle else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let token = LoadAttemptToken()
|
||||||
|
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
|
||||||
|
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||||
|
do {
|
||||||
|
let items = try await delegate.loadInitial()
|
||||||
|
guard case .loadingInitial(token, _) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadingIndicator.end()
|
||||||
|
await emit(event: .replaceAllItems(items, token))
|
||||||
|
state = .idle
|
||||||
|
} catch {
|
||||||
|
await loadingIndicator.end()
|
||||||
|
await emit(event: .loadAllError(error, token))
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNewer() async {
|
||||||
|
guard state == .idle else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let token = LoadAttemptToken()
|
||||||
|
state = .loadingNewer(token)
|
||||||
|
do {
|
||||||
|
let items = try await delegate.loadNewer()
|
||||||
|
guard case .loadingNewer(token) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await emit(event: .prependItems(items, token))
|
||||||
|
state = .idle
|
||||||
|
} catch {
|
||||||
|
await emit(event: .loadNewerError(error, token))
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOlder() async {
|
||||||
|
guard state == .idle else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let token = LoadAttemptToken()
|
||||||
|
// TODO: does the waiting state need to include the token?
|
||||||
|
// TODO: does this even need to be a separate state? maybe we should just await the delegate's permission, since it can suspend until user input. then the prompt could appear, and the user could scroll back to the top and still be able to refresh
|
||||||
|
// state = .waitingForLoadOlderPermission
|
||||||
|
guard await delegate.canLoadOlder() else {
|
||||||
|
// state = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
|
||||||
|
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||||
|
do {
|
||||||
|
let items = try await delegate.loadOlder()
|
||||||
|
guard case .loadingOlder(token, _) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadingIndicator.end()
|
||||||
|
await emit(event: .appendItems(items, token))
|
||||||
|
state = .idle
|
||||||
|
} catch {
|
||||||
|
await loadingIndicator.end()
|
||||||
|
await emit(event: .loadOlderError(error, token))
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transition(to newState: State) {
|
||||||
|
self.state = newState
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(event: Event) async {
|
||||||
|
precondition(state.canEmit(event: event))
|
||||||
|
await delegate.handleEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State: Equatable, CustomDebugStringConvertible {
|
||||||
|
case idle
|
||||||
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
|
case loadingNewer(LoadAttemptToken)
|
||||||
|
// case waitingForLoadOlderPermission
|
||||||
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
|
|
||||||
|
var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .idle:
|
||||||
|
return "idle"
|
||||||
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
|
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
|
case .loadingNewer(let token):
|
||||||
|
return "loadingNewer(\(ObjectIdentifier(token)))"
|
||||||
|
// case .waitingForLoadOlderPermission:
|
||||||
|
// return "waitingForLoadOlderPermission"
|
||||||
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canTransition(to: State) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .idle:
|
||||||
|
switch to {
|
||||||
|
case .loadingInitial(_, _), .loadingNewer(_)/*, .waitingForLoadOlderPermission*/, .loadingOlder(_, _):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||||
|
case .loadingNewer(_):
|
||||||
|
return to == .idle
|
||||||
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||||
|
// case .waitingForLoadOlderPermission:
|
||||||
|
// switch to {
|
||||||
|
// case .idle, .loadingOlder(_, _):
|
||||||
|
// return true
|
||||||
|
// default:
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEmit(event: Event) -> Bool {
|
||||||
|
switch event {
|
||||||
|
case .addLoadingIndicator:
|
||||||
|
switch self {
|
||||||
|
case .loadingInitial(_, _), .loadingOlder(_, _):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .removeLoadingIndicator:
|
||||||
|
switch self {
|
||||||
|
case .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .loadAllError(_, let token), .replaceAllItems(_, let token):
|
||||||
|
switch self {
|
||||||
|
case .loadingInitial(token, _):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .loadNewerError(_, let token), .prependItems(_, let token):
|
||||||
|
switch self {
|
||||||
|
case .loadingNewer(token):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case .loadOlderError(_, let token), .appendItems(_, let token):
|
||||||
|
switch self {
|
||||||
|
case .loadingOlder(token, _):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
case addLoadingIndicator
|
||||||
|
case removeLoadingIndicator
|
||||||
|
case loadAllError(Error, LoadAttemptToken)
|
||||||
|
case replaceAllItems([Item], LoadAttemptToken)
|
||||||
|
case loadNewerError(Error, LoadAttemptToken)
|
||||||
|
case prependItems([Item], LoadAttemptToken)
|
||||||
|
case loadOlderError(Error, LoadAttemptToken)
|
||||||
|
case appendItems([Item], LoadAttemptToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoadAttemptToken: Equatable {
|
||||||
|
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
|
||||||
|
return lhs === rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeferredLoadingIndicator {
|
||||||
|
private let owner: TimelineLikeController<Item>
|
||||||
|
private let addedIndicatorState: State
|
||||||
|
private let task: Task<Void, Error>
|
||||||
|
|
||||||
|
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
|
||||||
|
self.owner = owner
|
||||||
|
self.addedIndicatorState = addedIndicatorState
|
||||||
|
self.task = Task {
|
||||||
|
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
|
||||||
|
guard await state == owner.state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await owner.emit(event: .addLoadingIndicator)
|
||||||
|
await owner.transition(to: addedIndicatorState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() async {
|
||||||
|
let state = await owner.state
|
||||||
|
if state == addedIndicatorState {
|
||||||
|
await owner.emit(event: .removeLoadingIndicator)
|
||||||
|
} else {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
//
|
||||||
|
// ConfirmLoadMoreCollectionViewCell.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/21/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var confirmLoadMore: PassthroughSubject<Void, Never>?
|
||||||
|
var isLoading: Bool {
|
||||||
|
get {
|
||||||
|
button.configuration?.showsActivityIndicator ?? false
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
var config = button.configuration!
|
||||||
|
config.showsActivityIndicator = newValue
|
||||||
|
button.configuration = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var button: UIButton!
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
backgroundColor = .secondarySystemBackground
|
||||||
|
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
||||||
|
label.textColor = .secondaryLabel
|
||||||
|
label.textAlignment = .natural
|
||||||
|
label.numberOfLines = 0
|
||||||
|
|
||||||
|
var config = UIButton.Configuration.tinted()
|
||||||
|
config.title = "Load More"
|
||||||
|
config.imagePadding = 4
|
||||||
|
button = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in
|
||||||
|
self.confirmLoadMore?.send()
|
||||||
|
}))
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
label,
|
||||||
|
button,
|
||||||
|
])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.distribution = .fill
|
||||||
|
stack.spacing = 4
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// LoadingCollectionViewCell.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/24/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class LoadingCollectionViewCell: UICollectionViewCell {
|
||||||
|
let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(indicator)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
indicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||||
|
indicator.topAnchor.constraint(equalToSystemSpacingBelow: contentView.topAnchor, multiplier: 1),
|
||||||
|
contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: indicator.bottomAnchor, multiplier: 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,30 +35,36 @@ struct ToastConfiguration {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ToastConfiguration {
|
extension ToastConfiguration {
|
||||||
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
|
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
|
||||||
self.init(title: title)
|
self.init(title: title)
|
||||||
self.subtitle = error.localizedDescription
|
// localizedDescription is statically dispatched, so we need to call it after the downcast
|
||||||
self.systemImageName = error.systemImageName
|
if let error = error as? Client.Error {
|
||||||
|
self.subtitle = error.localizedDescription
|
||||||
|
self.systemImageName = error.systemImageName
|
||||||
|
self.longPressAction = { [unowned viewController] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
let text = """
|
||||||
|
\(title):
|
||||||
|
\(error.requestMethod.name) \(error.requestEndpoint)
|
||||||
|
|
||||||
|
\(error.type)
|
||||||
|
"""
|
||||||
|
let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in
|
||||||
|
viewController.dismiss(animated: true)
|
||||||
|
})
|
||||||
|
viewController.present(reporter, animated: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.subtitle = error.localizedDescription
|
||||||
|
self.systemImageName = "exclamationmark.triangle"
|
||||||
|
}
|
||||||
if let retryAction = retryAction {
|
if let retryAction = retryAction {
|
||||||
self.actionTitle = "Retry"
|
self.actionTitle = "Retry"
|
||||||
self.action = retryAction
|
self.action = retryAction
|
||||||
}
|
}
|
||||||
self.longPressAction = { [unowned viewController] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
let text = """
|
|
||||||
\(title):
|
|
||||||
\(error.requestMethod.name) \(error.requestEndpoint)
|
|
||||||
|
|
||||||
\(error.type)
|
|
||||||
"""
|
|
||||||
let reporter = IssueReporterViewController.create(reportText: text, dismiss: { [unowned viewController] in
|
|
||||||
viewController.dismiss(animated: true)
|
|
||||||
})
|
|
||||||
viewController.present(reporter, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
|
init(from error: Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
|
||||||
self.init(from: error, with: title, in: viewController) { toast in
|
self.init(from: error, with: title, in: viewController) { toast in
|
||||||
Task {
|
Task {
|
||||||
await retryAction(toast)
|
await retryAction(toast)
|
||||||
|
|
Loading…
Reference in New Issue