mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
e67a4c388f
The count was only on the forward button at the bottom, so VoiceOver users had to go and find it after every tap. While selecting, each selectable message now starts its label with "N messages selected". The label changes on every toggle, so VoiceOver reads the new count followed by the message, as in other messaging apps.
103 lines
4.1 KiB
Swift
103 lines
4.1 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// How a timeline item takes part in an active multi-selection, for accessibility purposes.
|
|
enum TimelineItemAccessibilityMessageSelection {
|
|
/// No selection is active, the item offers its regular message actions.
|
|
case none
|
|
/// A selection is active, the item acts as a toggle when it can be selected.
|
|
case selecting(isSelected: Bool, selectedCount: Int)
|
|
}
|
|
|
|
private struct TimelineItemAccessibilityModifier: ViewModifier {
|
|
let timelineItem: RoomTimelineItemProtocol
|
|
let selection: TimelineItemAccessibilityMessageSelection
|
|
let action: () -> Void
|
|
|
|
private var isSelecting: Bool {
|
|
if case .selecting = selection {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
switch timelineItem {
|
|
case is PollRoomTimelineItem:
|
|
// The answers stay traversable on their own, unless the whole poll acts as a selection toggle.
|
|
if isSelecting {
|
|
interactions(for: content.accessibilityElement(children: .combine))
|
|
} else {
|
|
interactions(for: content)
|
|
}
|
|
// 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:
|
|
interactions(for: content
|
|
.accessibilityElement(children: isSelecting ? .combine : .contain)
|
|
.accessibilityLabel { _ in
|
|
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
|
if let caption = timelineItem.content.caption, !caption.isBlank {
|
|
Text(caption)
|
|
}
|
|
Text(timelineItem.localizedSendInfo)
|
|
})
|
|
case let timelineItem as EventBasedTimelineItemProtocol:
|
|
interactions(for: content
|
|
.accessibilityRepresentation {
|
|
VStack(spacing: 8) {
|
|
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
|
content
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine))
|
|
default:
|
|
content
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
/// Offers the message actions, or turns the item into a selection toggle while selecting.
|
|
@ViewBuilder
|
|
private func interactions(for view: some View) -> some View {
|
|
switch selection {
|
|
case .none:
|
|
view
|
|
.accessibilityActions {
|
|
Button(L10n.commonMessageActions) {
|
|
action()
|
|
}
|
|
}
|
|
case .selecting(let isSelected, let selectedCount):
|
|
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isForwardable {
|
|
view
|
|
.accessibilityLabel { label in
|
|
Text(L10n.screenRoomSelectionCount(selectedCount))
|
|
label
|
|
}
|
|
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
|
.accessibilityAction(.default, action)
|
|
} else {
|
|
// Items that can't be selected are inert while selecting.
|
|
view
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func timelineItemAccessibility(_ timelineItem: RoomTimelineItemProtocol,
|
|
selection: TimelineItemAccessibilityMessageSelection = .none,
|
|
action: @escaping () -> Void) -> some View {
|
|
modifier(TimelineItemAccessibilityModifier(timelineItem: timelineItem, selection: selection, action: action))
|
|
}
|
|
}
|