From 97fe450d1f5103b61cf905350de0e18503c8db85 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 23 Sep 2018 12:01:05 -0400 Subject: [PATCH] Add initial x-callback-url implementation --- Tusker.xcodeproj/project.pbxproj | 20 ++++++ Tusker/AppDelegate.swift | 10 ++- Tusker/Info.plist | 12 ++-- Tusker/MastodonCache.swift | 14 +++-- .../Compose/ComposeViewController.swift | 31 ++++++++-- .../Profile/ProfileTableViewController.swift | 2 +- Tusker/XCallbackURL/XCBManager.swift | 54 ++++++++++++++++ Tusker/XCallbackURL/XCBSession.swift | 38 ++++++++++++ Tusker/XCallbackURL/XCBSessionType.swift | 13 ++++ Tusker/XCallbackURL/XCallbackURL.swift | 61 +++++++++++++++++++ 10 files changed, 234 insertions(+), 21 deletions(-) create mode 100644 Tusker/XCallbackURL/XCBManager.swift create mode 100644 Tusker/XCallbackURL/XCBSession.swift create mode 100644 Tusker/XCallbackURL/XCBSessionType.swift create mode 100644 Tusker/XCallbackURL/XCallbackURL.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index cd5a6d14..7cb9d954 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -92,6 +92,9 @@ 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 */; }; 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 */; }; @@ -247,6 +250,9 @@ 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 = ""; }; 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 +572,16 @@ path = Extensions; sourceTree = ""; }; + D6757A7A2157E00100721E32 /* XCallbackURL */ = { + isa = PBXGroup; + children = ( + D6757A7B2157E01900721E32 /* XCBManager.swift */, + D6757A812157E8FA00721E32 /* XCBSession.swift */, + D6757A7D2157E02600721E32 /* XCallbackURL.swift */, + ); + path = XCallbackURL; + sourceTree = ""; + }; D6BED1722126661300F02DA0 /* Views */ = { isa = PBXGroup; children = ( @@ -616,6 +632,7 @@ D64D0AAC2128D88B005A6F37 /* LocalData.swift */, 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */, D6028B9A2150811100F223B9 /* MastodonCache.swift */, + D6757A7A2157E00100721E32 /* XCallbackURL */, D663626021360A9600C9CBA2 /* Preferences */, D667E5F62135C2ED0057A976 /* Extensions */, D6F953F121251A2F00CF0F2B /* Controllers */, @@ -921,6 +938,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 */, @@ -956,7 +974,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 */, 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 e8e1badd..6ad18eaa 100644 --- a/Tusker/MastodonCache.swift +++ b/Tusker/MastodonCache.swift @@ -11,16 +11,18 @@ import Pachyderm class MastodonCache { - private static let statuses = NSCache() - private static let accounts = NSCache() +// private static let statuses = NSDictionary() +// private static let accounts = NSDictionary() + private static var statuses = [String: Status]() + private static var accounts = [String: Account]() // 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) @@ -49,11 +51,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) { diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift index fb428081..a12c9348 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,10 @@ class ComposeViewController: UIViewController { var scrolled = false var inReplyToID: String? - var mentioning: Account? + var mentioningAcct: String? + var text: String? + + var xcbSession: XCBSession? var contentWarning = false { didSet { @@ -95,8 +108,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 +145,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) } } @@ -232,6 +251,8 @@ class ComposeViewController: UIViewController { DispatchQueue.main.async { self.progressView.step() self.performSegue(withIdentifier: "postComplete", sender: self) + + self.xcbSession?.complete(with: .success) } } } diff --git a/Tusker/Screens/Profile/ProfileTableViewController.swift b/Tusker/Screens/Profile/ProfileTableViewController.swift index c51d5170..e8ea68a9 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/XCBManager.swift b/Tusker/XCallbackURL/XCBManager.swift new file mode 100644 index 00000000..53c5816a --- /dev/null +++ b/Tusker/XCallbackURL/XCBManager.swift @@ -0,0 +1,54 @@ +// +// XCBManager.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit + +class XCBManager { + + static var specs: [XCallbackURLSpec] = [ + XCallbackURLSpec(path: "/postStatus", arguments: ["mentioning": true, "text": true], handle: postStatus) + ] + + 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(xcbURL) + } + return false + } + + static func didComplete(session type: XCBSessionType, result: XCBSessionResult) { + if let currentSession = currentSession, + currentSession.type == type { + currentSession.complete(with: result) + self.currentSession = nil + } + } + + private static func createSession(type: XCBSessionType, url: XCallbackURL) -> XCBSession { + let session = XCBSession(type: type, success: url.success, error: url.error, cancel: url.cancel) + currentSession = session + return session + } + + static func postStatus(_ url: XCallbackURL) -> Bool { + let mentioning = url.arguments["mentioning"] + let text = url.arguments["text"] + if let window = UIApplication.shared.keyWindow { + let session = createSession(type: .postStatus, url: url) + let vc = ComposeViewController.create(for: session, mentioning: mentioning, text: text) + window.rootViewController?.present(vc, animated: true) + return true + } + return false + } + +} diff --git a/Tusker/XCallbackURL/XCBSession.swift b/Tusker/XCallbackURL/XCBSession.swift new file mode 100644 index 00000000..fa1f156e --- /dev/null +++ b/Tusker/XCallbackURL/XCBSession.swift @@ -0,0 +1,38 @@ +// +// XCBSession.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit + +class XCBSession { + let type: XCBSessionType + let success: URL? + let error: URL? + let cancel: URL? + + init(type: XCBSessionType, success: URL?, error: URL?, cancel: URL?) { + self.type = type + self.success = success + self.error = error + self.cancel = cancel + } + + func complete(with result: XCBSessionResult) { + let url = result == .success ? success : result == .error ? error : cancel + if let url = url { + UIApplication.shared.open(url, options: [:]) + } + } +} + +enum XCBSessionType { + case postStatus +} + +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..129d233b --- /dev/null +++ b/Tusker/XCallbackURL/XCallbackURL.swift @@ -0,0 +1,61 @@ +// +// XCallbackURL.swift +// Tusker +// +// Created by Shadowfacts on 9/23/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation + +struct XCallbackURLSpec { + + let path: String + let arguments: [String: Bool] + let handle: (XCallbackURL) -> Bool + + init(path: String, arguments: [String: Bool], handle: @escaping (XCallbackURL) -> Bool) { + self.path = path + self.arguments = arguments + self.handle = handle + } + +} + +struct XCallbackURL { + let path: String + let arguments: [String: 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 + } + }) + 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 { + return path == components.path && arguments.matches(components) + } +} + +extension Dictionary where Key == String, Value == Bool { + func matches(_ components: URLComponents) -> Bool { + for (name, optional) in self { + if (!optional && components.queryItems?.first(where: { $0.name == name }) == nil) { + return false + } + } + return true + } +}