diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift index ea73d2b01e..f073b8939b 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Follow.swift @@ -97,7 +97,7 @@ extension DataSourceFacade { notification.transientFollowRequestState = .init(state: .isRejecting) } - await notificationView.configure(notification: notification, authenticationBox: dependency.authenticationBox) + await notificationView.configure(notification: notification) do { let newRelationship = try await APIService.shared.followRequest( @@ -118,11 +118,11 @@ extension DataSourceFacade { UserInfoKey.relationship: newRelationship ]) - await notificationView.configure(notification: notification, authenticationBox: dependency.authenticationBox) + await notificationView.configure(notification: notification) } catch { // reset state when failure notification.transientFollowRequestState = .init(state: .none) - await notificationView.configure(notification: notification, authenticationBox: dependency.authenticationBox) + await notificationView.configure(notification: notification) if let error = error as? Mastodon.API.Error { switch error.httpResponseStatus { diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift index 5f7b38ffd0..46b2e6801d 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+NotificationTableViewCellDelegate.swift @@ -46,7 +46,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut completion: { (newRelationship: Mastodon.Entity.Relationship) in notification.relationship = newRelationship Task { @MainActor in - notificationView.configure(notification: notification, authenticationBox: self.authenticationBox) + notificationView.configure(notification: notification) } } ) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift index 62c390e92a..5a32572c3a 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController+DataSourceProvider.swift @@ -34,13 +34,22 @@ extension NotificationTimelineViewController: DataSourceProvider { case .notification, .notificationGroup: let item: DataSourceItem? = { // guard feed.kind == .notificationAll || feed.kind == .notificationMentions else { return nil } - - if let notification = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.Notification { - let mastodonNotification = MastodonNotification.fromEntity(notification, relationship: nil) - return .notification(record: mastodonNotification) - } else { - return nil + if let cachedItem = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) { + if let notification = cachedItem as? Mastodon.Entity.Notification { + let mastodonNotification = MastodonNotification.fromEntity(notification, relationship: nil) + return .notification(record: mastodonNotification) + } else if let notificationGroup = cachedItem as? Mastodon.Entity.NotificationGroup { + if let statusID = notificationGroup.statusID, let statusEntity = MastodonFeedItemCacheManager.shared.cachedItem(.status(id: statusID)) as? Mastodon.Entity.Status { + let status = MastodonStatus.fromEntity(statusEntity) + return .status(record: status) + }/* else if notificationGroup.type == .follow { + return .followers + } */ else { + return nil + } + } } + return nil }() return item case .status: diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift index dd9847ff21..d9fe4aa386 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -24,12 +24,7 @@ extension NotificationView { return } - let entity = MastodonNotification.fromEntity( - notification, - relationship: feed.relationship - ) - - configure(notification: entity, authenticationBox: authenticationBox) + configure(notification: notification) } } @@ -37,25 +32,39 @@ extension NotificationView { public func configure(notificationItem: MastodonFeedItemIdentifier) { let item = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) - guard let notification = item as? Mastodon.Entity.Notification, let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { assert(false); return } + if let notification = item as? Mastodon.Entity.Notification { + configure(notificationType: notification.type, status: notification.status) + configure(notification: notification) + } else if let notificationGroup = item as? Mastodon.Entity.NotificationGroup { + let status: Mastodon.Entity.Status? + if let statusID = notificationGroup.statusID { + status = MastodonFeedItemCacheManager.shared.cachedItem(.status(id: statusID)) as? Mastodon.Entity.Status + } else { + status = nil + } + configure(notificationType: notificationGroup.type, status: status) + configure(notificationGroup: notificationGroup) + } + } + private func configure(notificationType: Mastodon.Entity.NotificationType, status: Mastodon.Entity.Status?) { func contentDisplayMode(_ status: Mastodon.Entity.Status) -> StatusView.ContentDisplayMode { let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: MastodonFeedItemCacheManager.shared.shouldShowDespiteFilter(statusID: status.id), showDespiteContentWarning: MastodonFeedItemCacheManager.shared.shouldShowDespiteContentWarning(statusID: status.id)) return contentDisplayModel.effectiveDisplayMode } - switch notification.type { + switch notificationType { case .follow: setAuthorContainerBottomPaddingViewDisplay(isHidden: true) case .followRequest: setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: true) case .mention, .status: - if let status = notification.status { + if let status { statusView.configure(status: status, contentDisplayMode: contentDisplayMode(status)) setStatusViewDisplay() } case .reblog, .favourite, .poll: - if let status = notification.status { + if let status { quoteStatusView.configure(status: status, contentDisplayMode: contentDisplayMode(status)) setQuoteStatusViewDisplay() } @@ -66,12 +75,10 @@ extension NotificationView { setAuthorContainerBottomPaddingViewDisplay() assertionFailure() } - - configure(notification: notification, authenticationBox: authBox) } - public func configure(notification: Mastodon.Entity.Notification, authenticationBox: MastodonAuthenticationBox) { - configureAuthor(notification: notification, authenticationBox: authenticationBox) + public func configure(notification: Mastodon.Entity.Notification) { + configureAuthor(notification: notification) func contentDisplayMode(_ status: Mastodon.Entity.Status) -> StatusView.ContentDisplayMode { let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: MastodonFeedItemCacheManager.shared.shouldShowDespiteFilter(statusID: status.id), showDespiteContentWarning: MastodonFeedItemCacheManager.shared.shouldShowDespiteContentWarning(statusID: status.id)) @@ -103,8 +110,41 @@ extension NotificationView { } - public func configure(notification: MastodonNotification, authenticationBox: MastodonAuthenticationBox) { - configureAuthor(notification: notification, authenticationBox: authenticationBox) + public func configure(notificationGroup: Mastodon.Entity.NotificationGroup) { + configureAuthors(notificationGroup: notificationGroup) + + func contentDisplayMode(_ status: Mastodon.Entity.Status) -> StatusView.ContentDisplayMode { + let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications, showDespiteFilter: MastodonFeedItemCacheManager.shared.shouldShowDespiteFilter(statusID: status.id), showDespiteContentWarning: MastodonFeedItemCacheManager.shared.shouldShowDespiteContentWarning(statusID: status.id)) + return contentDisplayModel.effectiveDisplayMode + } + + switch notificationGroup.type { + case .follow: + setAuthorContainerBottomPaddingViewDisplay(isHidden: true) + case .followRequest: + setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: false) + case .mention, .status: + if let statusID = notificationGroup.statusID, let status = MastodonFeedItemCacheManager.shared.cachedItem(.status(id: statusID)) as? Mastodon.Entity.Status { + statusView.configure(status: status, contentDisplayMode: contentDisplayMode(status)) + setStatusViewDisplay() + } + case .reblog, .favourite, .poll: + if let statusID = notificationGroup.statusID, let status = MastodonFeedItemCacheManager.shared.cachedItem(.status(id: statusID)) as? Mastodon.Entity.Status { + quoteStatusView.configure(status: status, contentDisplayMode: contentDisplayMode(status)) + setQuoteStatusViewDisplay() + } + case .moderationWarning: + // case handled in `AccountWarningNotificationCell.swift` + break + case ._other: + setAuthorContainerBottomPaddingViewDisplay() + assertionFailure() + } + + } + + public func configure(notification: MastodonNotification) { + configureAuthor(notification: notification.entity) func contentDisplayMode(_ status: MastodonStatus) -> StatusView.ContentDisplayMode { let contentDisplayModel = StatusView.ContentConcealViewModel(status: status, filterBox: StatusFilterService.shared.activeFilterBox, filterContext: .notifications) @@ -136,21 +176,33 @@ extension NotificationView { } - private func configureAuthor(notification: Mastodon.Entity.Notification, authenticationBox: MastodonAuthenticationBox) { + private func configureAuthors(notificationGroup: Mastodon.Entity.NotificationGroup) { + let sampleAuthors: [NotificationAuthor] = notificationGroup.sampleAccountIDs.compactMap { MastodonFeedItemCacheManager.shared.fullAccount($0) ?? MastodonFeedItemCacheManager.shared.partialAccount($0) } + configureAuthors(sampleAuthors, notificationID: notificationGroup.id, notificationType: notificationGroup.type, notificationDate: notificationGroup.latestPageNotificationAt) + } + + private func configureAuthor(notification: Mastodon.Entity.Notification) { let author = notification.account + configureAuthors([author], notificationID: notification.id, notificationType: notification.type, notificationDate: notification.createdAt) + } + private func configureAuthors(_ authors: [NotificationAuthor], notificationID: String, notificationType: Mastodon.Entity.NotificationType, notificationDate: Date?) { + guard let author = authors.first as? Mastodon.Entity.Account else { return } + let authorsCount = authors.count + // author avatar - avatarButton.avatarImageView.configure(with: author.avatarImageURL()) + avatarButton.avatarImageView.configure(with: author.avatarURL) avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12))) // author name let metaAuthorName: MetaContent + let andOthers = authorsCount > 1 ? " and \(authorsCount - 1) others" : "" do { - let content = MastodonContent(content: author.displayNameWithFallback, emojis: author.emojis.asDictionary) + let content = MastodonContent(content: author.displayNameWithFallback + andOthers, emojis: author.emojis.asDictionary) metaAuthorName = try MastodonMetaContent.convert(document: content) } catch { assertionFailure(error.localizedDescription) - metaAuthorName = PlaintextMetaContent(string: author.displayNameWithFallback) + metaAuthorName = PlaintextMetaContent(string: author.displayNameWithFallback + andOthers) } authorNameLabel.configure(content: metaAuthorName) @@ -160,115 +212,112 @@ extension NotificationView { // notification type indicator let notificationIndicatorText: MetaContent? - if let type = MastodonNotificationType(rawValue: notification.type.rawValue) { - // TODO: fix the i18n. The subject should assert place at the string beginning - func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { - let content = MastodonContent(content: text, emojis: emojis) - guard let metaContent = try? MastodonMetaContent.convert(document: content) else { - return PlaintextMetaContent(string: text) - } - return metaContent - } - - switch type { - case .follow: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.followedYou, - emojis: author.emojis.asDictionary - ) - case .followRequest: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou, - emojis: author.emojis.asDictionary - ) - case .mention: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.mentionedYou, - emojis: author.emojis.asDictionary - ) - case .reblog: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost, - emojis: author.emojis.asDictionary - ) - case .favourite: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost, - emojis: author.emojis.asDictionary - ) - case .poll: - notificationIndicatorText = createMetaContent( - text: L10n.Scene.Notification.NotificationDescription.pollHasEnded, - emojis: author.emojis.asDictionary - ) - case .status: - notificationIndicatorText = createMetaContent( - text: .empty, - emojis: author.emojis.asDictionary - ) - case ._other: - notificationIndicatorText = nil + // TODO: fix the i18n. The subject should assert place at the string beginning + func createMetaContent(text: String, emojis: MastodonContent.Emojis) -> MetaContent { + let content = MastodonContent(content: text, emojis: emojis) + guard let metaContent = try? MastodonMetaContent.convert(document: content) else { + return PlaintextMetaContent(string: text) } - - var actions = [UIAccessibilityCustomAction]() - - // these notifications can be directly actioned to view the profile - if type != .follow, type != .followRequest { - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Status.showUserProfile, - image: nil - ) { [weak self] _ in - guard let self, let delegate = self.delegate else { return false } - delegate.notificationView(self, authorAvatarButtonDidPressed: self.avatarButton) - return true - } - ) - } - - if type == .followRequest { - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Actions.confirm, - image: Asset.Editing.checkmark20.image - ) { [weak self] _ in - guard let self, let delegate = self.delegate else { return false } - delegate.notificationView(self, acceptFollowRequestButtonDidPressed: self.acceptFollowRequestButton) - return true - } - ) - - actions.append( - UIAccessibilityCustomAction( - name: L10n.Common.Controls.Actions.delete, - image: Asset.Circles.forbidden20.image - ) { [weak self] _ in - guard let self, let delegate = self.delegate else { return false } - delegate.notificationView(self, rejectFollowRequestButtonDidPressed: self.rejectFollowRequestButton) - return true - } - ) - } - - notificationActions = actions - - } else { + return metaContent + } + + switch notificationType { + case .follow: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.followedYou, + emojis: author.emojis.asDictionary + ) + case .followRequest: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.requestToFollowYou, + emojis: author.emojis.asDictionary + ) + case .mention: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.mentionedYou, + emojis: author.emojis.asDictionary + ) + case .reblog: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.rebloggedYourPost, + emojis: author.emojis.asDictionary + ) + case .favourite: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.favoritedYourPost, + emojis: author.emojis.asDictionary + ) + case .poll: + notificationIndicatorText = createMetaContent( + text: L10n.Scene.Notification.NotificationDescription.pollHasEnded, + emojis: author.emojis.asDictionary + ) + case .status: + notificationIndicatorText = createMetaContent( + text: .empty, + emojis: author.emojis.asDictionary + ) + case .moderationWarning: +#warning("Not implemented") + notificationIndicatorText = createMetaContent(text: "Moderation Warning", emojis: author.emojis.asDictionary) + case ._other: notificationIndicatorText = nil - notificationActions = [] } - + + var actions = [UIAccessibilityCustomAction]() + + // these notifications can be directly actioned to view the profile + if notificationType != .follow, notificationType != .followRequest { + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Status.showUserProfile, + image: nil + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, authorAvatarButtonDidPressed: self.avatarButton) + return true + } + ) + } + + if notificationType == .followRequest { + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Actions.confirm, + image: Asset.Editing.checkmark20.image + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, acceptFollowRequestButtonDidPressed: self.acceptFollowRequestButton) + return true + } + ) + + actions.append( + UIAccessibilityCustomAction( + name: L10n.Common.Controls.Actions.delete, + image: Asset.Circles.forbidden20.image + ) { [weak self] _ in + guard let self, let delegate = self.delegate else { return false } + delegate.notificationView(self, rejectFollowRequestButtonDidPressed: self.rejectFollowRequestButton) + return true + } + ) + } + + notificationActions = actions + if let notificationIndicatorText { notificationTypeIndicatorLabel.configure(content: notificationIndicatorText) } else { notificationTypeIndicatorLabel.reset() } - - if let me = authenticationBox.cachedAccount { + + if let me = AuthenticationServiceProvider.shared.currentActiveUser.value?.cachedAccount { let isMyself = (author == me) let isMuting: Bool let isBlocking: Bool - if let relationship = MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: notification.account.id) { + if let relationship = MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: notificationID) { isMuting = relationship.muting isBlocking = relationship.blocking || relationship.domainBlocking } else { @@ -285,32 +334,34 @@ extension NotificationView { menuButton.isHidden = menuContext.isMyself } - timestampUpdatePublisher - .prepend(Date()) - .eraseToAnyPublisher() - .sink { [weak self] now in - guard let self, let type = MastodonNotificationType(rawValue: notification.type.rawValue) else { return } - - let formattedTimestamp = notification.createdAt.localizedAbbreviatedSlowedTimeAgoSinceNow - dateLabel.configure(content: PlaintextMetaContent(string: formattedTimestamp)) - - self.accessibilityLabel = [ - "\(author.displayNameWithFallback) \(type)", - author.acct, - formattedTimestamp - ].joined(separator: ", ") - if self.statusView.isHidden == false { - self.accessibilityLabel! += ", " + (self.statusView.accessibilityLabel ?? "") - } - if self.quoteStatusViewContainerView.isHidden == false { - self.accessibilityLabel! += ", " + (self.quoteStatusView.accessibilityLabel ?? "") + if let notificationDate { + timestampUpdatePublisher + .prepend(Date()) + .eraseToAnyPublisher() + .sink { [weak self] now in + guard let self else { return } + + let formattedTimestamp = notificationDate.localizedAbbreviatedSlowedTimeAgoSinceNow + dateLabel.configure(content: PlaintextMetaContent(string: formattedTimestamp)) + + self.accessibilityLabel = [ + "\(author.displayNameWithFallback) \(notificationType)", + author.acct, + formattedTimestamp + ].joined(separator: ", ") + if self.statusView.isHidden == false { + self.accessibilityLabel! += ", " + (self.statusView.accessibilityLabel ?? "") + } + if self.quoteStatusViewContainerView.isHidden == false { + self.accessibilityLabel! += ", " + (self.quoteStatusView.accessibilityLabel ?? "") + } + } - - } - .store(in: &disposeBag) + .store(in: &disposeBag) + } - if notification.type == .followRequest { - let followRequestState = MastodonFeedItemCacheManager.shared.followRequestState(forFollowRequestNotification: notification.id).state + if notificationType == .followRequest { + let followRequestState = MastodonFeedItemCacheManager.shared.followRequestState(forFollowRequestNotification: notificationID).state switch followRequestState { case .none: break @@ -576,3 +627,15 @@ extension MastodonFollowRequestState.State { } } } + +protocol NotificationAuthor { + var avatarURL: URL? { get } +} + +extension Mastodon.Entity.Account: NotificationAuthor { + var avatarURL: URL? { avatarImageURL() } +} + +extension Mastodon.Entity.PartialAccountWithAvatar: NotificationAuthor { + var avatarURL: URL? { URL(string: avatar) } +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift index da88fff290..ff593c5451 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController+DataSourceProvider.swift @@ -80,6 +80,7 @@ extension SearchResultViewController { ) case .notification, .notificationBanner(_): assertionFailure() + break } // end switch tableView.deselectRow(at: indexPath, animated: true) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 9936a4f600..081374bc56 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -56,7 +56,7 @@ extension Mastodon.Entity { public let id: ID public let notificationsCount: Int public let type: NotificationType - public let mostRecentNotificationID: ID + public let mostRecentNotificationID: Int public let pageOldestID: ID? // ID of the oldest notification from this group represented within the current page. This is only returned when paginating through notification groups. Useful when polling new notifications. public let pageNewestID: ID? // ID of the newest notification from this group represented within the current page. This is only returned when paginating through notification groups. Useful when polling new notifications. public let latestPageNotificationAt: Date? // Date at which the most recent notification from this group within the current page has been created. This is only returned when paginating through notification groups. diff --git a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift index 85f27fbe61..a21fd8d985 100644 --- a/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift +++ b/MastodonSDK/Sources/MastodonSDK/MastodonFeed.swift @@ -209,13 +209,11 @@ public class MastodonFeedItemCacheManager { } public func partialAccount(_ id: String) -> Mastodon.Entity.PartialAccountWithAvatar? { - assertionFailure("not implemented") - return nil + return partialAccountsCache[id] } public func fullAccount(_ id: String) -> Mastodon.Entity.Account? { - assertionFailure("not implemented") - return nil + return fullAccountsCache[id] } private func contentStatusID(forStatus statusID: String) -> String {