forked from zelo72/mastodon-ios
feat: [WIP] handle notification for multiple accounts
This commit is contained in:
parent
575035daaf
commit
eaa2ef4083
|
@ -167,4 +167,8 @@ extension MastodonAuthentication {
|
|||
])
|
||||
}
|
||||
|
||||
public static func predicate(userAccessToken: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userAccessToken), userAccessToken)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue