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

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
This commit is contained in:
shannon 2025-02-13 18:14:37 -05:00
parent f2311cc0fd
commit 6a27b50728
4 changed files with 566 additions and 322 deletions

View File

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

View File

@ -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 <your.domain> has blocked <some other domain>, 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()
}
}
}

View File

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

View File

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