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:
parent
7b348c0d5b
commit
b6b486533b
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user