feat: [WIP] handle notification for multiple accounts

This commit is contained in:
CMK 2021-10-11 19:19:27 +08:00
parent 575035daaf
commit eaa2ef4083
11 changed files with 272 additions and 18 deletions

View File

@ -167,4 +167,8 @@ extension MastodonAuthentication {
])
}
public static func predicate(userAccessToken: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userAccessToken), userAccessToken)
}
}

View File

@ -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<AnyCancellable>()
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<MastodonPushNotification?, Never> 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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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)
}
}

View File

@ -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)

View File

@ -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)

View File

@ -95,8 +95,11 @@ extension AuthenticationService {
func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, Never> {
var isActive = false
var _mastodonAuthentication: MastodonAuthentication?
return backgroundManagedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
return backgroundManagedObjectContext.performChanges {
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()

View File

@ -30,7 +30,7 @@ final class NotificationService {
/// [Token: NotificationViewModel]
let notificationSubscriptionDict: [String: NotificationViewModel] = [:]
let unreadNotificationCountDidUpdate = CurrentValueSubject<Void, Never>(Void())
let requestRevealNotificationPublisher = PassthroughSubject<Mastodon.Entity.Notification.ID, Never>()
let requestRevealNotificationPublisher = PassthroughSubject<MastodonPushNotification, Never>()
init(
apiService: APIService,

View File

@ -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()
}

View File

@ -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
}
}