From d3e8f85cb3b1990b1fc599876d7a4b8e1ace20fe Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 11 Feb 2022 19:27:14 +0800 Subject: [PATCH] feat: add notification timeline fetcher --- .../Provider/DataSourceFacade+Status.swift | 10 +- ...er+NotificationTableViewCellDelegate.swift | 101 ++++++++++++- .../HomeTimelineViewController.swift | 23 +-- .../NotificationTableViewCell+ViewModel.swift | 23 ++- .../NotificationTableViewCellDelegate.swift | 20 +++ .../NotificationTimelineViewController.swift | 69 ++++++++- .../NotificationTimelineViewModel.swift | 8 +- .../Timeline/UserTimelineViewController.swift | 1 + .../Settings/SettingsViewController.swift | 23 +-- .../Content/StatusView+Configuration.swift | 1 + .../StatusTableViewCell+ViewModel.swift | 8 +- .../APIService/APIService+Relationship.swift | 2 +- .../APIService/APIService+Subscriptions.swift | 20 ++- Mastodon/Service/AuthenticationService.swift | 75 ++++------ Mastodon/Service/NotificationService.swift | 141 ++++++++++++------ Mastodon/Supporting Files/AppDelegate.swift | 16 +- .../CoreData 3.xcdatamodel/contents | 10 -- .../CoreDataStack/Entity/App/Feed.swift | 2 +- .../Entity/App/HomeTimelineIndex.swift | 102 ------------- .../View/Content/NotificationView.swift | 23 ++- .../View/Content/StatusView+ViewModel.swift | 26 +++- .../MastodonUI/View/Content/StatusView.swift | 2 - .../View/Control/SpoilerBannerView.swift | 3 +- 23 files changed, 421 insertions(+), 288 deletions(-) delete mode 100644 MastodonSDK/Sources/CoreDataStack/Entity/App/HomeTimelineIndex.swift diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift index 54dce3bb..41c967b8 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Status.swift @@ -279,9 +279,8 @@ extension DataSourceFacade { dependency: NeedsDependency, status: ManagedObjectRecord ) async throws { - let managedObjectContext = dependency.context.managedObjectContext - try await managedObjectContext.performChanges { - guard let _status = status.object(in: managedObjectContext) else { return } + try await dependency.context.managedObjectContext.perform { + guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } let status = _status.reblog ?? _status let isToggled = status.isContentSensitiveToggled || status.isMediaSensitiveToggled @@ -295,9 +294,8 @@ extension DataSourceFacade { dependency: NeedsDependency, status: ManagedObjectRecord ) async throws { - let managedObjectContext = dependency.context.managedObjectContext - try await managedObjectContext.performChanges { - guard let _status = status.object(in: managedObjectContext) else { return } + try await dependency.context.managedObjectContext.perform { + guard let _status = status.object(in: dependency.context.managedObjectContext) else { return } let status = _status.reblog ?? _status status.update(isMediaSensitiveToggled: !status.isMediaSensitiveToggled) diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index 33efe385..56b5cf2b 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -202,7 +202,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider { return } guard case let .notification(notification) = item else { - assertionFailure("only works for status data provider") + assertionFailure("only works for notification item") return } let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { @@ -222,6 +222,105 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider { ) } + + + func tableViewCell( + _ cell: UITableViewCell, notificationView: NotificationView, + statusView: StatusView, + spoilerBannerViewDidPressed bannerView: SpoilerBannerView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + quoteStatusView: StatusView, + spoilerBannerViewDidPressed bannerView: SpoilerBannerView + ) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + } // end Task + } + + } // MARK: a11y diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 5985194e..2af35707 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -424,28 +424,15 @@ extension HomeTimelineViewController { } @objc func signOutAction(_ sender: UIAction) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - context.authenticationService.signOutMastodonUser( - domain: activeMastodonAuthenticationBox.domain, - userID: activeMastodonAuthenticationBox.userID - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isSignOut): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") - guard isSignOut else { return } - self.coordinator.setup() - self.coordinator.setupOnboardingIfNeeds(animated: true) - } + Task { @MainActor in + try await context.authenticationService.signOutMastodonUser(authenticationBox: authenticationBox) + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) } - .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift index dc16346c..be230391 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCell+ViewModel.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine import CoreDataStack extension NotificationTableViewCell { @@ -42,8 +43,26 @@ extension NotificationTableViewCell { case .feed(let feed): notificationView.configure(feed: feed) } -// - self.delegate = delegate + + self.delegate = delegate + + Publishers.CombineLatest( + notificationView.statusView.viewModel.$isContentReveal.removeDuplicates(), + notificationView.quoteStatusView.viewModel.$isContentReveal.removeDuplicates() + ) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak tableView, weak self] _, _ in + guard let tableView = tableView else { return } + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): tableView updates") + + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift index 77fbca71..31370f74 100644 --- a/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift +++ b/Mastodon/Scene/Notification/Cell/NotificationTableViewCellDelegate.swift @@ -26,9 +26,13 @@ protocol NotificationTableViewCellDelegate: AnyObject, AutoGenerateProtocolDeleg func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, authorAvatarButtonDidPressed button: AvatarButton) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) // sourcery:end } @@ -49,6 +53,14 @@ extension NotificationViewDelegate where Self: NotificationViewContainerTableVie delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, metaText: metaText, didSelectMeta: meta) } + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, spoilerOverlayViewDidPressed: overlayView) + } + + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) { + delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, spoilerBannerViewDidPressed: bannerView) + } + func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) { delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action) } @@ -61,6 +73,14 @@ extension NotificationViewDelegate where Self: NotificationViewContainerTableVie delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, metaText: metaText, didSelectMeta: meta) } + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, spoilerOverlayViewDidPressed: overlayView) + } + + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) { + delegate?.tableViewCell(self, notificationView: notificationView, quoteStatusView: quoteStatusView, spoilerBannerViewDidPressed: bannerView) + } + func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) { delegate?.tableViewCell(self, notificationView: notificationView, accessibilityActivate: accessibilityActivate) } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index b587ff74..44d165ba 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreDataStack final class NotificationTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -94,6 +95,30 @@ extension NotificationTimelineViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !viewModel.isLoadingLatest { + let now = Date() + if let timestamp = viewModel.lastAutomaticFetchTimestamp { + if now.timeIntervalSince(timestamp) > 60 { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): auto fetch latest timeline…") + Task { + await viewModel.loadLatest() + } + viewModel.lastAutomaticFetchTimestamp = now + } else { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): auto fetch latest timeline skip. Reason: updated in recent 60s") + } + } else { + Task { + await viewModel.loadLatest() + } + viewModel.lastAutomaticFetchTimestamp = now + } + } + } + } extension NotificationTimelineViewController { @@ -150,4 +175,46 @@ extension NotificationTimelineViewController: UITableViewDelegate, AutoGenerateT } // MARK: - NotificationTableViewCellDelegate -extension NotificationTimelineViewController: NotificationTableViewCellDelegate { } +extension NotificationTimelineViewController: NotificationTableViewCellDelegate { + + func tableViewCell( + _ cell: UITableViewCell, + notificationView: NotificationView, + statusView: StatusView, + spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView + ) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let reloadItem = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .notification(notification) = item else { + assertionFailure("only works for notification item") + return + } + let _status: ManagedObjectRecord? = try await self.context.managedObjectContext.perform { + guard let notification = notification.object(in: self.context.managedObjectContext) else { return nil } + guard let status = notification.status else { return nil } + return .init(objectID: status.objectID) + } + guard let status = _status else { + assertionFailure() + return + } + try await DataSourceFacade.responseToToggleSensitiveAction( + dependency: self, + status: status + ) + +// var snapshot = diffableDataSource.snapshot() +// snapshot.reloadItems([reloadItem]) +// diffableDataSource.apply(snapshot, animatingDifferences: false) + } // end Task + } + +} diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index 41e4cef0..eb24f3a3 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -23,7 +23,9 @@ final class NotificationTimelineViewModel { let scope: Scope let feedFetchedResultsController: FeedFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() - + @Published var isLoadingLatest = false + @Published var lastAutomaticFetchTimestamp: Date? + // output var diffableDataSource: UITableViewDiffableDataSource? var didLoadLatest = PassthroughSubject() @@ -144,6 +146,10 @@ extension NotificationTimelineViewModel { // load lastest func loadLatest() async { guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + isLoadingLatest = true + defer{ isLoadingLatest = false } + do { _ = try await context.apiService.notifications( maxID: nil, diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 0b1717f5..0b836da7 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -30,6 +30,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 100 tableView.separatorStyle = .none tableView.backgroundColor = .clear return tableView diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index cf20e2df..172602c3 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -294,31 +294,18 @@ extension SettingsViewController { } func signOut() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } // clear badge before sign-out context.notificationService.clearNotificationCountForActiveUser() - context.authenticationService.signOutMastodonUser( - domain: activeMastodonAuthenticationBox.domain, - userID: activeMastodonAuthenticationBox.userID - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .success(let isSignOut): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") - guard isSignOut else { return } - self.coordinator.setup() - self.coordinator.setupOnboardingIfNeeds(animated: true) - } + Task { @MainActor in + try await context.authenticationService.signOutMastodonUser(authenticationBox: authenticationBox) + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) } - .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift index 6854ffc7..85105c2c 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView+Configuration.swift @@ -264,6 +264,7 @@ extension StatusView { .assign(to: \.isContentSensitiveToggled, on: viewModel) .store(in: &disposeBag) + // viewModel.source = status.source } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift index 84dd4943..85184d40 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell+ViewModel.swift @@ -53,12 +53,14 @@ extension StatusTableViewCell { self.delegate = delegate - statusView.viewModel.isNeedsTableViewUpdate + statusView.viewModel.$isContentReveal + .removeDuplicates() + .dropFirst() .receive(on: DispatchQueue.main) - .sink { [weak tableView, weak self] in + .sink { [weak tableView, weak self] _ in guard let tableView = tableView else { return } guard let _ = self else { return } - + UIView.performWithoutAnimation { tableView.beginUpdates() tableView.endUpdates() diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 8c10f137..a852eaf6 100644 --- a/Mastodon/Service/APIService/APIService+Relationship.swift +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -43,7 +43,7 @@ extension APIService { try await managedObjectContext.performChanges { guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { - assertionFailure() + // assertionFailure() return } diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index e9df2bc5..825bdcd4 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -50,20 +50,18 @@ extension APIService { } func cancelSubscription( - mastodonAuthenticationBox: MastodonAuthenticationBox - ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let domain = mastodonAuthenticationBox.domain - - return Mastodon.API.Subscriptions.removeSubscription( + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) async throws -> Mastodon.Response.Content { + let response = try await Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, authorization: authorization - ) - .handleEvents(receiveOutput: { _ in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) - }) - .eraseToAnyPublisher() + ).singleOutput() + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + + return response } } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index b587a573..97afde93 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -137,58 +137,41 @@ extension AuthenticationService { .eraseToAnyPublisher() } - func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { - var isSignOut = false - - var _mastodonAuthenticationBox: MastodonAuthenticationBox? + func signOutMastodonUser( + authenticationBox: MastodonAuthenticationBox + ) async throws { let managedObjectContext = backgroundManagedObjectContext - return managedObjectContext.performChanges { - let request = MastodonAuthentication.sortedFetchRequest - request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) - request.fetchLimit = 1 - guard let mastodonAuthentication = try? managedObjectContext.fetch(request).first else { - return - } - _mastodonAuthenticationBox = MastodonAuthenticationBox( - authenticationRecord: .init(objectID: mastodonAuthentication.objectID), - domain: mastodonAuthentication.domain, - userID: mastodonAuthentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) + try await managedObjectContext.performChanges { + // remove Feed + let request = Feed.sortedFetchRequest + request.predicate = Feed.predicate( + acct: .mastodon( + domain: authenticationBox.domain, + userID: authenticationBox.userID + ) ) - - // remove home timeline indexes - let homeTimelineIndexRequest = HomeTimelineIndex.sortedFetchRequest - homeTimelineIndexRequest.predicate = HomeTimelineIndex.predicate( - domain: mastodonAuthentication.domain, - userID: mastodonAuthentication.userID - ) - let homeTimelineIndexes = managedObjectContext.safeFetch(homeTimelineIndexRequest) - for homeTimelineIndex in homeTimelineIndexes { - managedObjectContext.delete(homeTimelineIndex) - } - - // remove user authentication - managedObjectContext.delete(mastodonAuthentication) - isSignOut = true - } - .flatMap { result -> AnyPublisher, Never> in - guard let apiService = self.apiService, - let mastodonAuthenticationBox = _mastodonAuthenticationBox else { - return Just(result).eraseToAnyPublisher() + let feeds = managedObjectContext.safeFetch(request) + for feed in feeds { + managedObjectContext.delete(feed) } - return apiService.cancelSubscription( - mastodonAuthenticationBox: mastodonAuthenticationBox + guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext) else { + assertionFailure() + throw APIService.APIError.implicit(.authenticationMissing) + } + + managedObjectContext.delete(authentication) + } + + // cancel push notification subscription + do { + _ = try await apiService?.cancelSubscription( + domain: authenticationBox.domain, + authorization: authenticationBox.userAuthorization ) - .map { _ in result } - .catch { _ in Just(result).eraseToAnyPublisher() } - .eraseToAnyPublisher() + } catch { + // do nothing } - .map { result in - return result.map { isSignOut } - } - .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index d707c1ee..e4e7508a 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -121,57 +121,20 @@ extension NotificationService { return _notificationSubscription } - func handle(mastodonPushNotification: MastodonPushNotification) { + func handle( + pushNotification: MastodonPushNotification + ) { defer { unreadNotificationCountDidUpdate.send() } - - // Subscription maybe failed to cancel when sign-out - // Try cancel again if receive that kind push notification - guard let managedObjectContext = authenticationService?.managedObjectContext else { return } - guard let apiService = apiService else { return } - managedObjectContext.perform { - let subscriptionRequest = NotificationSubscription.sortedFetchRequest - subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) - let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + Task { + // trigger notification timeline update + try? await fetchLatestNotifications(pushNotification: pushNotification) - // note: assert setting remove after cancel subscription - guard let subscription = subscriptions.first else { return } - guard let setting = subscription.setting else { return } - let domain = setting.domain - let userID = setting.userID - - let authenticationRequest = MastodonAuthentication.sortedFetchRequest - authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) - guard let authentication = managedObjectContext.safeFetch(authenticationRequest).first else { - // do nothing if still sign-in - return - } - - // cancel subscription if sign-out - let accessToken = mastodonPushNotification.accessToken - let mastodonAuthenticationBox = MastodonAuthenticationBox( - authenticationRecord: .init(objectID: authentication.objectID), - domain: domain, - userID: userID, - appAuthorization: .init(accessToken: accessToken), - userAuthorization: .init(accessToken: accessToken) - ) - apiService - .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) - } - } receiveValue: { _ in - // do nothing - } - .store(in: &self.disposeBag) - } + // cancel sign-out account push notification subscription + try? await cancelSubscriptionForDetachedAccount(pushNotification: pushNotification) + } // end Task } } @@ -187,6 +150,92 @@ extension NotificationService { } } +extension NotificationService { + private func fetchLatestNotifications( + pushNotification: MastodonPushNotification + ) async throws { + guard let apiService = apiService else { return } + guard let authenticationBox = try await authenticationBox(for: pushNotification) else { return } + + _ = try await apiService.notifications( + maxID: nil, + scope: .everything, + authenticationBox: authenticationBox + ) + } + + private func cancelSubscriptionForDetachedAccount( + pushNotification: MastodonPushNotification + ) async throws { + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + let userAccessToken = pushNotification.accessToken + + let needsCancelSubscription: Bool = try await managedObjectContext.perform { + // check authentication exists + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(userAccessToken: userAccessToken) + return managedObjectContext.safeFetch(authenticationRequest).first == nil + } + + guard needsCancelSubscription else { + return + } + + guard let domain = try await domain(for: pushNotification) else { return } + + do { + _ = try await apiService.cancelSubscription( + domain: domain, + authorization: .init(accessToken: userAccessToken) + ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } + + private func domain(for pushNotification: MastodonPushNotification) async throws -> String? { + guard let authenticationService = self.authenticationService else { return nil } + let managedObjectContext = authenticationService.managedObjectContext + return try await managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: pushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting not remove after sign-out + guard let subscription = subscriptions.first else { return nil } + guard let setting = subscription.setting else { return nil } + let domain = setting.domain + + return domain + } + } + + private func authenticationBox(for pushNotification: MastodonPushNotification) async throws -> MastodonAuthenticationBox? { + guard let authenticationService = self.authenticationService else { return nil } + let managedObjectContext = authenticationService.managedObjectContext + return try await managedObjectContext.perform { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(userAccessToken: pushNotification.accessToken) + request.fetchLimit = 1 + guard let authentication = managedObjectContext.safeFetch(request).first else { return nil } + + return MastodonAuthenticationBox( + authenticationRecord: .init(objectID: authentication.objectID), + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: .init(accessToken: authentication.appAccessToken), + userAuthorization: .init(accessToken: authentication.userAccessToken) + ) + } + } + +} + // MARK: - NotificationViewModel extension NotificationService { diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 5989f80a..7b1185f8 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -90,19 +90,19 @@ 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) - guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + guard let pushNotification = AppDelegate.mastodonPushNotification(from: notification) else { completionHandler([]) return } - let notificationID = String(mastodonPushNotification.notificationID) + let notificationID = String(pushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - let accessToken = mastodonPushNotification.accessToken + let accessToken = pushNotification.accessToken UserDefaults.shared.increaseNotificationCount(accessToken: accessToken) appContext.notificationService.applicationIconBadgeNeedsUpdate.send() - appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) + appContext.notificationService.handle(pushNotification: pushNotification) completionHandler([.sound]) } @@ -114,15 +114,15 @@ 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 { + guard let pushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { completionHandler() return } - let notificationID = String(mastodonPushNotification.notificationID) + let notificationID = String(pushNotification.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(mastodonPushNotification) + appContext.notificationService.handle(pushNotification: pushNotification) + appContext.notificationService.requestRevealNotificationPublisher.send(pushNotification) completionHandler() } diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents index fbdf742e..3e2fcc13 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 3.xcdatamodel/contents @@ -39,15 +39,6 @@ - - - - - - - - - @@ -262,7 +253,6 @@ - diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift index 0a44a409..5fca6115 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/App/Feed.swift @@ -74,7 +74,7 @@ extension Feed { return NSPredicate(format: "%K == %@", #keyPath(Feed.kindRaw), kind.rawValue) } - static func predicate(acct: Acct) -> NSPredicate { + public static func predicate(acct: Acct) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(Feed.acctRaw), acct.rawValue) } diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/App/HomeTimelineIndex.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/HomeTimelineIndex.swift deleted file mode 100644 index d52d0c3c..00000000 --- a/MastodonSDK/Sources/CoreDataStack/Entity/App/HomeTimelineIndex.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// HomeTimelineIndex.swift -// CoreDataStack -// -// Created by MainasuK Cirno on 2021/1/27. -// - -import Foundation -import CoreData - -final public class HomeTimelineIndex: NSManagedObject { - - public typealias ID = String - @NSManaged public private(set) var identifier: ID - @NSManaged public private(set) var domain: String - @NSManaged public private(set) var userID: String - - @NSManaged public private(set) var hasMore: Bool // default NO - - @NSManaged public private(set) var createdAt: Date - @NSManaged public private(set) var deletedAt: Date? - - - // many-to-one relationship - @NSManaged public private(set) var status: Status - -} - -extension HomeTimelineIndex { - - @discardableResult - public static func insert( - into context: NSManagedObjectContext, - property: Property, - status: Status - ) -> HomeTimelineIndex { - let index: HomeTimelineIndex = context.insertObject() - - index.identifier = property.identifier - index.domain = property.domain - index.userID = property.userID - index.createdAt = status.createdAt - - index.status = status - - return index - } - - public func update(hasMore: Bool) { - if self.hasMore != hasMore { - self.hasMore = hasMore - } - } - - // internal method for status call - func softDelete() { - deletedAt = Date() - } - -} - -extension HomeTimelineIndex { - public struct Property { - public let identifier: String - public let domain: String - public let userID: String - - public init(domain: String, userID: String) { - self.identifier = UUID().uuidString + "@" + domain - self.domain = domain - self.userID = userID - } - } -} - -extension HomeTimelineIndex: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \HomeTimelineIndex.createdAt, ascending: false)] - } -} -extension HomeTimelineIndex { - - static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.domain), domain) - } - - static func predicate(userID: MastodonUser.ID) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(HomeTimelineIndex.userID), userID) - } - - public static func predicate(domain: String, userID: MastodonUser.ID) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - predicate(domain: domain), - predicate(userID: userID) - ]) - } - - public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(HomeTimelineIndex.deletedAt)) - } - -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index a16684c9..745c112f 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -18,10 +18,15 @@ public protocol NotificationViewDelegate: AnyObject { func notificationView(_ notificationView: NotificationView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) + func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton) func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) + func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) // a11y func notificationView(_ notificationView: NotificationView, accessibilityActivate: Void) @@ -384,11 +389,25 @@ extension NotificationView: StatusViewDelegate { } public func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) { - assertionFailure() + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, spoilerOverlayViewDidPressed: overlayView) + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, spoilerOverlayViewDidPressed: overlayView) + default: + assertionFailure() + } } public func statusView(_ statusView: StatusView, spoilerBannerViewDidPressed bannerView: SpoilerBannerView) { - assertionFailure() + switch statusView { + case self.statusView: + delegate?.notificationView(self, statusView: statusView, spoilerBannerViewDidPressed: bannerView) + case quoteStatusView: + delegate?.notificationView(self, quoteStatusView: statusView, spoilerBannerViewDidPressed: bannerView) + default: + assertionFailure() + } } public func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 5b762c2f..baaf5bbf 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -88,9 +88,7 @@ extension StatusView { @Published public var replyCount: Int = 0 @Published public var reblogCount: Int = 0 @Published public var favoriteCount: Int = 0 - - public let isNeedsTableViewUpdate = PassthroughSubject() - + @Published public var groupedAccessibilityLabel = "" let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -136,9 +134,23 @@ extension StatusView { init() { // isReblogEnabled - $locked - .map { !$0 } - .assign(to: &$isReblogEnabled) + Publishers.CombineLatest( + $visibility, + $isMyself + ) + .map { visibility, isMyself in + if isMyself { + return true + } + + switch visibility { + case .public, .unlisted: + return true + case .private, .direct, ._other: + return false + } + } + .assign(to: &$isReblogEnabled) // isContentSensitive $spoilerContent .map { $0 != nil } @@ -292,7 +304,7 @@ extension StatusView.ViewModel { statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal) - self.isNeedsTableViewUpdate.send() + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)") } .store(in: &disposeBag) // visibility diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 00415277..ff0e13c6 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -571,7 +571,6 @@ extension StatusView.Style { statusView.headerContainerView.removeFromSuperview() statusView.authorContainerView.removeFromSuperview() statusView.statusVisibilityView.removeFromSuperview() - statusView.spoilerBannerView.removeFromSuperview() } func notificationQuote(statusView: StatusView) { @@ -580,7 +579,6 @@ extension StatusView.Style { statusView.contentContainer.layoutMargins.bottom = 16 // fix contentText align to edge issue statusView.menuButton.removeFromSuperview() statusView.statusVisibilityView.removeFromSuperview() - statusView.spoilerBannerView.removeFromSuperview() statusView.actionToolbarContainer.removeFromSuperview() } diff --git a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift index 5552be63..9b5aa5a0 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Control/SpoilerBannerView.swift @@ -22,7 +22,6 @@ public final class SpoilerBannerView: UIView { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) - label.numberOfLines = 0 label.text = "Hide" // TODO: i18n return label }() @@ -75,8 +74,8 @@ extension SpoilerBannerView { ]) labelContainer.addArrangedSubview(label) + labelContainer.addArrangedSubview(UIView()) labelContainer.addArrangedSubview(hideLabel) - label.setContentHuggingPriority(.defaultLow, for: .horizontal) hideLabel.setContentHuggingPriority(.required - 1, for: .horizontal) hideLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal)