2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Initial display of grouped notifications

Contributes to IOS-253
Contributes to IOS-355
This commit is contained in:
shannon 2025-01-14 11:08:56 -05:00
parent 12d737c4e1
commit 6ec1d9a591
7 changed files with 228 additions and 157 deletions

View File

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

View File

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

View File

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

View File

@ -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
// 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)
}
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
}
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 {
notificationIndicatorText = nil
notificationActions = []
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
}
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 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 ?? "")
}
}
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) }
}

View File

@ -80,6 +80,7 @@ extension SearchResultViewController {
)
case .notification, .notificationBanner(_):
assertionFailure()
break
} // end switch
tableView.deselectRow(at: indexPath, animated: true)

View File

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

View File

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