diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift index b6b7b2d0d..6fddcd559 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationInfo.swift @@ -60,12 +60,12 @@ struct GroupedNotificationInfo { var relationshipElement: RelationshipElement { switch groupedNotificationType { case .follow(let accountsInfo): - if let primaryAuthorAccount = accountsInfo.primaryAuthorAccount { + if accountsInfo.primaryAuthorAccount != nil { return .unfetched(groupedNotificationType) } else { return .error(nil) } - case .followRequest(let account): + case .followRequest: if sourceAccounts.totalActorCount == 1 { return .unfetched(groupedNotificationType) } else { @@ -78,7 +78,7 @@ struct GroupedNotificationInfo { let statusViewModel: Mastodon.Entity.Status.ViewModel? - let defaultNavigation: (() -> Void)? + let primaryNavigation: NotificationRowViewModel.NotificationNavigation? } extension Mastodon.Entity.Notification: NotificationInfo { diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift index 06ff39022..c96f8cca9 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationListViewController.swift @@ -238,7 +238,7 @@ struct NotificationListView: View { case .notification: return case .groupedNotification(let notificationViewModel): - notificationViewModel.defaultNavigation?() + notificationViewModel.doPrimaryNavigation() default: return } @@ -442,3 +442,14 @@ private class NotificationListViewModel: ObservableObject { await feedLoader.commitToCache() } } + +extension NotificationRowViewModel.NotificationNavigation { + var a11yTitle: String? { + switch self { + case .myFollowers: + return L10n.Scene.Profile.Dashboard.myFollowers // TODO: improve string + case .profile(let account): + return L10n.Common.Controls.Status.MetaEntity.mention(account.displayNameWithFallback) + } + } +} diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift index 6858e21ba..2dcb1606a 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift @@ -15,7 +15,7 @@ enum AuthorName { var plainString: String { switch self { case .me: - return "You" + return "You" // TODO: localize (for voice over users) case .other(let name, _): return name } @@ -394,6 +394,27 @@ enum RelationshipElement: Equatable { return nil } } + + func a11yActionTitle(forAccept accept: Bool = true) -> String? { + switch self { + case .iFollowThem, .iHaveRequestedToFollowThem: + return L10n.Common.Alerts.UnfollowUser.unfollow + case .theyHaveRequestedToFollowMe: + if accept { + return L10n.Scene.Notification.FollowRequest.accept + } else { + return L10n.Scene.Notification.FollowRequest.reject + } + case .iHaveAnsweredTheirRequestToFollowMe(let accepted): + if accepted { + return L10n.Scene.Notification.FollowRequest.accepted + } else { + return L10n.Scene.Notification.FollowRequest.rejected + } + default: + return buttonText + } + } } extension Mastodon.Entity.Relationship { @@ -425,11 +446,15 @@ extension Mastodon.Entity.Relationship { struct NotificationSourceAccounts { let accounts: [AccountInfo] let totalActorCount: Int - let authorName: AuthorName? + let myAccountID: String var primaryAuthorAccount: Mastodon.Entity.Account? { return accounts.first?.fullAccount } + var authorName: AuthorName? { + guard let firstAuthor = accounts.first else { return nil } + return firstAuthor.displayName(whenViewedBy: myAccountID) + } var firstAccountID: String? { return primaryAuthorAccount?.id } @@ -444,7 +469,11 @@ struct NotificationSourceAccounts { ) { self.accounts = accounts self.totalActorCount = totalActorCount - self.authorName = accounts.first?.displayName(whenViewedBy: myAccountID) + self.myAccountID = myAccountID + } + + func displayName(forAccount account: AccountInfo) -> String { + return account.displayName(whenViewedBy: myAccountID)?.plainString ?? L10n.Plural.Count.others(1) } } @@ -597,6 +626,14 @@ struct NotificationRowView: View { } } .fixedSize(horizontal: false, vertical: true) + .accessibilityActions { + ForEach(viewModel.a11yActions) { a11y in + Button(a11y.title) { + a11y.doAction() + } + } + } + .accessibilityElement(children: .combine) } @ViewBuilder @@ -613,6 +650,7 @@ struct NotificationRowView: View { .frame(height: actionSuperheaderHeight) .fixedSize(horizontal: true, vertical: false) .foregroundColor(.secondary) + .accessibilityLabel(date.localizedAbbreviatedSlowedTimeAgoSinceNow) case .weightedText(let string, let weight): textComponent(string, fontWeight: weight) case .status(let statusViewModel): @@ -639,6 +677,7 @@ struct NotificationRowView: View { .frame(height: actionSuperheaderHeight) .fixedSize(horizontal: true, vertical: false) .foregroundColor(.secondary) + .accessibilityLabel(date.localizedAbbreviatedSlowedTimeAgoSinceNow) } } } @@ -693,6 +732,7 @@ struct NotificationRowView: View { Spacer().frame(minWidth: 0, maxWidth: .infinity) avatarRowTrailingElement( trailingElement, grouped: accountInfo.totalActorCount > 1) + .accessibilityHidden(true) } } .frame(height: smallAvatarSize) // this keeps GeometryReader from causing inconsistent visual spacing in the VStack @@ -724,6 +764,7 @@ struct NotificationRowView: View { FollowButton(.iFollowThem(theyFollowMe: false)) ) .fixedSize() + .accessibilityLabel(L10n.Common.Controls.Friendship.following) } Button(action: { diff --git a/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift b/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift index 8e231c6f3..f1747333d 100644 --- a/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift +++ b/Mastodon/In Progress New Layout and Datamodel/NotificationRowViewModel.swift @@ -17,9 +17,9 @@ class NotificationRowViewModel: ObservableObject { let type: GroupedNotificationType let author: AccountInfo? let navigateToScene: - (SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void + (SceneCoordinator.Scene, SceneCoordinator.Transition) -> Void let presentError: (Error) -> Void - let defaultNavigation: (() -> Void)? + let primaryNavigation: NotificationNavigation? let iconStyle: GroupedNotificationType.MainIconStyle? let usePrivateBackground: Bool let actionSuperheader: (iconName: String?, text: String, color: Color)? @@ -63,7 +63,7 @@ class NotificationRowViewModel: ObservableObject { self.iconStyle = notificationInfo.groupedNotificationType.mainIconStyle self.navigateToScene = navigateToScene self.presentError = presentError - self.defaultNavigation = notificationInfo.defaultNavigation + self.primaryNavigation = notificationInfo.primaryNavigation var needsPrivateBackground = false @@ -337,8 +337,8 @@ class NotificationRowViewModel: ObservableObject { switch type { case .follow, .followRequest: guard let accountID = sourceAccounts.firstAccountID, - let accountIsLocked = sourceAccounts.primaryAuthorAccount? - .locked + let accountIsLocked = sourceAccounts.primaryAuthorAccount? + .locked else { return } avatarRow = .avatarRow(sourceAccounts, .fetching) @@ -381,7 +381,7 @@ class NotificationRowViewModel: ObservableObject { } private func fetchRelationship(to accountID: String) async throws - -> Mastodon.Entity.Relationship? + -> Mastodon.Entity.Relationship? { guard let authBox = await AuthenticationServiceProvider.shared @@ -396,6 +396,16 @@ class NotificationRowViewModel: ObservableObject { } } +} + +struct A11yActionInfo: Identifiable { + let id = UUID() + let title: String + let doAction: ()->() +} + +extension NotificationRowViewModel { + func navigateToProfile(_ info: AccountInfo) async throws { guard let me = await AuthenticationServiceProvider.shared @@ -417,6 +427,64 @@ class NotificationRowViewModel: ObservableObject { relationship: relationship)), .show) } } + + func doPrimaryNavigation() { + guard let primaryNavigation else { return } + Task { + guard let scene = await primaryNavigation.destinationScene() + else { return } + navigateToScene(scene, .show) + } + } + + public var a11yActions: [A11yActionInfo] { + var actions = [A11yActionInfo]() + if let primaryNavigationTitle = primaryNavigation?.a11yTitle { actions.append(A11yActionInfo(title: primaryNavigationTitle, doAction: { [weak self] in self?.doPrimaryNavigation() })) + } + for component in self.headerComponents + self.contentComponents { + actions.append(contentsOf: a11yActions(forComponent: component)) + } + return actions + } + + private func a11yActions(forComponent component: NotificationViewComponent?) -> [A11yActionInfo] { + switch component { + case .none: + return [] + case let .avatarRow(sourceAccounts, relationshipElement): + let relationshipActions = a11yActions(forRelationshipElement: relationshipElement, isGrouped: sourceAccounts.totalActorCount > 1) + let accountNavigations = sourceAccounts.accounts.compactMap { account in + A11yActionInfo(title: L10n.Common.Controls.Status.MetaEntity.mention(account.displayName(whenViewedBy: nil)?.plainString ?? ""), doAction: { + Task { [weak self] in + try await self?.navigateToProfile(account) + } + }) + } + return relationshipActions + accountNavigations + case let .status(statusViewModel): + return [A11yActionInfo(title: L10n.Common.Controls.Status.showPost, doAction: { statusViewModel.navigateToStatus() })] + case .hyperlinkButton(_, _): + return [] + case .text, .textAndTimeLabel, .timeSinceLabel, .weightedText, ._other: + return [] + } + } + + private func a11yActions(forRelationshipElement relationshipElement: RelationshipElement, isGrouped: Bool) -> [A11yActionInfo] { + + guard !isGrouped else { return [] } + + switch relationshipElement { + case .error, .fetching, .iHaveAnsweredTheirRequestToFollowMe, .noneNeeded, .unfetched(_): + return [] + case .iDoNotFollowThem, .iFollowThem, .iHaveRequestedToFollowThem: + return [ A11yActionInfo(title: relationshipElement.a11yActionTitle() ?? "", doAction: { [weak self] in self?.doAvatarRowButtonAction() }) ] + case .theyHaveRequestedToFollowMe: + return [true, false].map { option in + A11yActionInfo(title: relationshipElement.a11yActionTitle(forAccept: option) ?? "", doAction: { [weak self] in self?.doAvatarRowButtonAction(option) }) + } + } + } } extension NotificationRowViewModel: Equatable { @@ -596,18 +664,9 @@ extension NotificationRowViewModel { false))))), .show) } }), - defaultNavigation: { - guard - let navigation = defaultNavigation( - group.type, isGrouped: group.notificationsCount > 1, - primaryAccount: sourceAccounts.primaryAuthorAccount) - else { return } - Task { - guard let scene = await navigation.destinationScene() - else { return } - navigateToScene(scene, .show) - } - } + primaryNavigation: defaultNavigation( + group.type, isGrouped: group.notificationsCount > 1, + primaryAccount: sourceAccounts.primaryAuthorAccount) ) return NotificationRowViewModel( @@ -665,19 +724,9 @@ extension NotificationRowViewModel { notification, sourceAccounts: sourceAccounts), sourceAccounts: sourceAccounts, statusViewModel: statusViewModel, - defaultNavigation: { - guard - let navigation = defaultNavigation( - notification.type, isGrouped: false, - primaryAccount: notification.primaryAuthorAccount) - else { return } - Task { - guard let scene = await navigation.destinationScene() - else { return } - navigateToScene(scene, .show) - } - } - ) + primaryNavigation: defaultNavigation( + notification.type, isGrouped: false, + primaryAccount: notification.primaryAuthorAccount)) return NotificationRowViewModel( info, timestamper: timestamper, myAccountDomain: myAccountDomain, diff --git a/Mastodon/Scene/Notification/NotificationListItem.swift b/Mastodon/Scene/Notification/NotificationListItem.swift index 47472060e..c5cd3f536 100644 --- a/Mastodon/Scene/Notification/NotificationListItem.swift +++ b/Mastodon/Scene/Notification/NotificationListItem.swift @@ -8,6 +8,7 @@ import CoreData import Foundation import MastodonSDK +import MastodonLocalization enum NotificationListItem { case filteredNotificationsInfo( @@ -47,6 +48,19 @@ enum NotificationListItem { return false } } + + var primaryA11yActionTitle: String? { + switch self { + case .filteredNotificationsInfo: + return L10n.Scene.Notification.FilteredNotification.title // TODO: improve string + case .notification(let identifier): + return nil + case .groupedNotification(let viewModel): + return viewModel.primaryNavigation?.a11yTitle + case .bottomLoader: + return nil + } + } } extension NotificationListItem: Identifiable, Equatable, Hashable { diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift index 39e373820..ba5ee90ab 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -629,6 +629,7 @@ extension MastodonFollowRequestState.State { } protocol AccountInfo { + var handle: String { get } var avatarURL: URL? { get } var locked: Bool { get } var id: String { get } @@ -636,11 +637,11 @@ protocol AccountInfo { } extension AccountInfo { - func displayName(whenViewedBy myAccountID: String) -> AuthorName? { + func displayName(whenViewedBy myAccountID: String?) -> AuthorName? { if myAccountID == id { return .me } else { - guard let fullAccount else { return nil } + guard let fullAccount else { return .other(named: handle, emojis: [:]) } return .other(named: fullAccount.displayNameWithFallback, emojis: fullAccount.emojiMeta) } } @@ -649,9 +650,11 @@ extension AccountInfo { extension Mastodon.Entity.Account: AccountInfo { var avatarURL: URL? { avatarImageURL() } var fullAccount: Mastodon.Entity.Account? { return self } + var handle: String { return acct } } extension Mastodon.Entity.PartialAccountWithAvatar: AccountInfo { var avatarURL: URL? { URL(string: avatar) } var fullAccount: Mastodon.Entity.Account? { return nil } + var handle: String { return acct } }