feat: add notification timeline fetcher

This commit is contained in:
CMK 2022-02-11 19:27:14 +08:00
parent 59812807c6
commit d3e8f85cb3
23 changed files with 421 additions and 288 deletions

View File

@ -279,9 +279,8 @@ extension DataSourceFacade {
dependency: NeedsDependency,
status: ManagedObjectRecord<Status>
) 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<Status>
) 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)

View File

@ -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<Status>? = 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<Status>? = 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<Status>? = 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<Status>? = 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

View File

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

View File

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

View File

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

View File

@ -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<Status>? = 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
}
}

View File

@ -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<NotificationSection, NotificationItem>?
var didLoadLatest = PassthroughSubject<Void, Never>()
@ -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,

View File

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

View File

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

View File

@ -264,6 +264,7 @@ extension StatusView {
.assign(to: \.isContentSensitiveToggled, on: viewModel)
.store(in: &disposeBag)
// viewModel.source = status.source
}

View File

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

View File

@ -43,7 +43,7 @@ extension APIService {
try await managedObjectContext.performChanges {
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else {
assertionFailure()
// assertionFailure()
return
}

View File

@ -50,20 +50,18 @@ extension APIService {
}
func cancelSubscription(
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.EmptySubscription>, 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<Mastodon.Entity.EmptySubscription> {
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
}
}

View File

@ -137,58 +137,41 @@ extension AuthenticationService {
.eraseToAnyPublisher()
}
func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher<Result<Bool, Error>, 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<Result<Void, Error>, 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()
}
}

View File

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

View File

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

View File

@ -39,15 +39,6 @@
<relationship name="notification" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Notification" inverseName="feeds" inverseEntity="Notification"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="feeds" inverseEntity="Status"/>
</entity>
<entity name="HomeTimelineIndex" representedClassName="CoreDataStack.HomeTimelineIndex" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="hasMore" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="userID" attributeType="String"/>
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
</entity>
<entity name="Instance" representedClassName="CoreDataStack.Instance" syncable="YES">
<attribute name="configurationRaw" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -262,7 +253,6 @@
<element name="DomainBlock" positionX="45" positionY="162" width="128" height="89"/>
<element name="Emoji" positionX="0" positionY="0" width="128" height="134"/>
<element name="Feed" positionX="54" positionY="171" width="128" height="149"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="Instance" positionX="45" positionY="162" width="128" height="104"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="224"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="734"/>

View File

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

View File

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

View File

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

View File

@ -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<Void, Never>()
@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

View File

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

View File

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