From eaa2ef40839f248a9f5bcb3493b0f8997dc8019e Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 11 Oct 2021 19:19:27 +0800 Subject: [PATCH] feat: [WIP] handle notification for multiple accounts --- .../Entity/MastodonAuthentication.swift | 4 + Mastodon/Coordinator/SceneCoordinator.swift | 92 +++++++++++++++++++ .../Scene/Account/AccountViewController.swift | 4 +- ...meTimelineViewController+DebugAction.swift | 78 ++++++++++++++++ .../Profile/RemoteProfileViewModel.swift | 48 ++++++++++ .../Root/MainTab/MainTabBarController.swift | 10 -- .../Scene/Thread/RemoteThreadViewModel.swift | 1 - Mastodon/Service/AuthenticationService.swift | 27 +++++- Mastodon/Service/NotificationService.swift | 2 +- Mastodon/Supporting Files/AppDelegate.swift | 4 +- .../MastodonNotification.swift | 20 +++- 11 files changed, 272 insertions(+), 18 deletions(-) diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index 66b8ad6a..7aafd65a 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -167,4 +167,8 @@ extension MastodonAuthentication { ]) } + public static func predicate(userAccessToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userAccessToken), userAccessToken) + } + } diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 00a245ba..87e97a53 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -5,12 +5,16 @@ // Created by Cirno MainasuK on 2021-1-27. import UIKit +import Combine import SafariServices import CoreDataStack +import MastodonSDK import PanModal final public class SceneCoordinator { + private var disposeBag = Set() + private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! @@ -28,6 +32,93 @@ final public class SceneCoordinator { self.appContext = appContext scene.session.sceneCoordinator = self + + appContext.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .compactMap { [weak self] pushNotification -> AnyPublisher in + guard let self = self else { return Just(nil).eraseToAnyPublisher() } + // skip if no available account + guard let currentActiveAuthenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else { + return Just(nil).eraseToAnyPublisher() + } + + let accessToken = pushNotification._accessToken // use raw accessToken value without normalize + if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken { + // do nothing if notification for current account + return Just(pushNotification).eraseToAnyPublisher() + } else { + // switch to notification's account + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(userAccessToken: accessToken) + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + do { + guard let authentication = try appContext.managedObjectContext.fetch(request).first else { + return Just(nil).eraseToAnyPublisher() + } + let domain = authentication.domain + let userID = authentication.userID + return appContext.authenticationService.activeMastodonUser(domain: domain, userID: userID) + .receive(on: DispatchQueue.main) + .map { [weak self] result -> MastodonPushNotification? in + guard let self = self else { return nil } + switch result { + case .success: + // reset view hierarchy + self.setup() + return pushNotification + case .failure: + return nil + } + } + .delay(for: 1, scheduler: DispatchQueue.main) // set delay to slow transition (not must) + .eraseToAnyPublisher() + } catch { + assertionFailure(error.localizedDescription) + return Just(nil).eraseToAnyPublisher() + } + } + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak self] pushNotification in + guard let self = self else { return } + guard let pushNotification = pushNotification else { return } + + // redirect to notification tab + self.switchToTabBar(tab: .notification) + + + // Delay in next run loop + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Note: + // show (push) on phone + // showDetail in .secondary in UISplitViewController on pad + let from = self.splitViewController?.topMost ?? self.tabBarController.topMost + + // show notification related content + guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return } + let notificationID = String(pushNotification.notificationID) + + switch type { + case .follow: + let profileViewModel = RemoteProfileViewModel(context: appContext, notificationID: notificationID) + self.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) + case .followRequest: + // do nothing + break + case .mention, .reblog, .favourite, .poll, .status: + let threadViewModel = RemoteThreadViewModel(context: appContext, notificationID: notificationID) + self.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) + case ._other: + assertionFailure() + break + } + } + } + .store(in: &disposeBag) } } @@ -254,6 +345,7 @@ extension SceneCoordinator { } func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue tabBarController.currentTab.value = tab } } diff --git a/Mastodon/Scene/Account/AccountViewController.swift b/Mastodon/Scene/Account/AccountViewController.swift index 2898b9b5..4f2ece25 100644 --- a/Mastodon/Scene/Account/AccountViewController.swift +++ b/Mastodon/Scene/Account/AccountViewController.swift @@ -111,8 +111,10 @@ extension AccountListViewController { viewModel.dataSourceDidUpdate .receive(on: DispatchQueue.main) - .sink { [weak self] in + .sink { [weak self, weak presentingViewController] in guard let self = self else { return } + // the presentingViewController may deinit + guard let _ = presentingViewController else { return } self.hasLoaded = true self.panModalSetNeedsLayoutUpdate() self.panModalTransition(to: .shortForm) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index fbc221c7..6d79d060 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -14,6 +14,7 @@ import CoreDataStack import FLEX import SwiftUI import MastodonUI +import MastodonSDK extension HomeTimelineViewController { var debugMenu: UIMenu { @@ -27,6 +28,7 @@ extension HomeTimelineViewController { moveMenu, dropMenu, miscMenu, + notificationMenu, UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in guard let self = self else { return } self.showSettings(action) @@ -175,6 +177,25 @@ extension HomeTimelineViewController { ) } + var notificationMenu: UIMenu { + return UIMenu( + title: "Notification…", + image: UIImage(systemName: "bell.badge"), + identifier: nil, + options: [], + children: [ + UIAction(title: "Profile", image: UIImage(systemName: "person.badge.plus"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showNotification(action, notificationType: .follow) + }, + UIAction(title: "Status", image: UIImage(systemName: "list.bullet.rectangle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showNotification(action, notificationType: .mention) + }, + ] + ) + } + } extension HomeTimelineViewController { @@ -412,6 +433,63 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + private func showNotification(_ sender: UIAction, notificationType: Mastodon.Entity.Notification.NotificationType) { + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + let alertController = UIAlertController(title: "Enter notification ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first, + let text = textField.text, + let notificationID = Int(text) + else { return } + + let pushNotification = MastodonPushNotification( + _accessToken: authenticationBox.userAuthorization.accessToken, + notificationID: notificationID, + notificationType: notificationType.rawValue, + preferredLocale: nil, + icon: nil, + title: "", + body: "" + ) + self.context.notificationService.requestRevealNotificationPublisher.send(pushNotification) + } + alertController.addAction(showAction) + + // for multiple accounts debug + let boxes = self.context.authenticationService.mastodonAuthenticationBoxes.value // already sorted + if boxes.count >= 2 { + let accessToken = boxes[1].userAuthorization.accessToken + let showForSecondaryAction = UIAlertAction(title: "Show for Secondary", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first, + let text = textField.text, + let notificationID = Int(text) + else { return } + + let pushNotification = MastodonPushNotification( + _accessToken: accessToken, + notificationID: notificationID, + notificationType: notificationType.rawValue, + preferredLocale: nil, + icon: nil, + title: "", + body: "" + ) + self.context.notificationService.requestRevealNotificationPublisher.send(pushNotification) + } + alertController.addAction(showForSecondaryAction) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + @objc private func showSettings(_ sender: UIAction) { guard let currentSetting = context.settingService.currentSetting.value else { return } let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index 153f5099..ef04d581 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -7,6 +7,7 @@ import os.log import Foundation +import Combine import CoreDataStack import MastodonSDK @@ -49,4 +50,51 @@ final class RemoteProfileViewModel: ProfileViewModel { .store(in: &disposeBag) } + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalMastodonUser: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + + context.apiService.notification( + notificationID: notificationID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .compactMap { [weak self] response -> AnyPublisher, Error>? in + let userID = response.value.account.id + // TODO: use .account directly + return context.apiService.accountInfo( + domain: domain, + userID: userID, + authorization: authorization + ) + } + .switchToLatest() + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s user fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) + guard let mastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.mastodonUser.value = mastodonUser + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index d24a67c4..d34c8553 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -226,16 +226,6 @@ extension MainTabBarController { } .store(in: &disposeBag) - context.notificationService.requestRevealNotificationPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] notificationID in - guard let self = self else { return } - self.coordinator.switchToTabBar(tab: .notification) - let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) - self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) - } - .store(in: &disposeBag) - layoutAvatarButton() context.authenticationService.activeMastodonAuthentication .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index e6e11101..f8f5d3e7 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -48,7 +48,6 @@ final class RemoteThreadViewModel: ThreadViewModel { .store(in: &disposeBag) } - // FIXME: multiple account supports init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { super.init(context: context, optionalStatus: nil) diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 0b3c3fa1..9e27caab 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -95,8 +95,11 @@ extension AuthenticationService { func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isActive = false + var _mastodonAuthentication: MastodonAuthentication? - return backgroundManagedObjectContext.performChanges { + return backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 @@ -104,9 +107,29 @@ extension AuthenticationService { return } mastodonAuthentication.update(activedAt: Date()) + _mastodonAuthentication = mastodonAuthentication isActive = true + } - .map { result in + .receive(on: DispatchQueue.main) + .map { [weak self] result in + switch result { + case .success: + if let self = self, + let mastodonAuthentication = _mastodonAuthentication + { + // force set to avoid delay + self.activeMastodonAuthentication.value = mastodonAuthentication + self.activeMastodonAuthenticationBox.value = MastodonAuthenticationBox( + domain: mastodonAuthentication.domain, + userID: mastodonAuthentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) + ) + } + case .failure: + break + } return result.map { isActive } } .eraseToAnyPublisher() diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 463a2def..6eb3120c 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -30,7 +30,7 @@ final class NotificationService { /// [Token: NotificationViewModel] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] let unreadNotificationCountDidUpdate = CurrentValueSubject(Void()) - let requestRevealNotificationPublisher = PassthroughSubject() + let requestRevealNotificationPublisher = PassthroughSubject() init( apiService: APIService, diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 192f201d..87d16241 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -109,7 +109,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { completionHandler([.sound]) } - // response to user action for notification + // response to user action for notification (e.g. redirect to post) func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -125,7 +125,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) - appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) + appContext.notificationService.requestRevealNotificationPublisher.send(mastodonPushNotification) completionHandler() } diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift index f3941b12..7d6fb034 100644 --- a/NotificationService/MastodonNotification.swift +++ b/NotificationService/MastodonNotification.swift @@ -9,7 +9,7 @@ import Foundation struct MastodonPushNotification: Codable { - private let _accessToken: String + let _accessToken: String var accessToken: String { return String.normalize(base64String: _accessToken) } @@ -32,4 +32,22 @@ struct MastodonPushNotification: Codable { case body } + public init( + _accessToken: String, + notificationID: Int, + notificationType: String, + preferredLocale: String?, + icon: String?, + title: String, + body: String + ) { + self._accessToken = _accessToken + self.notificationID = notificationID + self.notificationType = notificationType + self.preferredLocale = preferredLocale + self.icon = icon + self.title = title + self.body = body + } + }