From 6a27b507286f6e9bc77cf1415a5e8460a7e2dd95 Mon Sep 17 00:00:00 2001 From: shannon Date: Thu, 13 Feb 2025 18:14:37 -0500 Subject: [PATCH] Add url actions and begin localizing grouped notification display With refactoring to make that easier and more readable. Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification --- .../NotificationInfo.swift | 178 ++------ .../NotificationRowView.swift | 274 +++++++++-- .../NotificationRowViewModel.swift | 432 ++++++++++++------ .../TimelinePostCell/TimelinePostCell.swift | 4 +- 4 files changed, 566 insertions(+), 322 deletions(-) diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift index 98e0894ff..16f681550 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift @@ -8,12 +8,11 @@ protocol NotificationInfo { var id: String { get } var newestNotificationID: String { get } var oldestNotificationID: String { get } - var type: Mastodon.Entity.NotificationType { get } + var typeFromServer: Mastodon.Entity.NotificationType { get } var isGrouped: Bool { get } var notificationsCount: Int { get } var authorsCount: Int { get } var primaryAuthorAccount: Mastodon.Entity.Account? { get } - var authorName: Mastodon.Entity.NotificationType.AuthorName? { get } var authorAvatarUrls: [URL] { get } func availableRelationshipElement() async -> RelationshipElement? func fetchRelationshipElement() async -> RelationshipElement @@ -21,28 +20,26 @@ protocol NotificationInfo { var relationshipSeveranceEvent: Mastodon.Entity.RelationshipSeveranceEvent? { get } } -extension NotificationInfo { - var authorsDescription: String? { - switch authorName { - case .me, .none: - return nil - case .other(let name): - if authorsCount > 1 { - return "\(name) and \(authorsCount - 1) others" - } else { - return name - } - } - } - var avatarCount: Int { - min(authorsCount, 8) - } - var isGrouped: Bool { - return authorsCount > 1 - } + +enum GroupedNotificationType { + // TODO: update to use StatusViewModel rather than Status + case follow(from: NotificationSourceAccounts) // Someone followed you + case followRequest(from: Mastodon.Entity.Account) // Someone requested to follow you + case mention(Mastodon.Entity.Status?) // Someone mentioned you in their status + case reblog(Mastodon.Entity.Status?) // Someone boosted one of your statuses + case favourite(Mastodon.Entity.Status?) // Someone favourited one of your statuses + case poll(Mastodon.Entity.Status?) // A poll you have voted in or created has ended + case status(Mastodon.Entity.Status?) // Someone you enabled notifications for has posted a status + case update(Mastodon.Entity.Status?) // A status you interacted with has been edited + case adminSignUp // Someone signed up (optionally sent to admins) + case adminReport(Mastodon.Entity.Report?) // A new report has been filed + case severedRelationships(Mastodon.Entity.RelationshipSeveranceEvent?) // Some of your follow relationships have been severed as a result of a moderation or block event + case moderationWarning(Mastodon.Entity.AccountWarning?) // A moderator has taken action against your account or has sent you a warning + + case _other(String) } -struct GroupedNotificationInfo: NotificationInfo { +struct GroupedNotificationInfo { func availableRelationshipElement() async -> RelationshipElement? { return relationshipElement } @@ -55,23 +52,21 @@ struct GroupedNotificationInfo: NotificationInfo { let oldestNotificationID: String let newestNotificationID: String - let type: MastodonSDK.Mastodon.Entity.NotificationType + let groupedNotificationType: GroupedNotificationType - let authorsCount: Int - - let notificationsCount: Int - - let primaryAuthorAccount: MastodonSDK.Mastodon.Entity.Account? - - let authorName: Mastodon.Entity.NotificationType.AuthorName? - - let authorAvatarUrls: [URL] + let sourceAccounts: NotificationSourceAccounts var relationshipElement: RelationshipElement { - switch type { - case .follow, .followRequest: - if let primaryAuthorAccount { - return .unfetched(type, accountID: primaryAuthorAccount.id) + switch groupedNotificationType { + case .follow(let accountsInfo): + if let primaryAuthorAccount = accountsInfo.primaryAuthorAccount { + return .unfetched(groupedNotificationType) + } else { + return .error(nil) + } + case .followRequest(let account): + if sourceAccounts.totalActorCount == 1 { + return .unfetched(groupedNotificationType) } else { return .error(nil) } @@ -81,13 +76,14 @@ struct GroupedNotificationInfo: NotificationInfo { } let statusViewModel: Mastodon.Entity.Status.ViewModel? - let ruleViolationReport: Mastodon.Entity.Report? - let relationshipSeveranceEvent: Mastodon.Entity.RelationshipSeveranceEvent? let defaultNavigation: (() -> Void)? } extension Mastodon.Entity.Notification: NotificationInfo { + var isGrouped: Bool { + return false + } var oldestNotificationID: String { return id @@ -96,12 +92,14 @@ extension Mastodon.Entity.Notification: NotificationInfo { return id } + var typeFromServer: Mastodon.Entity.NotificationType { + return type + } + var authorsCount: Int { 1 } var notificationsCount: Int { 1 } var primaryAuthorAccount: Mastodon.Entity.Account? { account } - var authorName: Mastodon.Entity.NotificationType.AuthorName? { - .other(named: account.displayNameWithFallback) - } + var authorAvatarUrls: [URL] { if let domain = account.domain { return [account.avatarImageURLWithFallback(domain: domain)] @@ -146,101 +144,3 @@ extension Mastodon.Entity.Notification: NotificationInfo { } } -extension Mastodon.Entity.NotificationGroup: NotificationInfo { - - var newestNotificationID: String { - return pageNewestID ?? "\(mostRecentNotificationID)" - } - var oldestNotificationID: String { - return pageOldestID ?? "\(mostRecentNotificationID)" - } - - @MainActor - var primaryAuthorAccount: Mastodon.Entity.Account? { - guard let firstAccountID = sampleAccountIDs.first else { return nil } - return MastodonFeedItemCacheManager.shared.fullAccount(firstAccountID) - } - - var authorsCount: Int { notificationsCount } - - @MainActor - var authorName: Mastodon.Entity.NotificationType.AuthorName? { - guard let firstAccountID = sampleAccountIDs.first, - let firstAccount = MastodonFeedItemCacheManager.shared.fullAccount( - firstAccountID) - else { return .none } - return .other(named: firstAccount.displayNameWithFallback) - } - - @MainActor - var authorAvatarUrls: [URL] { - return - sampleAccountIDs - .prefix(avatarCount) - .compactMap { accountID in - let account: NotificationAuthor? = - MastodonFeedItemCacheManager.shared.fullAccount(accountID) - ?? MastodonFeedItemCacheManager.shared.partialAccount( - accountID) - return account?.avatarURL - } - } - - @MainActor - var firstAccount: NotificationAuthor? { - guard let firstAccountID = sampleAccountIDs.first else { return nil } - let firstAccount: NotificationAuthor? = - MastodonFeedItemCacheManager.shared.fullAccount(firstAccountID) - ?? MastodonFeedItemCacheManager.shared.partialAccount( - firstAccountID) - return firstAccount - } - - @MainActor - func availableRelationshipElement() -> RelationshipElement? { - guard authorsCount == 1 && type == .follow else { return .noneNeeded } - guard let firstAccountID = sampleAccountIDs.first else { - return .noneNeeded - } - if let relationship = MastodonFeedItemCacheManager.shared - .currentRelationship(toAccount: firstAccountID) - { - return relationship.relationshipElement - } - return nil - } - - @MainActor - func fetchRelationshipElement() async -> RelationshipElement { - do { - try await fetchRelationship() - if let available = availableRelationshipElement() { - return available - } else { - return .noneNeeded - } - } catch { - return .error(error) - } - } - - func fetchRelationship() async throws { - assert( - notificationsCount == 1, - "one relationship cannot be assumed representative of \(notificationsCount) notifications" - ) - guard let firstAccountId = sampleAccountIDs.first, - let authBox = await AuthenticationServiceProvider.shared - .currentActiveUser.value - else { return } - if let relationship = try await APIService.shared.relationship( - forAccountIds: [firstAccountId], authenticationBox: authBox - ).value.first { - await MastodonFeedItemCacheManager.shared.addToCache(relationship) - } - } - - var statusViewModel: MastodonSDK.Mastodon.Entity.Status.ViewModel? { - return nil - } -} diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift index 25760fb2d..9c9bd5c59 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift @@ -78,19 +78,6 @@ extension Mastodon.Entity.NotificationType { } } - enum AuthorName { - case me - case other(named: String) - - var string: String { - switch self { - case .me: - return "You" - case .other(let name): - return name - } - } - } func actionSummaryLabel(firstAuthor: AuthorName, totalAuthorCount: Int) -> AttributedString { @@ -169,6 +156,167 @@ extension Mastodon.Entity.NotificationType { } } +enum AuthorName { + case me + case other(named: String) + + var string: String { + switch self { + case .me: + return "You" + case .other(let name): + return name + } + } +} + +extension GroupedNotificationType { + func shouldShowIcon( + grouped: Bool, visibility: Mastodon.Entity.Status.Visibility? + ) -> Bool { + return iconSystemName(grouped: grouped, visibility: visibility) != nil + } + + func iconSystemName( + grouped: Bool = false, visibility: Mastodon.Entity.Status.Visibility? + ) -> String? { + switch self { + case .favourite: + return "star.fill" + case .reblog: + return "arrow.2.squarepath" + case .follow: + if grouped { + return "person.2.badge.plus.fill" + } else { + return "person.fill.badge.plus" + } + case .poll: + return "chart.bar.yaxis" + case .adminReport: + return "info.circle" + case .severedRelationships: + return "person.badge.minus" + case .moderationWarning: + return "exclamationmark.shield.fill" + case ._other: + return "questionmark.square.dashed" + case .mention: + // TODO: make this nil when full status view is available + switch visibility { + case .direct: + return "at.circle.fill" + default: + return "at" + } + case .status: + // TODO: make this nil when full status view is available + return "bell.fill" + case .followRequest: + return "person.fill.badge.plus" + case .update: + return "pencil" + case .adminSignUp: + return nil + } + } + + var iconColor: Color { + switch self { + case .favourite: + return .orange + case .reblog: + return .green + case .follow, .followRequest, .status, .mention, .update: + return Color(asset: Asset.Colors.accent) + case .poll, .severedRelationships, .moderationWarning, .adminReport, + .adminSignUp: + return .secondary + case ._other: + return .gray + } + } + + func actionSummaryLabel(_ sourceAccounts: NotificationSourceAccounts) + -> AttributedString? + { + guard let authorName = sourceAccounts.authorName else { return nil } + let totalAuthorCount = sourceAccounts.totalActorCount + // TODO: L10n strings + switch authorName { + case .me: + assert(totalAuthorCount == 1) + //assert(self == .poll) + return "Your poll has ended" + case .other(let firstAuthorName): + let nameComponent = boldedNameStringComponent(firstAuthorName) + var composedString: AttributedString + if totalAuthorCount == 1 { + switch self { + case .favourite: + composedString = + nameComponent + AttributedString(" favorited:") + case .follow: + composedString = + nameComponent + AttributedString(" followed you") + case .followRequest: + composedString = + nameComponent + + AttributedString(" requested to follow you") + case .reblog: + composedString = + nameComponent + AttributedString(" boosted:") + case .mention: + composedString = + nameComponent + AttributedString(" mentioned you:") + case .poll: + composedString = + nameComponent + + AttributedString(" ran a poll that you voted in") // TODO: add count of how many others voted + case .status: + composedString = + nameComponent + AttributedString(" posted:") + case .adminSignUp: + composedString = + nameComponent + AttributedString(" signed up") + default: + composedString = + nameComponent + AttributedString("did something?") + } + } else { + switch self { + case .favourite: + composedString = + nameComponent + + AttributedString( + " and \(totalAuthorCount - 1) others favorited:") + case .follow: + composedString = + nameComponent + + AttributedString( + " and \(totalAuthorCount - 1) others followed you") + case .reblog: + composedString = + nameComponent + + AttributedString( + " and \(totalAuthorCount - 1) others boosted:") + default: + composedString = + nameComponent + + AttributedString( + " and \(totalAuthorCount - 1) others did something") + } + } + let nameStyling = AttributeContainer.font( + .system(.body, weight: .bold)) + let nameContainer = AttributeContainer.personNameComponent( + .givenName) + composedString.replaceAttributes(nameContainer, with: nameStyling) + return composedString + } + } +} + extension Mastodon.Entity.Report { // TODO: localization (inc. plurals) // "Someone reported X posts from someone else for rule violation" @@ -199,16 +347,16 @@ extension Mastodon.Entity.Report { var listFormatter = ListFormatter() extension Mastodon.Entity.RelationshipSeveranceEvent { - // TODO: details and localization - // Ideal example: "An admin from a.b has blocked c.d, including x of your followers and y accounts you follow." - // For now: "An admin action has blocked x of your followers and y accounts that you follow" - var summary: AttributedString? { - let baseString = "Your admins have blocked " + // "An admin from has blocked , including x of your followers and y accounts you follow." + + func summary(myDomain: String) -> AttributedString? { let lostFollowersString = - followersCount > 0 ? "\(followersCount) of your followers" : nil + followersCount > 0 + ? L10n.Plural.Count.ofYourFollowers(followersCount) : nil let lostFollowingString = followingCount > 0 - ? "\(followingCount) accounts that you follow" : nil + ? L10n.Plural.Count.accountsThatYouFollow(followingCount) : nil + guard let followersAndFollowingString = listFormatter.string( from: [lostFollowersString, lostFollowingString].compactMap { @@ -217,7 +365,14 @@ extension Mastodon.Entity.RelationshipSeveranceEvent { else { return nil } - return AttributedString(baseString + followersAndFollowingString + ".") + + let string = L10n.Scene.Notification.NotificationDescription + .relationshipSeveranceEvent( + myDomain, targetName, followersAndFollowingString) + + var attributed = AttributedString(string) + attributed.bold([myDomain, targetName]) + return attributed } } @@ -251,7 +406,7 @@ func NotificationIconView(systemName: String) -> some View { enum RelationshipElement: Equatable { case noneNeeded - case unfetched(Mastodon.Entity.NotificationType, accountID: String) + case unfetched(GroupedNotificationType) case fetching case error(Error?) case iDoNotFollowThem(theirAccountIsLocked: Bool) @@ -259,7 +414,7 @@ enum RelationshipElement: Equatable { case iHaveRequestedToFollowThem case theyHaveRequestedToFollowMe(iFollowThem: Bool) case iHaveAnsweredTheirRequestToFollowMe(didAccept: Bool) - + enum FollowAction { case follow case unfollow @@ -309,7 +464,7 @@ enum RelationshipElement: Equatable { { return lhs.description == rhs.description } - + var followAction: FollowAction { switch self { case .iDoNotFollowThem: @@ -369,13 +524,14 @@ extension Mastodon.Entity.Relationship { } struct NotificationIconInfo { - let notificationType: Mastodon.Entity.NotificationType + let notificationType: GroupedNotificationType let isGrouped: Bool let visibility: Mastodon.Entity.Status.Visibility? } struct NotificationSourceAccounts { let primaryAuthorAccount: Mastodon.Entity.Account? + let authorName: AuthorName? var firstAccountID: String? { return primaryAuthorAccount?.id } @@ -383,13 +539,24 @@ struct NotificationSourceAccounts { let totalActorCount: Int init( + myAccountID: String, primaryAuthorAccount: Mastodon.Entity.Account?, avatarUrls: [URL], totalActorCount: Int ) { self.primaryAuthorAccount = primaryAuthorAccount self.avatarUrls = avatarUrls.removingDuplicates() self.totalActorCount = totalActorCount + let authorName: AuthorName? + if primaryAuthorAccount?.id == myAccountID { + authorName = .me + } else if let name = primaryAuthorAccount?.displayNameWithFallback { + authorName = .other(named: name) + } else { + authorName = nil + } + self.authorName = authorName } + } struct FilteredNotificationsRowView: View { @@ -533,7 +700,9 @@ struct NotificationRowView: View { } case .hyperlinkButton(let label, let url): Button(label) { - // TODO: open url + if let url { + UIApplication.shared.open(url) + } } .bold() .tint(Color(asset: Asset.Colors.accent)) @@ -601,7 +770,8 @@ struct NotificationRowView: View { switch (elementType, grouped) { case (.fetching, false): ProgressView().progressViewStyle(.circular) - case (.iDoNotFollowThem, false), (.iFollowThem, false), (.iHaveRequestedToFollowThem, false): + case (.iDoNotFollowThem, false), (.iFollowThem, false), + (.iHaveRequestedToFollowThem, false): if let buttonText = elementType.buttonText { Button(buttonText) { viewModel.doAvatarRowButtonAction() @@ -612,11 +782,12 @@ struct NotificationRowView: View { HStack { if iFollowThem { - Button(L10n.Common.Controls.Friendship.following) - { + Button(L10n.Common.Controls.Friendship.following) { // TODO: allow unfollow here? } - .buttonStyle(FollowButton(.iFollowThem(theyFollowMe: false))) + .buttonStyle( + FollowButton(.iFollowThem(theyFollowMe: false)) + ) .fixedSize() } @@ -625,14 +796,19 @@ struct NotificationRowView: View { }) { lightwieghtImageView("xmark.circle", size: smallAvatarSize) } - .buttonStyle(ImageButton(foregroundColor: .secondary, backgroundColor: .clear)) + .buttonStyle( + ImageButton( + foregroundColor: .secondary, backgroundColor: .clear)) Button(action: { viewModel.doAvatarRowButtonAction(true) }) { - lightwieghtImageView("checkmark.circle", size: smallAvatarSize) + lightwieghtImageView( + "checkmark.circle", size: smallAvatarSize) } - .buttonStyle(ImageButton(foregroundColor: .secondary, backgroundColor: .clear)) + .buttonStyle( + ImageButton( + foregroundColor: .secondary, backgroundColor: .clear)) } case (.iHaveAnsweredTheirRequestToFollowMe(let didAccept), false): if didAccept { @@ -641,7 +817,8 @@ struct NotificationRowView: View { lightwieghtImageView("xmark", size: smallAvatarSize) } case (.error(_), _): - lightwieghtImageView("exclamationmark.triangle", size: smallAvatarSize) + lightwieghtImageView( + "exclamationmark.triangle", size: smallAvatarSize) default: Spacer().frame(width: 0) } @@ -890,11 +1067,11 @@ extension Mastodon.Entity.Status { struct FollowButton: ButtonStyle { private let followAction: RelationshipElement.FollowAction - + init(_ relationshipElement: RelationshipElement) { followAction = relationshipElement.followAction } - + func makeBody(configuration: Configuration) -> some View { configuration.label .padding([.horizontal], 12) @@ -905,7 +1082,7 @@ struct FollowButton: ButtonStyle { .fontWeight(fontWeight) .clipShape(Capsule()) } - + private var backgroundColor: Color { switch followAction { case .follow: @@ -917,7 +1094,7 @@ struct FollowButton: ButtonStyle { return .clear } } - + private var textColor: Color { switch followAction { case .follow: @@ -929,7 +1106,7 @@ struct FollowButton: ButtonStyle { return .clear } } - + private var fontWeight: SwiftUICore.Font.Weight { switch followAction { case .follow: @@ -944,10 +1121,10 @@ struct FollowButton: ButtonStyle { } struct ImageButton: ButtonStyle { - + let foregroundColor: Color let backgroundColor: Color - + func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(foregroundColor) @@ -956,10 +1133,23 @@ struct ImageButton: ButtonStyle { } } -@ViewBuilder func lightwieghtImageView(_ systemName: String, size: CGFloat) -> some View { +@ViewBuilder func lightwieghtImageView(_ systemName: String, size: CGFloat) + -> some View +{ Image(systemName: systemName) .resizable() .aspectRatio(contentMode: .fit) .fontWeight(.light) .frame(width: size, height: size) } + +extension AttributedString { + mutating func bold(_ substrings: [String]) { + let boldedRanges = substrings.map { + self.range(of: $0) + }.compactMap { $0 } + for range in boldedRanges { + self[range].font = .system(.body).bold() + } + } +} diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift index 4cbcd3b9f..f8b5bcd8b 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift @@ -3,13 +3,14 @@ import Combine import Foundation import MastodonCore +import MastodonLocalization import MastodonSDK class NotificationRowViewModel: ObservableObject { let identifier: MastodonFeedItemIdentifier let oldestID: String? let newestID: String? - let type: Mastodon.Entity.NotificationType + let type: GroupedNotificationType let navigateToScene: (SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void let presentError: (Error) -> Void @@ -37,6 +38,7 @@ class NotificationRowViewModel: ObservableObject { init( _ notificationInfo: GroupedNotificationInfo, + myAccountDomain: String, navigateToScene: @escaping ( SceneCoordinator.Scene, SceneCoordinator.Transition ) -> Void, presentError: @escaping (Error) -> Void @@ -45,83 +47,70 @@ class NotificationRowViewModel: ObservableObject { self.identifier = .notificationGroup(id: notificationInfo.id) self.oldestID = notificationInfo.oldestNotificationID self.newestID = notificationInfo.newestNotificationID - self.type = notificationInfo.type + self.type = notificationInfo.groupedNotificationType self.iconInfo = NotificationIconInfo( - notificationType: notificationInfo.type, - isGrouped: notificationInfo.isGrouped, + notificationType: notificationInfo.groupedNotificationType, + isGrouped: notificationInfo.sourceAccounts.totalActorCount > 1, visibility: notificationInfo.statusViewModel?.visibility) self.navigateToScene = navigateToScene self.presentError = presentError self.defaultNavigation = notificationInfo.defaultNavigation - switch notificationInfo.type { + switch notificationInfo.groupedNotificationType { case .follow, .followRequest: let avatarRowAdditionalElement: RelationshipElement - if let account = notificationInfo.primaryAuthorAccount { + if let account = notificationInfo.sourceAccounts + .primaryAuthorAccount + { avatarRowAdditionalElement = .unfetched( - notificationInfo.type, accountID: account.id) + notificationInfo.groupedNotificationType) } else { avatarRowAdditionalElement = .error(nil) } avatarRow = .avatarRow( - NotificationSourceAccounts( - primaryAuthorAccount: notificationInfo.primaryAuthorAccount, - avatarUrls: notificationInfo.authorAvatarUrls, - totalActorCount: notificationInfo.authorsCount), + notificationInfo.sourceAccounts, avatarRowAdditionalElement) - if let accountName = notificationInfo.primaryAuthorAccount? + if let accountName = notificationInfo.sourceAccounts + .primaryAuthorAccount? .displayNameWithFallback { headerTextComponents = [ .text( - notificationInfo.type.actionSummaryLabel( - firstAuthor: .other(named: accountName), - totalAuthorCount: notificationInfo.authorsCount)) + notificationInfo.groupedNotificationType + .actionSummaryLabel(notificationInfo.sourceAccounts) + ?? "") ] } case .mention, .status: // TODO: eventually make this full status style, not inline // TODO: distinguish mentions from replies - if let primaryAuthorAccount = notificationInfo.primaryAuthorAccount, - let statusViewModel = - notificationInfo.statusViewModel + if let statusViewModel = + notificationInfo.statusViewModel { avatarRow = .avatarRow( - NotificationSourceAccounts( - primaryAuthorAccount: primaryAuthorAccount, - avatarUrls: notificationInfo.authorAvatarUrls, - totalActorCount: notificationInfo.authorsCount), + notificationInfo.sourceAccounts, .noneNeeded) headerTextComponents = [ .text( - notificationInfo.type.actionSummaryLabel( - firstAuthor: .other( - named: primaryAuthorAccount - .displayNameWithFallback), - totalAuthorCount: notificationInfo.authorsCount)) + notificationInfo.groupedNotificationType + .actionSummaryLabel(notificationInfo.sourceAccounts) + ?? "") ] contentComponents = [.status(statusViewModel)] } else { headerTextComponents = [._other("POST BY UNKNOWN ACCOUNT")] } case .reblog, .favourite: - if let primaryAuthorAccount = notificationInfo.primaryAuthorAccount, - let statusViewModel = notificationInfo.statusViewModel - { + if let statusViewModel = notificationInfo.statusViewModel { avatarRow = .avatarRow( - NotificationSourceAccounts( - primaryAuthorAccount: primaryAuthorAccount, - avatarUrls: notificationInfo.authorAvatarUrls, - totalActorCount: notificationInfo.authorsCount), + notificationInfo.sourceAccounts, .noneNeeded) headerTextComponents = [ .text( - notificationInfo.type.actionSummaryLabel( - firstAuthor: .other( - named: primaryAuthorAccount - .displayNameWithFallback), - totalAuthorCount: notificationInfo.authorsCount)) + notificationInfo.groupedNotificationType + .actionSummaryLabel(notificationInfo.sourceAccounts) + ?? "") ] contentComponents = [.status(statusViewModel)] } else { @@ -130,15 +119,14 @@ class NotificationRowViewModel: ObservableObject { ] } case .poll, .update: - if let author = notificationInfo.authorName, - let statusViewModel = - notificationInfo.statusViewModel + if let statusViewModel = + notificationInfo.statusViewModel { headerTextComponents = [ .text( - notificationInfo.type.actionSummaryLabel( - firstAuthor: author, - totalAuthorCount: notificationInfo.authorsCount)) + notificationInfo.groupedNotificationType + .actionSummaryLabel(notificationInfo.sourceAccounts) + ?? "") ] contentComponents = [.status(statusViewModel)] } else { @@ -147,36 +135,25 @@ class NotificationRowViewModel: ObservableObject { ] } case .adminSignUp: - if let primaryAuthorAccount = notificationInfo.primaryAuthorAccount, - let authorName = notificationInfo.authorName - { - avatarRow = .avatarRow( - NotificationSourceAccounts( - primaryAuthorAccount: primaryAuthorAccount, - avatarUrls: notificationInfo.authorAvatarUrls, - totalActorCount: notificationInfo.authorsCount), - .noneNeeded) - headerTextComponents = [ - .text( - notificationInfo.type.actionSummaryLabel( - firstAuthor: authorName, - totalAuthorCount: notificationInfo.authorsCount)) - ] - } else { - headerTextComponents = [._other("ADMIN_SIGNUP NOTIFICATION")] - } - case .adminReport: - if let summary = notificationInfo.ruleViolationReport?.summary { + avatarRow = .avatarRow( + notificationInfo.sourceAccounts, + .noneNeeded) + headerTextComponents = [ + .text( + notificationInfo.groupedNotificationType.actionSummaryLabel( + notificationInfo.sourceAccounts) ?? "") + ] + case .adminReport(let report): + if let summary = report?.summary { headerTextComponents = [.text(summary)] } - if let comment = notificationInfo.ruleViolationReport? + if let comment = report? .displayableComment { contentComponents = [.text(comment)] } - case .severedRelationships: - if let summary = notificationInfo.relationshipSeveranceEvent? - .summary + case .severedRelationships(let severanceEvent): + if let summary = severanceEvent?.summary(myDomain: myAccountDomain) { headerTextComponents = [.text(summary)] } else { @@ -187,15 +164,36 @@ class NotificationRowViewModel: ObservableObject { ] } contentComponents = [ - .hyperlinkButton("Learn more about server blocks", nil) - ] // TODO: localization and go somewhere - case .moderationWarning: + .hyperlinkButton( + L10n.Scene.Notification.learnMoreAboutServerBlocks, + notificationInfo.groupedNotificationType.learnMoreUrl( + forDomain: myAccountDomain, + notificationID: notificationInfo.newestNotificationID)) + ] + case .moderationWarning(let accountWarning): headerTextComponents = [ - .text( - AttributedString( - "Your account has received a moderation warning.")) - ] // TODO: localization - contentComponents = [.hyperlinkButton("Learn more", nil)] // TODO: localization and go somewhere + .weightedText( + (accountWarning?.action ?? .none).actionDescription, + .regular) + ] + + let learnMoreButton = NotificationViewComponent.hyperlinkButton( + L10n.Scene.Notification.Warning.learnMore, + notificationInfo.groupedNotificationType.learnMoreUrl( + forDomain: myAccountDomain, + notificationID: accountWarning?.id ?? notificationInfo.newestNotificationID)) + + if let accountWarningText = accountWarning?.text { + contentComponents = [ + .weightedText(accountWarningText, .regular), + learnMoreButton, + ] + } else { + contentComponents = [ + learnMoreButton + ] + } + case ._other(let text): headerTextComponents = [ ._other("UNEXPECTED NOTIFICATION TYPE: \(text)") @@ -226,7 +224,10 @@ class NotificationRowViewModel: ObservableObject { ) { switch type { case .follow, .followRequest: - guard let accountID = sourceAccounts.firstAccountID, let accountIsLocked = sourceAccounts.primaryAuthorAccount?.locked else { return } + guard let accountID = sourceAccounts.firstAccountID, + let accountIsLocked = sourceAccounts.primaryAuthorAccount? + .locked + else { return } avatarRow = .avatarRow(sourceAccounts, .fetching) Task { @MainActor in @@ -240,9 +241,11 @@ class NotificationRowViewModel: ObservableObject { case (.follow, true): element = .iFollowThem(theyFollowMe: true) case (.follow, false): - element = .iDoNotFollowThem(theirAccountIsLocked: accountIsLocked) + element = .iDoNotFollowThem( + theirAccountIsLocked: accountIsLocked) case (.followRequest, _): - element = .theyHaveRequestedToFollowMe(iFollowThem: relationship.following) + element = .theyHaveRequestedToFollowMe( + iFollowThem: relationship.following) default: element = .noneNeeded } @@ -311,8 +314,11 @@ extension NotificationRowViewModel { switch avatarRow { case .avatarRow(let accountInfo, let relationshipElement): switch relationshipElement { - case .iDoNotFollowThem, .iFollowThem, .iHaveRequestedToFollowThem: - await doFollowAction(relationshipElement.followAction, notificationSourceAccounts: accountInfo) + case .iDoNotFollowThem, .iFollowThem, + .iHaveRequestedToFollowThem: + await doFollowAction( + relationshipElement.followAction, + notificationSourceAccounts: accountInfo) case .theyHaveRequestedToFollowMe: await doAnswerFollowRequest(accountInfo, accept: accept) default: @@ -325,8 +331,13 @@ extension NotificationRowViewModel { } @MainActor - private func doFollowAction(_ action: RelationshipElement.FollowAction, notificationSourceAccounts: NotificationSourceAccounts) async { - guard let accountID = notificationSourceAccounts.firstAccountID, let theirAccountIsLocked = notificationSourceAccounts.primaryAuthorAccount?.locked, + private func doFollowAction( + _ action: RelationshipElement.FollowAction, + notificationSourceAccounts: NotificationSourceAccounts + ) async { + guard let accountID = notificationSourceAccounts.firstAccountID, + let theirAccountIsLocked = notificationSourceAccounts + .primaryAuthorAccount?.locked, let authBox = AuthenticationServiceProvider.shared.currentActiveUser .value else { return } @@ -340,16 +351,20 @@ extension NotificationRowViewModel { response = try await APIService.shared.follow( accountID, authenticationBox: authBox) case .unfollow: - response = try await APIService.shared.unfollow(accountID, authenticationBox: authBox) + response = try await APIService.shared.unfollow( + accountID, authenticationBox: authBox) case .noAction: - throw AppError.unexpected("action attempted for relationship element that has no action") + throw AppError.unexpected( + "action attempted for relationship element that has no action" + ) } if response.following { updatedElement = .iFollowThem(theyFollowMe: response.followedBy) } else if response.requested { updatedElement = .iHaveRequestedToFollowThem } else { - updatedElement = .iDoNotFollowThem(theirAccountIsLocked: theirAccountIsLocked) + updatedElement = .iDoNotFollowThem( + theirAccountIsLocked: theirAccountIsLocked) } avatarRow = .avatarRow(notificationSourceAccounts, updatedElement) } catch { @@ -380,7 +395,8 @@ extension NotificationRowViewModel { return } self.avatarRow = .avatarRow( - accountInfo, .iHaveAnsweredTheirRequestToFollowMe(didAccept: accept)) + accountInfo, + .iHaveAnsweredTheirRequestToFollowMe(didAccept: accept)) } catch { presentError(error) self.avatarRow = startingAvatarRow @@ -428,27 +444,22 @@ extension NotificationRowViewModel { ?? partialAccounts?[accountID]?.avatarURL } - let authorName: Mastodon.Entity.NotificationType.AuthorName? - if primaryAccount?.id == myAccountID { - authorName = .me - } else if let name = primaryAccount?.displayNameWithFallback { - authorName = .other(named: name) - } else { - authorName = nil - } + let sourceAccounts = NotificationSourceAccounts( + myAccountID: myAccountID, primaryAuthorAccount: primaryAccount, + avatarUrls: avatarUrls, + totalActorCount: group.notificationsCount) let status = group.statusID == nil ? nil : statuses[group.statusID!] + let type = GroupedNotificationType( + group, sourceAccounts: sourceAccounts, status: status) + let info = GroupedNotificationInfo( id: group.id, - oldestNotificationID: group.oldestNotificationID, - newestNotificationID: group.newestNotificationID, - type: group.type, - authorsCount: group.authorsCount, - notificationsCount: group.notificationsCount, - primaryAuthorAccount: primaryAccount, - authorName: authorName, - authorAvatarUrls: avatarUrls, + oldestNotificationID: group.pageNewestID ?? "", + newestNotificationID: group.pageOldestID ?? "", + groupedNotificationType: type, + sourceAccounts: sourceAccounts, statusViewModel: status?.viewModel( myDomain: myAccountDomain, navigateToStatus: { @@ -470,12 +481,10 @@ extension NotificationRowViewModel { false))))), .show) } }), - ruleViolationReport: group.ruleViolationReport, - relationshipSeveranceEvent: group.relationshipSeveranceEvent, defaultNavigation: { guard let navigation = defaultNavigation( - group.type, isGrouped: group.isGrouped, + group.type, isGrouped: group.notificationsCount > 1, primaryAccount: primaryAccount) else { return } Task { @@ -487,7 +496,8 @@ extension NotificationRowViewModel { ) return NotificationRowViewModel( - info, navigateToScene: navigateToScene, + info, myAccountDomain: myAccountDomain, + navigateToScene: navigateToScene, presentError: presentError) } } @@ -502,40 +512,42 @@ extension NotificationRowViewModel { ) -> [NotificationRowViewModel] { return notifications.map { notification in + let sourceAccounts = NotificationSourceAccounts( + myAccountID: myAccountID, + primaryAuthorAccount: notification.account, + avatarUrls: notification.authorAvatarUrls, totalActorCount: 1) + + let statusViewModel = notification.status?.viewModel( + myDomain: myAccountDomain, + navigateToStatus: { + Task { + guard + let authBox = + await AuthenticationServiceProvider.shared + .currentActiveUser.value, + let status = notification.status + else { return } + await navigateToScene( + .thread( + viewModel: ThreadViewModel( + authenticationBox: authBox, + optionalRoot: .root( + context: .init( + status: MastodonStatus( + entity: status, + showDespiteContentWarning: + false))))), .show) + } + }) + let info = GroupedNotificationInfo( id: notification.id, oldestNotificationID: notification.id, newestNotificationID: notification.id, - type: notification.type, - authorsCount: notification.authorsCount, - notificationsCount: 1, - primaryAuthorAccount: notification.account, - authorName: notification.authorName, - authorAvatarUrls: notification.authorAvatarUrls, - statusViewModel: notification.status?.viewModel( - myDomain: myAccountDomain, - navigateToStatus: { - Task { - guard - let authBox = - await AuthenticationServiceProvider.shared - .currentActiveUser.value, - let status = notification.status - else { return } - await navigateToScene( - .thread( - viewModel: ThreadViewModel( - authenticationBox: authBox, - optionalRoot: .root( - context: .init( - status: MastodonStatus( - entity: status, - showDespiteContentWarning: - false))))), .show) - } - }), - ruleViolationReport: notification.ruleViolationReport, - relationshipSeveranceEvent: notification.relationshipSeveranceEvent, + groupedNotificationType: GroupedNotificationType( + notification, sourceAccounts: sourceAccounts), + sourceAccounts: sourceAccounts, + statusViewModel: statusViewModel, defaultNavigation: { guard let navigation = defaultNavigation( @@ -551,7 +563,8 @@ extension NotificationRowViewModel { ) return NotificationRowViewModel( - info, navigateToScene: navigateToScene, + info, myAccountDomain: myAccountDomain, + navigateToScene: navigateToScene, presentError: presentError) } } @@ -615,3 +628,144 @@ extension NotificationRowViewModel { return nil } } + +extension GroupedNotificationType { + init( + _ notification: Mastodon.Entity.Notification, + sourceAccounts: NotificationSourceAccounts + ) { + switch notification.typeFromServer { + case .follow: + self = .follow(from: sourceAccounts) + case .followRequest: + if let account = sourceAccounts.primaryAuthorAccount { + self = .followRequest(from: account) + } else { + self = ._other("Follow request from unknown account") + } + case .mention: + self = .mention(notification.status) + case .reblog: + self = .mention(notification.status) + case .favourite: + self = .favourite(notification.status) + case .poll: + self = .poll(notification.status) + case .status: + self = .status(notification.status) + case .update: + self = .update(notification.status) + case .adminSignUp: + self = .adminSignUp + case .adminReport: + self = .adminReport(notification.ruleViolationReport) + case .severedRelationships: + self = .severedRelationships( + notification.relationshipSeveranceEvent) + case .moderationWarning: + self = .moderationWarning(notification.accountWarning) + case ._other(let string): + self = ._other(string) + } + } + + init( + _ notificationGroup: Mastodon.Entity.NotificationGroup, + sourceAccounts: NotificationSourceAccounts, + status: Mastodon.Entity.Status? + ) { + switch notificationGroup.type { + case .follow: + self = .follow(from: sourceAccounts) + case .followRequest: + if let account = sourceAccounts.primaryAuthorAccount { + self = .followRequest(from: account) + } else { + self = ._other("Follow request from unknown account") + } + case .mention: + self = .mention(status) + case .reblog: + self = .mention(status) + case .favourite: + self = .favourite(status) + case .poll: + self = .poll(status) + case .status: + self = .status(status) + case .update: + self = .update(status) + case .adminSignUp: + self = .adminSignUp + case .adminReport: + self = .adminReport(notificationGroup.ruleViolationReport) + case .severedRelationships: + self = .severedRelationships( + notificationGroup.relationshipSeveranceEvent) + case .moderationWarning: + self = .moderationWarning(notificationGroup.accountWarning) + case ._other(let string): + self = ._other(string) + } + } +} + +extension NotificationSourceAccounts { + var authorsDescription: String? { + switch authorName { + case .me, .none: + return nil + case .other(let name): + if totalActorCount > 1 { + return "\(name) and \(totalActorCount - 1) others" + } else { + return name + } + } + } +} + +extension GroupedNotificationType { + func learnMoreUrl(forDomain domain: String, notificationID: String) -> URL? + { + let trailingPathComponents: [String] + switch self { + case .severedRelationships: + trailingPathComponents = ["severed_relationships"] + case .moderationWarning: + trailingPathComponents = [ + "disputes", + "strikes", + notificationID, + ] + default: + return nil + } + var url = URL(string: "https://" + domain) + for component in trailingPathComponents { + url?.append(component: component) + } + return url + } +} + +extension Mastodon.Entity.AccountWarning.Action { + var actionDescription: String { + switch self { + case .none: + return L10n.Scene.Notification.Warning.none + case .disable: + return L10n.Scene.Notification.Warning.disable + case .markStatusesAsSensitive: + return L10n.Scene.Notification.Warning.markStatusesAsSensitive + case .deleteStatuses: + return L10n.Scene.Notification.Warning.deleteStatuses + case .sensitive: + return L10n.Scene.Notification.Warning.sensitive + case .silence: + return L10n.Scene.Notification.Warning.silence + case .suspend: + return L10n.Scene.Notification.Warning.suspend + } + } +} diff --git a/Mastodon/In Progress New Layout and Datamodel/TimelinePostCell/TimelinePostCell.swift b/Mastodon/In Progress New Layout and Datamodel/TimelinePostCell/TimelinePostCell.swift index d5435f0e7..8cb773dd3 100644 --- a/Mastodon/In Progress New Layout and Datamodel/TimelinePostCell/TimelinePostCell.swift +++ b/Mastodon/In Progress New Layout and Datamodel/TimelinePostCell/TimelinePostCell.swift @@ -24,8 +24,8 @@ class TimelinePostViewModel { init(feedItemIdentifier: MastodonFeedItemIdentifier, includePadding: Bool) { self.includePadding = includePadding self.feedItemIdentifier = feedItemIdentifier - if let notification = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? NotificationInfo { - boostingAccountName = notification.type == .reblog ? notification.authorName?.string : nil + if let notification = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? Mastodon.Entity.Notification { + boostingAccountName = notification.type == .reblog ? notification.primaryAuthorAccount?.displayNameWithFallback : nil } else { boostingAccountName = nil }