diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index 9db74413a7..2afff31a59 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -61,7 +61,9 @@ class NotificationService: UNNotificationServiceExtension { mutableContent.title = notification.title mutableContent.body = notification.body - + mutableContent.userInfo["notificationID"] = notification.notificationID + mutableContent.userInfo["accountID"] = accountID + let task = Task { await updateNotificationContent(mutableContent, account: account, push: notification) if !Task.isCancelled { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 9ee956559e..14f56440f0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -174,6 +174,7 @@ D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; }; D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; }; D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; }; + D68245122BCA1F4000AFB38B /* NotificationLoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */; }; D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */; }; D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; }; @@ -595,6 +596,7 @@ D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = ""; }; D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = ""; }; D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = ""; }; + D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationLoadingViewController.swift; sourceTree = ""; }; D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTrendsFooterCollectionViewCell.swift; sourceTree = ""; }; D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = ""; }; D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = ""; }; @@ -1145,6 +1147,7 @@ D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */, D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */, D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */, + D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */, ); path = Notifications; sourceTree = ""; @@ -2161,6 +2164,7 @@ D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, + D68245122BCA1F4000AFB38B /* NotificationLoadingViewController.swift in Sources */, D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */, diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 0879b7d8f6..ccb9092502 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -159,7 +159,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .search, .bookmarks, .myProfile, - .showProfile: + .showProfile, + .showNotification: if activity.displaysAuxiliaryScene { stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)") return "auxiliary" @@ -275,4 +276,33 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } mainSceneDelegate.showNotificationsPreferences() } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler(.banner) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + guard let notificationID = userInfo["notificationID"] as? String, + let accountID = userInfo["accountID"] as? String, + let account = UserAccountsManager.shared.getAccount(id: accountID) else { + return + } + if let scene = response.targetScene, + let delegate = scene.delegate as? MainSceneDelegate, + let rootViewController = delegate.rootViewController { + let mastodonController = MastodonController.getForAccount(account) + + // if the scene is already active, then we animate the account switching if necessary + delegate.activateAccount(account, animated: scene.activationState == .foregroundActive) + + rootViewController.select(route: .notifications, animated: false) + let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController) + rootViewController.getNavigationController().pushViewController(vc, animated: false) + } else { + let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID) + UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil) + } + completionHandler() + } } diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index 7b820a9043..7062d76282 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -377,9 +377,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer { func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) { backgroundContext.perform { let statuses = notifications.compactMap { $0.status } - // filter out mentions, otherwise we would double increment the reference count of those accounts - // since the status has the same account as the notification - let accounts = notifications.filter { $0.kind != .mention }.map { $0.account } + let accounts = notifications.map { $0.account } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) } self.save(context: self.backgroundContext) diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 202b35d00d..a3a629c555 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -71,6 +71,7 @@ $(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile $(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile $(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene + $(PRODUCT_BUNDLE_IDENTIFIER).activity.show-notification OSLogPreferences diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index f08da5b4b2..503a83a276 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -208,6 +208,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } func activateAccount(_ account: UserAccountInfo, animated: Bool) { + guard (window?.rootViewController as? AccountSwitchingContainerViewController)?.currentAccountID != account.id else { + return + } + let oldMostRecentAccount = UserAccountsManager.shared.mostRecentAccountID UserAccountsManager.shared.setMostRecentAccount(account) window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account) diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index 2fde5d39de..6f7a5b8b12 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -20,7 +20,7 @@ protocol AccountSwitchableViewController: TuskerRootViewController { class AccountSwitchingContainerViewController: UIViewController { - private var currentAccountID: String + private(set) var currentAccountID: String private(set) var root: AccountSwitchableViewController private var userActivities: [String: NSUserActivity] = [:] diff --git a/Tusker/Screens/Notifications/NotificationLoadingViewController.swift b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift new file mode 100644 index 0000000000..3a19f7fee1 --- /dev/null +++ b/Tusker/Screens/Notifications/NotificationLoadingViewController.swift @@ -0,0 +1,155 @@ +// +// NotificationLoadingViewController.swift +// Tusker +// +// Created by Shadowfacts on 4/12/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class NotificationLoadingViewController: UIViewController { + + private let notificationID: String + private let mastodonController: MastodonController + + init(notificationID: String, mastodonController: MastodonController) { + self.notificationID = notificationID + self.mastodonController = mastodonController + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondarySystemBackground + + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + indicator.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(indicator) + NSLayoutConstraint.activate([ + indicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + ]) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Task { + let request = Client.getNotification(id: notificationID) + do { + let (notification, _) = try await mastodonController.run(request) + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(notifications: [notification]) { + continuation.resume() + } + } + showNotification(notification) + } catch { + showLoadingError(error) + } + } + } + + private func showNotification(_ notification: Pachyderm.Notification) { + let vc: UIViewController + switch notification.kind { + case .mention, .status, .poll, .update: + guard let statusID = notification.status?.id else { + showLoadingError(Error.missingStatus) + return + } + vc = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController) + case .reblog, .favourite: + guard let statusID = notification.status?.id else { + showLoadingError(Error.missingStatus) + return + } + let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite + vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController) + case .follow: + vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController) + case .followRequest: + // todo + return + case .unknown: + showLoadingError(Error.unknownType) + return + } + guard let navigationController else { + fatalError("Don't know how to show notification VC outside of navigation controller") + } + navigationController.viewControllers[navigationController.viewControllers.count - 1] = vc + } + + private func showLoadingError(_ error: any Swift.Error) { + let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!) + image.tintColor = .secondaryLabel + image.contentMode = .scaleAspectFit + + let title = UILabel() + title.textColor = .secondaryLabel + title.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)! + title.adjustsFontForContentSizeCategory = true + title.text = "Couldn't Load Notification" + + let subtitle = UILabel() + subtitle.textColor = .secondaryLabel + subtitle.font = .preferredFont(forTextStyle: .body) + subtitle.adjustsFontForContentSizeCategory = true + subtitle.numberOfLines = 0 + subtitle.textAlignment = .center + if let error = error as? Error { + subtitle.text = error.localizedDescription + } else if let error = error as? Client.Error { + subtitle.text = error.localizedDescription + } else { + subtitle.text = error.localizedDescription + } + + let stack = UIStackView(arrangedSubviews: [ + image, + title, + subtitle, + ]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 8 + stack.isAccessibilityElement = true + stack.accessibilityLabel = "\(title.text!). \(subtitle.text!)" + + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + image.widthAnchor.constraint(equalToConstant: 64), + image.heightAnchor.constraint(equalToConstant: 64), + + stack.leadingAnchor.constraint(equalToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1), + view.safeAreaLayoutGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1), + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + ]) + } + +} + +private enum Error: LocalizedError { + case missingStatus + case unknownType + + var errorDescription: String? { + switch self { + case .missingStatus: + "Missing status for mention/status notification" + case .unknownType: + "Unknown notification type" + } + } +} diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index e5676da821..18b7fbd1ed 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -352,4 +352,21 @@ class UserActivityManager { context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController)) } + // MARK: - Show Notification + static func showNotificationActivity(id notificationID: String, accountID: String) -> NSUserActivity { + let activity = NSUserActivity(type: .showNotification, accountID: accountID) + activity.addUserInfoEntries(from: [ + "notificationID": notificationID, + ]) + return activity + } + + func handleShowNotification(activity: NSUserActivity) { + guard let notificationID = activity.userInfo?["notificationID"] as? String else { + return + } + context.select(route: .notifications) + context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)) + } + } diff --git a/Tusker/Shortcuts/UserActivityType.swift b/Tusker/Shortcuts/UserActivityType.swift index dd3c4f10b2..6d7e991f86 100644 --- a/Tusker/Shortcuts/UserActivityType.swift +++ b/Tusker/Shortcuts/UserActivityType.swift @@ -18,6 +18,7 @@ enum UserActivityType: String { case showConversation = "space.vaccor.Tusker.activity.show-conversation" case myProfile = "space.vaccor.Tusker.activity.my-profile" case showProfile = "space.vaccor.Tusker.activity.show-profile" + case showNotification = "space.vaccor.Tusker.activity.show-notification" } extension UserActivityType { @@ -42,6 +43,8 @@ extension UserActivityType { return UserActivityManager.handleMyProfile case .showProfile: return UserActivityManager.handleShowProfile + case .showNotification: + return UserActivityManager.handleShowNotification } } }