diff --git a/.gitignore b/.gitignore index 71c83743..fc6431b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +MyPlayground.playground/ ### Swift ### # Xcode diff --git a/MyPlayground.playground/Contents.swift b/MyPlayground.playground/Contents.swift deleted file mode 100644 index dc558611..00000000 --- a/MyPlayground.playground/Contents.swift +++ /dev/null @@ -1,58 +0,0 @@ -import UIKit - -class Client { - func test(_ thing: A) { - if var thing = thing as? ClientModel { - thing.client = self - } else if var arr = thing as? [ClientModel] { - arr.client = self - } -// } else if let arr = thing as? Array { -// for el in arr { -// if var el = el as? ClientModel { -// el.client = self -// } -// } -// } - } -} - -protocol ClientModel { - var client: Client! { get set } -} - -class Something: ClientModel { - var client: Client! -} - -extension Array: ClientModel where Element: ClientModel { - var client: Client! { - get { - return first?.client - } - set { - for var el in self { - el.client = newValue - } - } - } -} -//extension Array: ClientModel where Element == ClientModel { -// var client: Client! { -// get { -// return first?.client -// } -// set { -// for var el in self { -// el.client = newValue -// } -// } -// } -//} - -var array = [Something(), Something()] - -let client = Client() -client.test(array) -array[0].client -array[1].client diff --git a/MyPlayground.playground/contents.xcplayground b/MyPlayground.playground/contents.xcplayground deleted file mode 100644 index 9f5f2f40..00000000 --- a/MyPlayground.playground/contents.xcplayground +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index c03db8e8..a60820f6 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -286,14 +286,14 @@ public class Client { media: [Attachment]? = nil, sensitive: Bool? = nil, spoilerText: String? = nil, - visiblity: Status.Visibility? = nil, + visibility: Status.Visibility? = nil, language: String? = nil) -> Request { return Request(method: .post, path: "/api/v1/statuses", body: .parameters([ "status" => text, "in_reply_to_id" => inReplyTo, "sensitive" => sensitive, "spoiler_text" => spoilerText, - "visibility" => visiblity?.rawValue, + "visibility" => visibility?.rawValue, "language" => language ] + "media_ids" => media?.map { $0.id })) } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 0a8db48e..f5cb14f3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; + D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; }; D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */; }; @@ -92,6 +93,10 @@ D667E5F32135BC260057A976 /* Conversation.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D667E5F22135BC260057A976 /* Conversation.storyboard */; }; D667E5F52135BCD50057A976 /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F42135BCD50057A976 /* ConversationViewController.swift */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; + D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; }; + D6757A7E2157E02600721E32 /* XCallbackURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCallbackURL.swift */; }; + D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; }; + D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; }; D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */; }; D6C94D852139DFD800CB5196 /* LargeImage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6C94D842139DFD800CB5196 /* LargeImage.storyboard */; }; @@ -224,6 +229,7 @@ D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; }; D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; + D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConversationMainStatusTableViewCell.xib; sourceTree = ""; }; D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusTableViewCell.swift; sourceTree = ""; }; @@ -247,6 +253,10 @@ D667E5F22135BC260057A976 /* Conversation.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Conversation.storyboard; sourceTree = ""; }; D667E5F42135BCD50057A976 /* ConversationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = ""; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = ""; }; + D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = ""; }; + D6757A7D2157E02600721E32 /* XCallbackURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCallbackURL.swift; sourceTree = ""; }; + D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = ""; }; + D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = ""; }; D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; D6C94D842139DFD800CB5196 /* LargeImage.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LargeImage.storyboard; sourceTree = ""; }; @@ -566,6 +576,18 @@ path = Extensions; sourceTree = ""; }; + D6757A7A2157E00100721E32 /* XCallbackURL */ = { + isa = PBXGroup; + children = ( + D6757A7B2157E01900721E32 /* XCBManager.swift */, + D6757A7D2157E02600721E32 /* XCallbackURL.swift */, + D6757A812157E8FA00721E32 /* XCBSession.swift */, + D64F80E1215875CC00BEF393 /* XCBActionType.swift */, + D679C09E215850EF00DA27FE /* XCBActions.swift */, + ); + path = XCallbackURL; + sourceTree = ""; + }; D6BED1722126661300F02DA0 /* Views */ = { isa = PBXGroup; children = ( @@ -616,6 +638,7 @@ D64D0AAC2128D88B005A6F37 /* LocalData.swift */, 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */, D6028B9A2150811100F223B9 /* MastodonCache.swift */, + D6757A7A2157E00100721E32 /* XCallbackURL */, D663626021360A9600C9CBA2 /* Preferences */, D667E5F62135C2ED0057A976 /* Extensions */, D6F953F121251A2F00CF0F2B /* Controllers */, @@ -767,7 +790,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1000; - LastUpgradeCheck = 1000; + LastUpgradeCheck = 1010; ORGANIZATIONNAME = Shadowfacts; TargetAttributes = { D61099AA2144B0CC00432DC2 = { @@ -921,6 +944,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */, 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationViewController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, @@ -933,6 +957,7 @@ D6333B772138D94E00CE884A /* ComposeMediaView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, + D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */, D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, @@ -949,6 +974,7 @@ D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D663626821360E2C00C9CBA2 /* PreferencesTableViewController.swift in Sources */, D66362732136FFC600C9CBA2 /* UITextView+Placeholder.swift in Sources */, + D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */, D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, @@ -956,7 +982,9 @@ D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */, D641C77D213CB024004B4513 /* FollowNotificationTableViewCell.swift in Sources */, D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */, + D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, D64A0CD32132153900640E3B /* HTMLContentLabel.swift in Sources */, + D6757A7E2157E02600721E32 /* XCallbackURL.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */, @@ -1034,7 +1062,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; @@ -1064,7 +1092,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; diff --git a/Tusker.xcworkspace/contents.xcworkspacedata b/Tusker.xcworkspace/contents.xcworkspacedata index d751e971..bb8fa0df 100644 --- a/Tusker.xcworkspace/contents.xcworkspacedata +++ b/Tusker.xcworkspace/contents.xcworkspacedata @@ -1,9 +1,6 @@ - - diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 51e3a3e9..f3e41416 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -26,6 +26,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + if url.host == "x-callback-url" { + return XCBManager.handle(url: url) + } + return false + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. @@ -52,7 +59,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } extension AppDelegate: OnboardingViewControllerDelegate { - func showOnboarding() { if let window = self.window, let onboardingViewController = UIStoryboard(name: "Onboarding", bundle: nil).instantiateInitialViewController() as? OnboardingViewController { @@ -73,6 +79,4 @@ extension AppDelegate: OnboardingViewControllerDelegate { hideOnboarding() LocalData.shared.onboardingComplete = true } - } - diff --git a/Tusker/Info.plist b/Tusker/Info.plist index d23f5ebc..57e31d57 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -2,12 +2,6 @@ - NSPhotoLibraryAddUsageDescription - Save photos directly from other people's posts. - NSCameraUsageDescription - Post photos from the camera. - NSPhotoLibraryUsageDescription - Post photos from the photo library. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -39,6 +33,12 @@ 1 LSRequiresIPhoneOS + NSCameraUsageDescription + Post photos from the camera. + NSPhotoLibraryAddUsageDescription + Save photos directly from other people's posts. + NSPhotoLibraryUsageDescription + Post photos from the photo library. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/Tusker/MastodonCache.swift b/Tusker/MastodonCache.swift index f77baee7..c7afafab 100644 --- a/Tusker/MastodonCache.swift +++ b/Tusker/MastodonCache.swift @@ -11,17 +11,20 @@ import Pachyderm class MastodonCache { - private static let statuses = NSCache() - private static let accounts = NSCache() - private static let relationships = NSCache() +// private static let statuses = NSDictionary() +// private static let accounts = NSDictionary() +// private static let relationships = NSCache() + private static var statuses = [String: Status]() + private static var accounts = [String: Account]() + private static var relationships = [String: Relationship]() // MARK: - Statuses static func status(for id: String) -> Status? { - return statuses.object(forKey: id as NSString) + return statuses[id] } static func set(status: Status, for id: String) { - statuses.setObject(status, forKey: id as NSString) + statuses[id] = status add(account: status.account) if let reblog = status.reblog { add(account: reblog.account) @@ -50,11 +53,11 @@ class MastodonCache { // MARK: - Accounts static func account(for id: String) -> Account? { - return accounts.object(forKey: id as NSString) + return accounts[id] } static func set(account: Account, for id: String) { - accounts.setObject(account, forKey: id as NSString) + accounts[id] = account } static func account(for id: String, completion: @escaping (Account?) -> Void) { @@ -79,11 +82,11 @@ class MastodonCache { // MARK: - Relationships static func relationship(for id: String) -> Relationship? { - return relationships.object(forKey: id as NSString) + return relationships[id] } static func set(relationship: Relationship, id: String) { - relationships.setObject(relationship, forKey: id as NSString) + relationships[id] = relationship } static func relationship(for id: String, completion: @escaping (Relationship?) -> Void) { diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index 529726eb..207e3d01 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -39,4 +39,12 @@ class Preferences: Codable { var defaultPostVisibility = Status.Visibility.public + var silentActions: [String: Permission] = [:] + +} + +extension Preferences { + enum Permission: String, Codable { + case undecided, accepted, rejected + } } diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift index fb428081..d1870001 100644 --- a/Tusker/Screens/Compose/ComposeViewController.swift +++ b/Tusker/Screens/Compose/ComposeViewController.swift @@ -11,11 +11,21 @@ import Pachyderm class ComposeViewController: UIViewController { - static func create(inReplyTo inReplyToID: String? = nil, mentioning: Account? = nil) -> UIViewController { + static func create(inReplyTo inReplyToID: String? = nil, mentioning: String? = nil, text: String? = nil) -> UIViewController { guard let navigationController = UIStoryboard(name: "Compose", bundle: nil).instantiateInitialViewController() as? UINavigationController, let composeVC = navigationController.topViewController as? ComposeViewController else { fatalError() } composeVC.inReplyToID = inReplyToID - composeVC.mentioning = mentioning + composeVC.mentioningAcct = mentioning + composeVC.text = text + return navigationController + } + + static func create(for session: XCBSession, mentioning: String? = nil, text: String? = nil) -> UIViewController { + guard let navigationController = UIStoryboard(name: "Compose", bundle: nil).instantiateInitialViewController() as? UINavigationController, + let composeVC = navigationController.topViewController as? ComposeViewController else { fatalError() } + composeVC.mentioningAcct = mentioning + composeVC.text = text + composeVC.xcbSession = session return navigationController } @@ -37,7 +47,11 @@ class ComposeViewController: UIViewController { var scrolled = false var inReplyToID: String? - var mentioning: Account? + var mentioningAcct: String? + var text: String? + + // Weak so that if a new session is initiated (i.e. XCBManager.currentSession is changed) while the current one is in progress, this one will be released + weak var xcbSession: XCBSession? var contentWarning = false { didSet { @@ -95,8 +109,12 @@ class ComposeViewController: UIViewController { inReplyToContainerView.isHidden = true } - if let mentioning = mentioning { - statusTextView.text += "@\(mentioning.acct) " + if let mentioningAcct = mentioningAcct { + statusTextView.text += "@\(mentioningAcct) " + statusTextView.textViewDidChange(statusTextView) + } + if let text = text { + statusTextView.text += text statusTextView.textViewDidChange(statusTextView) } @@ -128,6 +146,8 @@ class ComposeViewController: UIViewController { let navController = dest.selectedViewController as? UINavigationController, let topVC = navController.topViewController as? StatusTableViewCellDelegate else { return } topVC.selected(status: status.id) + } else if segue.identifier == "cancel" { + xcbSession?.complete(with: .cancel) } } @@ -224,7 +244,7 @@ class ComposeViewController: UIViewController { media: attachments, sensitive: sensitive, spoilerText: contentWarning, - visiblity: visibility) + visibility: visibility) MastodonController.shared.client.run(request) { response in guard case let .success(status, _) = response else { fatalError() } self.status = status @@ -232,6 +252,11 @@ class ComposeViewController: UIViewController { DispatchQueue.main.async { self.progressView.step() self.performSegue(withIdentifier: "postComplete", sender: self) + + self.xcbSession?.complete(with: .success, additionalData: [ + "statusURL": status.url?.absoluteString, + "statusURI": status.uri + ]) } } } diff --git a/Tusker/Screens/Profile/ProfileTableViewController.swift b/Tusker/Screens/Profile/ProfileTableViewController.swift index 2c8faa42..fdf23914 100644 --- a/Tusker/Screens/Profile/ProfileTableViewController.swift +++ b/Tusker/Screens/Profile/ProfileTableViewController.swift @@ -74,7 +74,7 @@ class ProfileTableViewController: UITableViewController, PreferencesAdaptive { func sendMessageMentioning() { guard let account = MastodonCache.account(for: accountID) else { fatalError("Missing cached account \(accountID!)") } - let vc = ComposeViewController.create(mentioning: account) + let vc = ComposeViewController.create(mentioning: account.acct) present(vc, animated: true) } diff --git a/Tusker/XCallbackURL/XCBActionType.swift b/Tusker/XCallbackURL/XCBActionType.swift new file mode 100644 index 00000000..238e4510 --- /dev/null +++ b/Tusker/XCallbackURL/XCBActionType.swift @@ -0,0 +1,21 @@ +// +// XCBActionType.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation + +enum XCBActionType: String { + // Statuses + case postStatus + case favoriteStatus + // Accounts + case getCurrentUser + + var path: String { + return "/\(rawValue)" + } +} diff --git a/Tusker/XCallbackURL/XCBActions.swift b/Tusker/XCallbackURL/XCBActions.swift new file mode 100644 index 00000000..b897aa2f --- /dev/null +++ b/Tusker/XCallbackURL/XCBActions.swift @@ -0,0 +1,136 @@ +// +// XCBActions.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +struct XCBActions { + + // MARK: - Utils + static func presentModally(_ vc: UIViewController, animated: Bool, completion: (() -> Void)? = nil) { + UIApplication.shared.keyWindow!.rootViewController!.present(vc, animated: animated, completion: completion) + } + + static func presentNav(_ vc: UIViewController, animated: Bool) { + let tabBarController = UIApplication.shared.keyWindow!.rootViewController! as! UITabBarController + let navController = tabBarController.selectedViewController as! UINavigationController + navController.pushViewController(vc, animated: animated) + } + + // MARK: - Statuses + static func postStatus(_ url: XCallbackURL, _ session: XCBSession, _ silent: Bool?) { + let mentioning = url.arguments["mentioning"] + let text = url.arguments["text"] + + if silent ?? false { + var status = "" + if let mentioning = mentioning { status += mentioning } + if let text = text { status += text } + let request = MastodonController.shared.client.createStatus(text: status, visibility: Preferences.shared.defaultPostVisibility) + MastodonController.shared.client.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 { + let vc = ComposeViewController.create(for: session, mentioning: mentioning, text: text) + presentModally(vc, animated: true) + } + } + + static func favoriteStatus(_ url: XCallbackURL, _ session: XCBSession, _ silent: Bool?) { + func performAction(status: Status, completion: ((Status) -> Void)?) { + let request = Status.favourite(status) + MastodonController.shared.client.run(request) { (response) in + if case let .success(status, _) = response { + MastodonCache.add(status: status) + 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 = ConversationViewController.create(for: status.id) + DispatchQueue.main.async { + presentNav(vc, animated: true) + } + let alertController = UIAlertController(title: "Favorite status?", 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 { + presentModally(alertController, animated: true) + } + } + } + + if let id = url.arguments["statusID"] { + MastodonCache.status(for: id) { (status) in + if let status = status { + favorite(status) + } + } + } else if let searchQuery = url.arguments["statusURL"] ?? url.arguments["statusURI"] { + let request = MastodonController.shared.client.search(query: searchQuery) + MastodonController.shared.client.run(request) { (response) in + if case let .success(results, _) = response, + let status = results.statuses.first { + favorite(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/statusURI" + ]) + } + } + + // MARK: - Accounts + static func getCurrentUser(_ url: XCallbackURL, _ session: XCBSession, _ silent: Bool?) { + let account = MastodonController.shared.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, + ]) + } +} diff --git a/Tusker/XCallbackURL/XCBManager.swift b/Tusker/XCallbackURL/XCBManager.swift new file mode 100644 index 00000000..ed90bfa2 --- /dev/null +++ b/Tusker/XCallbackURL/XCBManager.swift @@ -0,0 +1,38 @@ +// +// XCBManager.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit + +class XCBManager { + + static var specs: [XCallbackURLSpec] = [ + // Statuses + XCallbackURLSpec(type: .postStatus, arguments: ["mentioning": true, "text": true], canRunSilently: true, action: XCBActions.postStatus), + XCallbackURLSpec(type: .favoriteStatus, arguments: ["statusID": true, "statusURL": true, "statusURI": true], canRunSilently: true, action: XCBActions.favoriteStatus), + // Accounts + XCallbackURLSpec(type: .getCurrentUser, arguments: [:], canRunSilently: false, action: XCBActions.getCurrentUser) + ] + + 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 xcbURL = XCallbackURL(spec: spec, components: components) + return spec.handle(url: xcbURL) + } + return false + } + + static func createSession(type: XCBActionType, url: XCallbackURL) -> XCBSession { + let session = XCBSession(type: type, success: url.success, error: url.error, cancel: url.cancel) + currentSession = session + return session + } + +} diff --git a/Tusker/XCallbackURL/XCBSession.swift b/Tusker/XCallbackURL/XCBSession.swift new file mode 100644 index 00000000..c4179c70 --- /dev/null +++ b/Tusker/XCallbackURL/XCBSession.swift @@ -0,0 +1,43 @@ +// +// XCBSession.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit + +class XCBSession { + let type: XCBActionType + let success: URL? + let error: URL? + let cancel: URL? + + init(type: XCBActionType, success: URL?, error: URL?, cancel: URL?) { + self.type = type + self.success = success + self.error = error + self.cancel = cancel + } + + func complete(with result: XCBSessionResult, additionalData: [String: String?]? = nil) { + let url = result == .success ? success : result == .error ? error : cancel + if var url = url { + XCBManager.currentSession = nil + if let additionalData = additionalData { + var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! + components.queryItems = components.queryItems ?? [] + 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 +} diff --git a/Tusker/XCallbackURL/XCBSessionType.swift b/Tusker/XCallbackURL/XCBSessionType.swift new file mode 100644 index 00000000..871381be --- /dev/null +++ b/Tusker/XCallbackURL/XCBSessionType.swift @@ -0,0 +1,13 @@ +// +// XCBSessionType.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation + +enum XCBSessionType { + case postStatus +} diff --git a/Tusker/XCallbackURL/XCallbackURL.swift b/Tusker/XCallbackURL/XCallbackURL.swift new file mode 100644 index 00000000..7e383edc --- /dev/null +++ b/Tusker/XCallbackURL/XCallbackURL.swift @@ -0,0 +1,107 @@ +// +// XCallbackURL.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit + +typealias XCBAction = (_ url: XCallbackURL, _ session: XCBSession, _ silent: Bool?) -> Void + +struct XCallbackURLSpec { + + 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 + } + self.arguments = arguments + } + + func handle(url: XCallbackURL) -> Bool { + let session = XCBManager.createSession(type: type, url: url) + if canRunSilently && url.silent { + if let source = url.source { + let permission = Preferences.shared.silentActions[source] ?? .undecided + switch permission { + case .accepted: + action(url, session, true) + case .rejected: + action(url, 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(url, session, true) + })) + alert.addAction(UIAlertAction(title: "Reject", style: .default, handler: { (_) in + Preferences.shared.silentActions[source] = .rejected + self.action(url, 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(url, session, nil) + } + return true + } + +} + +struct XCallbackURL { + let path: String + let arguments: [String: String] + let silent: Bool + let source: String? + let success: URL? + let error: URL? + let cancel: URL? + + init(spec: XCallbackURLSpec, components: URLComponents) { + self.path = spec.path + 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 + } + }) + if spec.canRunSilently { + silent = Bool(arguments["silent"] ?? "false") ?? false + } else { + silent = false + } + 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) } + } +} + +extension XCallbackURLSpec { + 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 + } +}