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

Improved VoiceOver experience for Grouped Notifications screen

Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification
Contributes to IOS-380
This commit is contained in:
shannon 2025-03-13 10:11:27 -04:00
parent 7b348c0d5b
commit b6b486533b
6 changed files with 158 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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