Compare commits

...

3 Commits

19 changed files with 800 additions and 687 deletions

View File

@ -420,6 +420,10 @@ extension Client {
public let requestEndpoint: Endpoint
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) {
self.requestMethod = request.method
self.requestEndpoint = request.endpoint

View File

@ -33,6 +33,8 @@
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
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 */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
@ -98,7 +100,6 @@
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */; };
D6420AEF26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */; };
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
@ -115,7 +116,6 @@
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
@ -142,17 +142,14 @@
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationTableViewController.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 */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; };
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; };
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; };
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; };
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; };
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; };
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
@ -168,6 +165,7 @@
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.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 */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
@ -380,6 +378,8 @@
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>"; };
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>"; };
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>"; };
@ -445,7 +445,6 @@
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
D6420AEC26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionTableViewCell.swift; sourceTree = "<group>"; };
D6420AED26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PublicTimelineDescriptionTableViewCell.xib; sourceTree = "<group>"; };
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
@ -462,7 +461,6 @@
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; };
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
@ -491,17 +489,14 @@
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>"; };
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>"; };
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.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>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherIndicatorView.swift; sourceTree = "<group>"; };
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
@ -517,6 +512,7 @@
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>"; };
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>"; };
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>"; };
@ -894,10 +890,11 @@
D641C781213DD7DD004B4513 /* Timeline */ = {
isa = PBXGroup;
children = (
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1125,19 +1122,6 @@
path = Extensions;
sourceTree = "<group>";
};
D6757A7A2157E00100721E32 /* XCallbackURL */ = {
isa = PBXGroup;
children = (
D6757A7B2157E01900721E32 /* XCBManager.swift */,
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */,
D6434EB2215B1856001A919A /* XCBRequest.swift */,
D6757A812157E8FA00721E32 /* XCBSession.swift */,
D64F80E1215875CC00BEF393 /* XCBActionType.swift */,
D679C09E215850EF00DA27FE /* XCBActions.swift */,
);
path = XCallbackURL;
sourceTree = "<group>";
};
D67B506B250B28FF00FAECFB /* Vendor */ = {
isa = PBXGroup;
children = (
@ -1266,6 +1250,7 @@
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
@ -1369,16 +1354,17 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F953F121251A2F00CF0F2B /* API */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6F953F121251A2F00CF0F2B /* Controllers */,
D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
@ -1386,11 +1372,9 @@
D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */,
D6E9CDA6281A426700BBC98E /* Services */,
D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
);
path = Tusker;
sourceTree = "<group>";
@ -1426,6 +1410,7 @@
children = (
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */,
);
path = "Confirm Load More Cell";
sourceTree = "<group>";
@ -1443,14 +1428,6 @@
path = OpenInTusker;
sourceTree = "<group>";
};
D6E9CDA6281A426700BBC98E /* Services */ = {
isa = PBXGroup;
children = (
D6E9CDA7281A427800BBC98E /* PostService.swift */,
);
path = Services;
sourceTree = "<group>";
};
D6F1F84E2193B9BE00F5FE67 /* Caching */ = {
isa = PBXGroup;
children = (
@ -1473,13 +1450,14 @@
path = "Crash Reporter";
sourceTree = "<group>";
};
D6F953F121251A2F00CF0F2B /* Controllers */ = {
D6F953F121251A2F00CF0F2B /* API */ = {
isa = PBXGroup;
children = (
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D6F953EF21251A2900CF0F2B /* MastodonController.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D6E9CDA7281A427800BBC98E /* PostService.swift */,
);
path = Controllers;
path = API;
sourceTree = "<group>";
};
/* End PBXGroup section */
@ -1751,7 +1729,6 @@
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
@ -1825,6 +1802,7 @@
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
@ -1849,7 +1827,6 @@
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
@ -1873,6 +1850,7 @@
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
@ -1890,7 +1868,6 @@
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
@ -1906,6 +1883,7 @@
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */,
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */,
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
@ -1919,12 +1897,12 @@
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
@ -1958,7 +1936,6 @@
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
@ -1972,7 +1949,6 @@
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,

View File

@ -59,9 +59,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
let url = URLContexts.first!.url
if url.host == "x-callback-url" {
_ = XCBManager.handle(url: url)
} else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let rootViewController = rootViewController {
components.scheme = "https"
let query = components.string!

View File

@ -95,17 +95,3 @@ struct MenuController {
}
}
extension MenuController {
class SidebarItem: NSObject, NSCopying {
let item: MainSidebarViewController.Item
init(item: MainSidebarViewController.Item) {
self.item = item
}
func copy(with zone: NSZone? = nil) -> Any {
return SidebarItem(item: self.item)
}
}
}

View File

@ -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 {
}

View File

@ -19,7 +19,7 @@ class TimelinesPageViewController: SegmentedPageViewController {
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let home = TimelineTableViewController(for: .home, mastodonController: mastodonController)
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle
let federated = TimelineTableViewController(for: .public(local: false), mastodonController: mastodonController)

View File

@ -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()
}
}
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -35,30 +35,36 @@ struct 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.subtitle = error.localizedDescription
self.systemImageName = error.systemImageName
// localizedDescription is statically dispatched, so we need to call it after the downcast
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 {
self.actionTitle = "Retry"
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
Task {
await retryAction(toast)

View File

@ -1,29 +0,0 @@
//
// XCBActionType.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
enum XCBActionType: String {
// Statuses
case showStatus
case postStatus
case getStatus
case favoriteStatus
case reblogStatus
// Accounts
case showAccount
case getAccount
case getCurrentUser
case followUser
// Search
case search
var path: String {
return "/\(rawValue)"
}
}

View File

@ -1,354 +0,0 @@
//
// XCBActions.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
struct XCBActions {
// MARK: - Utils
private static var mastodonController: MastodonController {
let scene = UIApplication.shared.activeOrBackgroundScene!
return scene.session.mastodonController!
}
private static func getMainTabBarController() -> MainTabBarViewController {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = scene.windows.first { $0.isKeyWindow }!
return window.rootViewController as! MainTabBarViewController
}
private static func show(_ vc: UIViewController) {
let tabBarController = getMainTabBarController()
if tabBarController.presentedViewController != nil {
tabBarController.presentedViewController?.dismiss(animated: false)
}
tabBarController.selectedViewController!.show(vc, sender: nil)
}
private static func present(_ vc: UIViewController, animated: Bool = true) {
getMainTabBarController().present(vc, animated: animated)
}
private static func getStatus(from request: XCBRequest, session: XCBSession, completion: @escaping (Status) -> Void) {
if let id = request.arguments["statusID"] {
let request = Client.getStatus(id: id)
mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else {
session.complete(with: .error, additionalData: [
"error": "Could not get status with ID \(id)"
])
return
}
completion(status)
}
} else if let searchQuery = request.arguments["statusURL"] {
let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in
if case let .success(results, _) = response,
let status = results.statuses.first {
completion(status)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find status by searching '\(searchQuery)'"
])
}
}
} else {
session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local statusID or remote statusURL."
])
}
}
private static func getAccount(from request: XCBRequest, session: XCBSession, completion: @escaping (Account) -> Void) {
if let id = request.arguments["accountID"] {
let request = Client.getAccount(id: id)
mastodonController.run(request) { (response) in
guard case let .success(account, _) = response else {
session.complete(with: .error, additionalData: [
"error": "Could not get account with ID \(id)"
])
return
}
completion(account)
}
} else if let searchQuery = request.arguments["accountURL"] {
let request = Client.search(query: searchQuery)
mastodonController.run(request) { (response) in
if case let .success(results, _) = response {
if let account = results.accounts.first {
completion(account)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find account by searching '\(searchQuery)'"
])
}
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else if let acct = request.arguments["acct"] {
let request = Client.searchForAccount(query: acct)
mastodonController.run(request) { (response) in
if case let .success(accounts, _) = response {
if let account = accounts.first {
completion(account)
} else {
session.complete(with: .error, additionalData: [
"error": "Could not find account \(acct)"
])
}
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else {
session.complete(with: .error, additionalData: [
"error": "No status provided. Specify either instance-local ID, account URL, or qualified username."
])
}
}
// MARK: - Statuses
static func showStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getStatus(from: request, session: session) { (status) in
DispatchQueue.main.async {
let vc = ConversationTableViewController(for: status.id, mastodonController: mastodonController)
show(vc)
}
}
}
static func postStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let mentioning = request.arguments["mentioning"]
let text = request.arguments["text"]
if silent ?? false {
var status = ""
if let mentioning = mentioning { status += mentioning }
if let text = text { status += text }
guard CharacterCounter.count(text: status) <= mastodonController.instance.maxStatusCharacters ?? 500 else {
session.complete(with: .error, additionalData: [
"error": "Too many characters. Instance maximum is \(mastodonController.instance.maxStatusCharacters ?? 500)"
])
return
}
let request = Client.createStatus(text: status, visibility: Preferences.shared.defaultPostVisibility)
mastodonController.run(request) { response in
if case let .success(status, _) = response {
session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
} else {
// todo: use text param
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
// compose.xcbSession = session
let vc = UINavigationController(rootViewController: compose)
present(vc)
}
}
static func getStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getStatus(from: request, session: session) { (status) in
let html = Bool(request.arguments["html"] ?? "false") ?? false
let content: String
if html {
content = status.content
} else {
do {
let doc = try SwiftSoup.parse(status.content)
content = try doc.body()!.text()
} catch {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
return
}
}
session.complete(with: .success, additionalData: [
"url": status.url?.absoluteString,
"uri": status.uri,
"id": status.id,
"account": status.account.acct,
"inReplyTo": status.inReplyToID,
"posted": status.createdAt.timeIntervalSince1970.description,
"content": content,
"reblog": status.reblog?.id
])
}
}
static func favoriteStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
statusAction(request: Status.favourite, alertTitle: "Favorite status?", request, session, silent)
}
static func reblogStatus(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
statusAction(request: { Status.reblog($0) }, alertTitle: "Reblog status?", request, session, silent)
}
static func statusAction(request: @escaping (String) -> Request<Status>, alertTitle: String, _ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(status: Status, completion: ((Status) -> Void)?) {
mastodonController.run(request(status.id)) { (response) in
if case let .success(status, _) = response {
completion?(status)
session.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
}
func favorite(_ status: Status) {
if silent ?? false {
performAction(status: status, completion: nil)
} else {
let vc = ConversationTableViewController(for: status.id, mastodonController: mastodonController)
DispatchQueue.main.async {
show(vc)
}
let alertController = UIAlertController(title: alertTitle, message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(status: status, completion: { (status) in
DispatchQueue.main.async {
vc.tableView.reloadData()
}
})
}))
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
session.complete(with: .cancel)
}))
DispatchQueue.main.async {
present(alertController)
}
}
}
getStatus(from: url, session: session, completion: favorite)
}
// MARK: - Accounts
static func showAccount(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getAccount(from: request, session: session) { (account) in
DispatchQueue.main.async {
let vc = ProfileViewController(accountID: account.id, mastodonController: mastodonController)
show(vc)
}
}
}
static func getAccount(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
getAccount(from: request, session: session) { (account) in
session.complete(with: .success, additionalData: [
"username": account.acct,
"displayName": account.displayName,
"locked": account.locked.description,
"followers": account.followersCount.description,
"following": account.followingCount.description,
"url": account.url.absoluteString,
"avatarURL": account.avatar?.absoluteString,
"headerURL": account.header?.absoluteString
])
}
}
static func getCurrentUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let account = mastodonController.account!
session.complete(with: .success, additionalData: [
"username": account.acct,
"displayName": account.displayName,
"locked": account.locked.description,
"followers": account.followersCount.description,
"following": account.followingCount.description,
"url": account.url.absoluteString,
"avatarURL": account.avatar?.absoluteString,
"headerURL": account.header?.absoluteString
])
}
static func followUser(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
func performAction(_ account: Account) {
let request = Account.follow(account.id)
mastodonController.run(request) { (response) in
if case .success(_, _) = response {
session.complete(with: .success, additionalData: [
"url": account.url.absoluteString
])
} else if case let .failure(error) = response {
session.complete(with: .error, additionalData: [
"error": error.localizedDescription
])
}
}
}
func follow(_ account: Account) {
if silent ?? false {
performAction(account)
} else {
let vc = ProfileViewController(accountID: account.id, mastodonController: mastodonController)
DispatchQueue.main.async {
show(vc)
}
// todo: update to use managed objects
let alertController = UIAlertController(title: "Follow \(account.displayName)?", message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
performAction(account)
}))
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
session.complete(with: .cancel)
}))
DispatchQueue.main.async {
present(alertController)
}
}
}
getAccount(from: request, session: session, completion: follow)
}
// MARK: - Search
static func search(_ request: XCBRequest, _ session: XCBSession, _ silent: Bool?) {
let query = request.arguments["query"]!
let tabBarController = getMainTabBarController()
if let navigationController = tabBarController.getTabController(tab: .explore) as? UINavigationController,
let exploreController = navigationController.viewControllers.first as? ExploreViewController {
tabBarController.select(tab: .explore)
navigationController.popToRootViewController(animated: false)
exploreController.loadViewIfNeeded()
exploreController.searchController.isActive = true
exploreController.searchController.searchBar.text = query
exploreController.resultsController.performSearch(query: query)
} else {
session.complete(with: .error)
}
}
}

View File

@ -1,46 +0,0 @@
//
// XCBManager.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
class XCBManager {
static var specs: [XCBRequestSpec] = [
// Statuses
XCBRequestSpec(type: .showStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: false, action: XCBActions.showStatus),
XCBRequestSpec(type: .getStatus, arguments: ["statusID": true, "statusURL": true, "html": true], canRunSilently: false, action: XCBActions.getStatus),
XCBRequestSpec(type: .postStatus, arguments: ["mentioning": true, "text": true], canRunSilently: true, action: XCBActions.postStatus),
XCBRequestSpec(type: .favoriteStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: true, action: XCBActions.favoriteStatus),
XCBRequestSpec(type: .reblogStatus, arguments: ["statusID": true, "statusURL": true], canRunSilently: true, action: XCBActions.reblogStatus),
// Accounts
XCBRequestSpec(type: .showAccount, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: false, action: XCBActions.showAccount),
XCBRequestSpec(type: .getAccount, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: false, action: XCBActions.getAccount),
XCBRequestSpec(type: .getCurrentUser, arguments: [:], canRunSilently: false, action: XCBActions.getCurrentUser),
XCBRequestSpec(type: .followUser, arguments: ["accountID": true, "accountURL": true, "acct": true], canRunSilently: true, action: XCBActions.followUser),
// Search
XCBRequestSpec(type: .search, arguments: ["query": false], canRunSilently: false, action: XCBActions.search),
]
static var currentSession: XCBSession?
static func handle(url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return false }
if let spec = specs.first(where: { $0.matches(components) }) {
let request = XCBRequest(spec: spec, components: components)
return spec.handle(request: request)
}
return false
}
static func createSession(type: XCBActionType, request: XCBRequest) -> XCBSession {
let session = XCBSession(type: type, request: request)
currentSession = session
return session
}
}

View File

@ -1,51 +0,0 @@
//
// XCBRequest.swift
// Tusker
//
// Created by Shadowfacts on 9/25/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
struct XCBRequest {
let path: String
let arguments: [String: String]
let json: Bool
let silent: Bool
let source: String?
let success: URL?
let error: URL?
let cancel: URL?
init(spec: XCBRequestSpec, components: URLComponents) {
self.path = spec.path
if let queryItems = components.queryItems {
self.arguments = spec.arguments.reduce(into: [String: String](), { (result, el) in
if let value = queryItems.first(where: { $0.name == el.key })?.value {
result[el.key] = value
}
})
source = queryItems.first(where: { $0.name == "x-source" }).flatMap { $0.value }
success = queryItems.first(where: { $0.name == "x-success" }).flatMap { $0.value }.flatMap { URL(string: $0) }
error = queryItems.first(where: { $0.name == "x-error" }).flatMap { $0.value }.flatMap { URL(string: $0) }
cancel = queryItems.first(where: { $0.name == "x-cancel" }).flatMap { $0.value }.flatMap { URL(string: $0) }
} else {
self.arguments = [:]
source = nil
success = nil
error = nil
cancel = nil
}
if let arg = arguments["json"] {
json = Bool(arg) ?? false
} else {
json = false
}
if spec.canRunSilently, let arg = arguments["silent"] {
silent = Bool(arg) ?? false
} else {
silent = false
}
}
}

View File

@ -1,79 +0,0 @@
//
// XCallbackURL.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
typealias XCBAction = (_ url: XCBRequest, _ session: XCBSession, _ silent: Bool?) -> Void
struct XCBRequestSpec {
let path: String
let type: XCBActionType
let arguments: [String: Bool]
let canRunSilently: Bool
let action: XCBAction
init(type: XCBActionType, arguments: [String: Bool], canRunSilently: Bool, action: @escaping XCBAction) {
self.path = type.path
self.type = type
self.canRunSilently = canRunSilently
self.action = action
var arguments = arguments
if canRunSilently {
arguments["silent"] = true
}
arguments["json"] = true
self.arguments = arguments
}
func handle(request: XCBRequest) -> Bool {
let session = XCBManager.createSession(type: type, request: request)
if canRunSilently && request.silent {
if let source = request.source {
let permission = Preferences.shared.silentActions[source] ?? .undecided
switch permission {
case .accepted:
action(request, session, true)
case .rejected:
action(request, session, false)
case .undecided:
let alert = UIAlertController(title: "\(source) wants to perform actions silently", message: "Accepting will allow \(source) to perform actions without your confirmation, rejecting will always prompt for confirmation.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Accept", style: .default, handler: { (_) in
Preferences.shared.silentActions[source] = .accepted
self.action(request, session, true)
}))
alert.addAction(UIAlertAction(title: "Reject", style: .default, handler: { (_) in
Preferences.shared.silentActions[source] = .rejected
self.action(request, session, false)
}))
UIApplication.shared.keyWindow!.rootViewController!.present(alert, animated: true)
}
} else {
session.complete(with: .error, additionalData: [
"error": "Cannot perform silent action without source app, x-source parameter must be specified."
])
}
} else {
action(request, session, nil)
}
return true
}
}
extension XCBRequestSpec {
func matches(_ components: URLComponents) -> Bool {
guard path == components.path else { return false }
for (name, optional) in arguments {
if (!optional && components.queryItems?.first(where: { $0.name == name }) == nil) {
return false
}
}
return true
}
}

View File

@ -1,45 +0,0 @@
//
// XCBSession.swift
// Tusker
//
// Created by Shadowfacts on 9/23/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
class XCBSession {
static let encoder = JSONEncoder()
let type: XCBActionType
let request: XCBRequest
init(type: XCBActionType, request: XCBRequest) {
self.type = type
self.request = request
}
func complete(with result: XCBSessionResult, additionalData: [String: String?]? = nil) {
guard var url = result == .success ? request.success : result == .error ? request.error : request.cancel else { return }
XCBManager.currentSession = nil
if let additionalData = additionalData {
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
components.queryItems = components.queryItems ?? []
if request.json {
let data = try! XCBSession.encoder.encode(additionalData)
let response = String(data: data, encoding: .utf8)
components.queryItems!.append(URLQueryItem(name: "response", value: response))
} else {
components.queryItems!.append(contentsOf: additionalData.map(URLQueryItem.init))
}
url = components.url!
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:])
}
}
}
enum XCBSessionResult {
case success, error, cancel
}