diff --git a/Mastodon/Extension/UIViewController.swift b/Mastodon/Extension/UIViewController.swift index c3782fa1..9ebb3a0a 100644 --- a/Mastodon/Extension/UIViewController.swift +++ b/Mastodon/Extension/UIViewController.swift @@ -46,6 +46,53 @@ extension UIViewController { } +extension UIViewController { + + func viewController(of type: T.Type) -> T? { + if let viewController = self as? T { + return viewController + } + + // UITabBarController + if let tabBarController = self as? UITabBarController { + for tab in tabBarController.viewControllers ?? [] { + if let viewController = tab.viewController(of: type) { + return viewController + } + } + } + + // UINavigationController + if let navigationController = self as? UINavigationController { + for page in navigationController.viewControllers { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // UIPageController + if let pageViewController = self as? UIPageViewController { + for page in pageViewController.viewControllers ?? [] { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController, + let viewController = childViewController.viewController(of: type) { + return viewController + } + } + + return nil + } + +} + extension UIViewController { /// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/ diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 9d609234..d5905cbb 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { - open override var childForStatusBarStyle: UIViewController? { return selectedViewController } @@ -156,9 +155,26 @@ extension MainTabBarController { } .store(in: &disposeBag) - #if DEBUG - // selectedIndex = 3 - #endif + // handle push notification. toggle entry when finish fetch latest notification + context.notificationService.hasUnreadPushNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] hasUnreadPushNotification in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) } } + +extension MainTabBarController { + + var notificationViewController: NotificationViewController? { + return viewController(of: NotificationViewController.self) + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc63..b27c4581 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -88,6 +88,11 @@ extension NotificationViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) + // fetch latest if has unread push notification + if context.notificationService.hasUnreadPushNotification.value { + viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d62..b9d60ae7 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState { query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) - .sink { completion in - switch completion { - case .failure(let error): - viewModel.isFetchingLatestNotification.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { response in - if response.value.isEmpty { - viewModel.isFetchingLatestNotification.value = false - } + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // toggle unread state + viewModel.context.notificationService.hasUnreadPushNotification.value = false + // handle isFetchingLatestTimeline in fetch controller delegate + break } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186..cbc0f9ed 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -64,4 +64,20 @@ extension APIService { } .eraseToAnyPublisher() } + + func notification( + notificationID: Mastodon.Entity.Notification.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notifications.getNotification( + session: session, + domain: domain, + notificationID: notificationID, + authorization: authorization + ) + } + } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 90680e78..bfd96df7 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -20,6 +20,7 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input + weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) @@ -27,10 +28,13 @@ final class NotificationService { // output /// [Token: UserID] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] + let hasUnreadPushNotification = CurrentValueSubject(false) init( + apiService: APIService, authenticationService: AuthenticationService ) { + self.apiService = apiService self.authenticationService = authenticationService authenticationService.mastodonAuthentications @@ -94,9 +98,15 @@ extension NotificationService { } return _notificationSubscription } - + + func handlePushNotification(notificationID: Mastodon.Entity.Notification.ID) { + hasUnreadPushNotification.value = true + } + } +// MARK: - NotificationViewModel + extension NotificationService { final class NotificationViewModel { @@ -141,4 +151,5 @@ extension NotificationService.NotificationViewModel { return query } + } diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 0c40ff12..93287f6e 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -61,6 +61,7 @@ class AppContext: ObservableObject { apiService: _apiService ) let _notificationService = NotificationService( + apiService: _apiService, authenticationService: _authenticationService ) notificationService = _notificationService diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 79017e29..0cca6ddf 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import UserNotifications import AppShared @main @@ -66,12 +67,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) - if let plaintext = notification.request.content.userInfo["plaintext"] as? Data, - let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] present", ((#file as NSString).lastPathComponent), #line, #function) - + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + completionHandler([]) + return } - completionHandler(.banner) + + 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.handlePushNotification(notificationID: notificationID) + completionHandler([.sound]) } func userNotificationCenter( @@ -81,6 +85,25 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + completionHandler() + return + } + + 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.handlePushNotification(notificationID: notificationID) + + completionHandler() + } + + private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? { + guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else { + return nil + } + + return mastodonPushNotification } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index b0ab13ed..c6b56c9e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -67,7 +67,7 @@ extension Mastodon.API.Notifications { public static func getNotification( session: URLSession, domain: String, - notificationID: String, + notificationID: Mastodon.Entity.Notification.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get(