// // UserActivityManager.swift // Tusker // // Created by Shadowfacts on 10/19/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Intents import Pachyderm import OSLog import UserAccounts import ComposeUI private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") @MainActor class UserActivityManager { private let scene: UIWindowScene private let context: any UserActivityHandlingContext init(scene: UIWindowScene, context: any UserActivityHandlingContext) { self.scene = scene self.context = context } // MARK: - Utils private static let encoder = PropertyListEncoder() private static let decoder = PropertyListDecoder() private var mastodonController: MastodonController { scene.session.mastodonController! } static func getAccount(from activity: NSUserActivity) -> UserAccountInfo? { guard let id = activity.userInfo?["accountID"] as? String else { return nil } return UserAccountsManager.shared.getAccount(id: id) } // MARK: - Main Scene static func mainSceneActivity(accountID: String?) -> NSUserActivity { let activity = NSUserActivity(activityType: UserActivityType.mainScene.rawValue) if let accountID { activity.userInfo = [ "accountID": accountID, ] } return activity } // MARK: - New Post static func newPostActivity(mentioning: Account? = nil, accountID: String) -> NSUserActivity { // todo: update to use managed objects let activity = NSUserActivity(type: .newPost, accountID: accountID) activity.isEligibleForPrediction = true if let mentioning = mentioning { activity.userInfo!["mentioning"] = mentioning.acct activity.title = "Send a message to \(mentioning.displayName)" activity.suggestedInvocationPhrase = "Send a message to \(mentioning.displayName)" } else { activity.title = "New Post" activity.suggestedInvocationPhrase = "Post in Tusker" } return activity } func handleNewPost(activity: NSUserActivity) { if let draft = Self.getDraft(from: activity) { context.compose(editing: draft) } else { let mentioning = activity.userInfo?["mentioning"] as? String let draft = mastodonController.createDraft(mentioningAcct: mentioning) context.compose(editing: draft) } } static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .newPost, accountID: accountID) activity.addUserInfoEntries(from: [ "draftID": id.uuidString, ]) return activity } static func addDuckedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity { if let activity { activity.addUserInfoEntries(from: [ "duckedDraftID": draft.id.uuidString ]) return activity } else { return editDraftActivity(id: draft.id, accountID: draft.accountID) } } static func getDraft(from activity: NSUserActivity) -> Draft? { guard let idStr = activity.userInfo?["draftID"] as? String, let uuid = UUID(uuidString: idStr) else { return nil } return DraftsManager.shared.getBy(id: uuid) } static func getDuckedDraft(from activity: NSUserActivity) -> Draft? { guard let idStr = activity.userInfo?["duckedDraftID"] as? String, let uuid = UUID(uuidString: idStr) else { return nil } return DraftsManager.shared.getBy(id: uuid) } // MARK: - Check Notifications static func checkNotificationsActivity(mode: NotificationsMode = .allNotifications, accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .checkNotifications, accountID: accountID) activity.isEligibleForPrediction = true activity.isEligibleForHandoff = true activity.addUserInfoEntries(from: [ "notificationsMode": mode.rawValue ]) switch mode { case .allNotifications: activity.title = NSLocalizedString("Check Notifications", comment: "check notifications shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Check my Tusker notifications", comment: "check notifications shortcut invocation phrase") case .mentionsOnly: activity.title = NSLocalizedString("Check Mentions", comment: "check mentions shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Check my mentions", comment: "check mentions shortcut invocation phrase") } return activity } func handleCheckNotifications(activity: NSUserActivity) { context.select(route: .notifications) context.popToRoot() if let notificationsPageController = context.topViewController as? NotificationsPageViewController { notificationsPageController.loadViewIfNeeded() notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode) } } static func getNotificationsMode(from activity: NSUserActivity) -> NotificationsMode? { guard let str = activity.userInfo?["notificationsMode"] as? String else { return nil } return NotificationsMode(rawValue: str) } // MARK: - Show Timeline static func showTimelineActivity(timeline: Timeline, accountID: String) -> NSUserActivity? { guard let timelineData = try? encoder.encode(timeline) else { return nil } let activity = NSUserActivity(type: .showTimeline, accountID: accountID) activity.isEligibleForPrediction = true activity.isEligibleForHandoff = true activity.addUserInfoEntries(from: [ "timelineData": timelineData, ]) switch timeline { case .home: activity.title = NSLocalizedString("Show Home Timeline", comment: "home timeline shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my home timeline", comment: "home timeline shortcut invocation phrase") case .public(local: true): activity.title = NSLocalizedString("Show Local Timeline", comment: "local timeline shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my local timeline", comment: "local timeline shortcut invocation phrase") case .public(local: false): activity.title = NSLocalizedString("Show Federated Timeline", comment: "federated timeline shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my federated timeline", comment: "federated timeline invocation phrase") case let .tag(hashtag): activity.title = String(format: NSLocalizedString("Show #%@", comment: "show hashtag shortcut title"), hashtag) activity.suggestedInvocationPhrase = String(format: NSLocalizedString("Show the %@ hashtag", comment: "hashtag shortcut invocation phrase"), hashtag) case .list: // todo: add title to list activity.title = NSLocalizedString("Show List", comment: "list timeline shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my list", comment: "list timeline invocation phrase") case .direct: activity.title = NSLocalizedString("Show Direct Messages", comment: "direct message timeline shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my direct messages", comment: "direct message timeline invocation phrase") } return activity } static func addTimelinePositionInfo(to activity: NSUserActivity, statusIDs: [String], centerStatusID: String) { activity.addUserInfoEntries(from: [ "statusIDs": statusIDs, "centerStatusID": centerStatusID ]) } static func getTimeline(from activity: NSUserActivity) -> (Timeline, (statusIDs: [String], centerStatusID: String)?)? { guard activity.activityType == UserActivityType.showTimeline.rawValue, let data = activity.userInfo?["timelineData"] as? Data, let timeline = try? UserActivityManager.decoder.decode(Timeline.self, from: data) else { return nil } var positionInfo: ([String], String)? if let ids = activity.userInfo?["statusIDs"] as? [String], let center = activity.userInfo?["centerStatusID"] as? String { positionInfo = (ids, center) } return (timeline, positionInfo) } func handleShowTimeline(activity: NSUserActivity) { guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return } var timelineVC: TimelineViewController? if let pinned = PinnedTimeline(timeline: timeline), mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { context.select(route: .timelines) context.popToRoot() let pageController = context.topViewController as! TimelinesPageViewController pageController.selectTimeline(pinned, animated: false) timelineVC = pageController.currentViewController as? TimelineViewController } else if case .list(let id) = timeline { context.select(route: .list(id: id)) timelineVC = context.topViewController as? TimelineViewController } else { context.select(route: .explore) context.popToRoot() timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController) context.push(timelineVC!) } if let timelineVC, let positionInfo, context.isHandoff { Task { await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID) } } } // MARK: - Show Conversation static func showConversationActivity(mainStatusID: String, accountID: String, isEligibleForPrediction: Bool = false) -> NSUserActivity { let activity = NSUserActivity(type: .showConversation, accountID: accountID) activity.addUserInfoEntries(from: [ "mainStatusID": mainStatusID, ]) activity.isEligibleForPrediction = isEligibleForPrediction activity.isEligibleForHandoff = true return activity } static func getConversationStatus(from activity: NSUserActivity) -> String? { return activity.userInfo?["mainStatusID"] as? String } func handleShowConversation(activity: NSUserActivity) { guard let mainStatusID = Self.getConversationStatus(from: activity) else { return } context.select(route: .timelines) context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController)) } // MARK: - Explore static func searchActivity(query: String?, accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .search, accountID: accountID) if let query { activity.userInfo!["query"] = query } activity.isEligibleForPrediction = true activity.title = NSLocalizedString("Search", comment: "search shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Search the fediverse", comment: "search shortcut invocation phrase") return activity } static func getSearchQuery(from activity: NSUserActivity) -> String? { return activity.userInfo?["query"] as? String } func handleSearch(activity: NSUserActivity) { context.select(route: .explore) context.popToRoot() let searchController: UISearchController let resultsController: SearchResultsViewController if let explore = context.topViewController as? ExploreViewController { explore.loadViewIfNeeded() explore.searchControllerStatusOnAppearance = true searchController = explore.searchController resultsController = explore.resultsController } else if let inlineTrends = context.topViewController as? InlineTrendsViewController { inlineTrends.loadViewIfNeeded() inlineTrends.searchControllerStatusOnAppearance = true searchController = inlineTrends.searchController resultsController = inlineTrends.resultsController } else { return } if let query = Self.getSearchQuery(from: activity), !query.isEmpty { searchController.searchBar.text = query resultsController.performSearch(query: query) } else { searchController.searchBar.becomeFirstResponder() } } static func bookmarksActivity(accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .bookmarks, accountID: accountID) activity.isEligibleForPrediction = true activity.title = NSLocalizedString("View Bookmarks", comment: "bookmarks shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my bookmarks in Tusker", comment: "bookmarks shortcut invocation phrase") return activity } func handleBookmarks(activity: NSUserActivity) { context.select(route: .bookmarks) } // MARK: - My Profile static func myProfileActivity(accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .myProfile, accountID: accountID) activity.isEligibleForPrediction = true activity.isEligibleForHandoff = true activity.title = NSLocalizedString("My Profile", comment: "my profile shortcut title") activity.suggestedInvocationPhrase = NSLocalizedString("Show my Mastodon profile", comment: "my profile shortuct invocation phrase") return activity } func handleMyProfile(activity: NSUserActivity) { context.select(route: .myProfile) } // MARK: - Show Profile static func showProfileActivity(id profileID: String, accountID: String) -> NSUserActivity { let activity = NSUserActivity(type: .showProfile, accountID: accountID) activity.addUserInfoEntries(from: [ "profileID": profileID, ]) activity.isEligibleForPrediction = true activity.isEligibleForHandoff = true return activity } static func getProfile(from activity: NSUserActivity) -> String? { return activity.userInfo?["profileID"] as? String } func handleShowProfile(activity: NSUserActivity) { guard let accountID = Self.getProfile(from: activity) else { return } context.select(route: .timelines) context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController)) } }