diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings
index 7d573d495..50db1f35b 100644
--- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings
+++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings
@@ -12,6 +12,11 @@
"a11y_edit_avatar" = "Edit avatar";
"a11y_edit_room_address_hint" = "The full address will be %1$@";
"a11y_expand_message_text_field" = "Expand message text field";
+"a11y_gallery_attachment" = "Gallery, attachment %1$d";
+"a11y_gallery_failed_scan" = "Gallery, failed scan %1$d";
+"a11y_gallery_image" = "Gallery, image %1$d";
+"a11y_gallery_scanning" = "Gallery, scanning %1$d";
+"a11y_gallery_video" = "Gallery, video %1$d";
"a11y_hide_password" = "Hide password";
"a11y_info" = "Info";
"a11y_join_call" = "Join call";
diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings
index c004691d4..b2e30a9ff 100644
--- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings
+++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings
@@ -12,6 +12,11 @@
"a11y_edit_avatar" = "Edit avatar";
"a11y_edit_room_address_hint" = "The full address will be %1$@";
"a11y_expand_message_text_field" = "Expand message text field";
+"a11y_gallery_attachment" = "Gallery, attachment %1$d";
+"a11y_gallery_failed_scan" = "Gallery, failed scan %1$d";
+"a11y_gallery_image" = "Gallery, image %1$d";
+"a11y_gallery_scanning" = "Gallery, scanning %1$d";
+"a11y_gallery_video" = "Gallery, video %1$d";
"a11y_hide_password" = "Hide password";
"a11y_info" = "Info";
"a11y_join_call" = "Join call";
diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict
index a9537fc8f..fb32c8c60 100644
--- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict
+++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict
@@ -18,6 +18,22 @@
NSStringLocalizedFormatKey
%#@COUNT@
+ a11y_gallery_more_media
+
+ COUNT
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ d
+ one
+ Gallery, %1$d more media
+ other
+ Gallery, %1$d more medias
+
+ NSStringLocalizedFormatKey
+ %#@COUNT@
+
a11y_polls_percent_of_total
COUNT
diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift
index d885fd7c7..569260f52 100644
--- a/ElementX/Sources/Generated/Strings.swift
+++ b/ElementX/Sources/Generated/Strings.swift
@@ -48,6 +48,30 @@ internal nonisolated enum L10n {
}
/// Expand message text field
internal static var a11yExpandMessageTextField: String { return L10n.tr("Localizable", "a11y_expand_message_text_field") }
+ /// Gallery, attachment %1$d
+ internal static func a11yGalleryAttachment(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_attachment", p1)
+ }
+ /// Gallery, failed scan %1$d
+ internal static func a11yGalleryFailedScan(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_failed_scan", p1)
+ }
+ /// Gallery, image %1$d
+ internal static func a11yGalleryImage(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_image", p1)
+ }
+ /// Plural format key: "%#@COUNT@"
+ internal static func a11yGalleryMoreMedia(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_more_media", p1)
+ }
+ /// Gallery, scanning %1$d
+ internal static func a11yGalleryScanning(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_scanning", p1)
+ }
+ /// Gallery, video %1$d
+ internal static func a11yGalleryVideo(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "a11y_gallery_video", p1)
+ }
/// Hide password
internal static var a11yHidePassword: String { return L10n.tr("Localizable", "a11y_hide_password") }
/// Info
diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift
index 3a7d8224c..8507c7cb0 100644
--- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift
+++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift
@@ -21,6 +21,24 @@ private struct TimelineItemAccessibilityModifier: ViewModifier {
action()
}
}
+ // A gallery is a container so that each of its attachments can be focussed on its own.
+ // Everything that isn't an attachment is announced when entering it, as the caption and
+ // the send info are hidden where they're shown to avoid being read twice.
+ case let timelineItem as GalleryRoomTimelineItem:
+ content
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel { _ in
+ Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
+ if let caption = timelineItem.content.caption, !caption.isBlank {
+ Text(caption)
+ }
+ Text(timelineItem.localizedSendInfo)
+ }
+ .accessibilityActions {
+ Button(L10n.commonMessageActions) {
+ action()
+ }
+ }
case let timelineItem as EventBasedTimelineItemProtocol:
content
.accessibilityRepresentation {
diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift
index b529ac815..ba8daca54 100644
--- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift
+++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemSendInfoLabel.swift
@@ -18,6 +18,8 @@ extension View {
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
adjustedDeliveryStatus: adjustedDeliveryStatus,
hasContentScanningFailure: hasContentScanningFailure),
+ // A gallery announces this when entering it instead.
+ isAccessibilityHidden: timelineItem is GalleryRoomTimelineItem,
context: context))
}
}
@@ -25,6 +27,7 @@ extension View {
/// Adds the send info to a view with the correct layout.
private struct TimelineItemSendInfoModifier: ViewModifier {
let sendInfo: TimelineItemSendInfo
+ let isAccessibilityHidden: Bool
let context: TimelineViewModel.Context
var layout: AnyLayout {
@@ -43,6 +46,7 @@ private struct TimelineItemSendInfoModifier: ViewModifier {
content
TimelineItemSendInfoLabel(sendInfo: sendInfo)
+ .accessibilityHidden(isAccessibilityHidden)
.contentShape(.rect)
// Tap gesture to avoid the message being detected as a button by VoiceOver
// (and the action shows a description that is already read to the user).
diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift
index d9d8a111a..cf6f275af 100644
--- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift
+++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryGridView.swift
@@ -72,6 +72,7 @@ struct GalleryGridView: View {
let overflow = (overflowCount > 0 && isLastVisible) ? overflowCount : 0
GalleryItemTileView(item: items[index],
+ position: index + 1,
mediaProvider: mediaProvider,
contentScannerService: contentScannerService,
overflowCount: overflow) {
diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift
index 86f5304f7..a5d8ea89d 100644
--- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift
+++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift
@@ -10,11 +10,25 @@ import SwiftUI
struct GalleryItemTileView: View {
let item: GalleryItem
+ /// The item's position within the gallery, announced instead of its filename.
+ let position: Int
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let overflowCount: Int
let onTap: () -> Void
+ private var accessibilityLabel: String {
+ if overflowCount > 0 {
+ L10n.a11yGalleryMoreMedia(overflowCount)
+ } else if item.isVideo {
+ L10n.a11yGalleryVideo(position)
+ } else if item.isImage {
+ L10n.a11yGalleryImage(position)
+ } else {
+ L10n.a11yGalleryAttachment(position)
+ }
+ }
+
var body: some View {
// Overflow above the scanner: shows on any scan state, survives content updates.
ZStack {
@@ -33,11 +47,15 @@ struct GalleryItemTileView: View {
}
}
.accessibilityElement(children: .ignore)
- .accessibilityLabel(item.filename)
+ .accessibilityLabel(accessibilityLabel)
} scanningContent: {
ScanningMediaEventsTimelineView()
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(L10n.a11yGalleryScanning(position))
} unsafeContent: { failure in
UnsafeMediaEventsTimelineView(failure: failure)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(L10n.a11yGalleryFailedScan(position))
}
if overflowCount > 0 {
@@ -47,6 +65,9 @@ struct GalleryItemTileView: View {
// Tappable in every scan state, so that an unsafe item can't hide the rest of the gallery.
.contentShape(Rectangle())
.onTapGesture { onTap() }
+ // One element per tile, labelled by whichever state is on show.
+ .accessibilityElement(children: .combine)
+ .accessibilityAddTraits(.isButton)
}
@ViewBuilder
@@ -109,6 +130,7 @@ struct GalleryItemTileView: View {
.foregroundStyle(.compound.textPrimary)
}
.allowsHitTesting(false) // Let the tap fall through to the tile below.
+ .accessibilityHidden(true) // The count is already announced by the tile's label.
}
private func formatted(duration: TimeInterval) -> String {
diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift
index e88021929..30e823a74 100644
--- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift
+++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryListView.swift
@@ -24,6 +24,7 @@ struct GalleryListView: View {
}
GalleryListRow(item: item,
+ position: index + 1,
mediaProvider: mediaProvider,
contentScannerService: contentScannerService) {
onItemTap(index)
@@ -46,6 +47,8 @@ struct GalleryDivider: View {
private struct GalleryListRow: View {
let item: GalleryItem
+ /// The item's position within the gallery, prepended to the row's own description.
+ let position: Int
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let onTap: () -> Void
@@ -71,6 +74,13 @@ private struct GalleryListRow: View {
} unsafeContent: { failure in
failureRow(failure)
}
+ // One element per row, so that each attachment is focussed on its own, with its position
+ // prepended to the description that the row already provides.
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel { label in
+ Text(L10n.a11yGalleryAttachment(position))
+ label
+ }
}
private func row(icon: some View) -> some View {
diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift
index e455d7f74..c68d00ad0 100644
--- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift
+++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift
@@ -41,6 +41,7 @@ struct GalleryRoomTimelineView: View {
GalleryDivider()
}
caption
+ .accessibilityHidden(true) // Announced when entering the gallery, so not a stop of its own.
}
}
.frame(width: GalleryGridView.groupWidth, alignment: .leading)