Files
element-x-ios/ElementX/Sources/Other/UserIndicator/UserIndicatorController.swift
T
Mauro d3604e8115 Announce toasts with VoiceOver (#6250)
* Announce toasts with VoiceOver

Toasts are posted from a separate overlay window and never take
VoiceOver focus, so their text was never spoken. UserIndicatorController
now posts a high priority announcement with the title whenever a toast
with readable text is submitted, including re-submissions of an already
visible toast with the same ID. Modal indicators are not announced.

* Remove empty-title toast announcement check

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Hide toasts from VoiceOver

The toast title is now announced by UserIndicatorController when the
toast is submitted, so letting VoiceOver also focus the toast itself
would only read the same text twice.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-10-02 12:29:18 +02:00

107 lines
3.6 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-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 Combine
import SwiftUI
class UserIndicatorController: ObservableObject, UserIndicatorControllerProtocol {
private var timerCancellable: AnyCancellable?
private var displayTimes = [String: Date]()
private var delayedIndicators = Set<String>()
var nonPersistentDisplayDuration = 2.5
var minimumDisplayDuration = 0.5
@Published private(set) var activeIndicator: UserIndicator?
private(set) var indicatorQueue = [UserIndicator]() {
didSet {
activeIndicator = indicatorQueue.last
if let activeIndicator, !activeIndicator.persistent {
timerCancellable?.cancel()
timerCancellable = Task { [weak self, nonPersistentDisplayDuration] in
try await Task.sleep(for: .seconds(nonPersistentDisplayDuration))
self?.retractIndicatorWithId(activeIndicator.id)
}.asCancellable()
}
}
}
var window: UIWindow? {
didSet {
let hostingController = UIHostingController(rootView: UserIndicatorPresenter(userIndicatorController: self).statusBarHidden(ProcessInfo.isRunningUITests))
hostingController.view.backgroundColor = .clear
window?.rootViewController = hostingController
}
}
func submitIndicator(_ indicator: UserIndicator, delay: Duration?) {
if let index = indicatorQueue.firstIndex(where: { $0.id == indicator.id }) {
indicatorQueue[index] = indicator
displayTimes[indicator.id] = .now
announceIfNeeded(indicator)
} else {
if let delay {
delayedIndicators.insert(indicator.id)
Task {
try? await Task.sleep(for: .seconds(delay.seconds))
guard delayedIndicators.contains(indicator.id) else {
return
}
enqueue(indicator: indicator)
}
} else {
enqueue(indicator: indicator)
}
}
}
func retractAllIndicators() {
for indicator in indicatorQueue {
retractIndicatorWithId(indicator.id)
}
}
func retractIndicatorWithId(_ id: String) {
delayedIndicators.remove(id)
guard let displayTime = displayTimes[id], abs(displayTime.timeIntervalSinceNow) <= minimumDisplayDuration else {
indicatorQueue.removeAll { $0.id == id }
return
}
Task {
try? await Task.sleep(for: .seconds(minimumDisplayDuration))
indicatorQueue.removeAll { $0.id == id }
displayTimes[id] = nil
}
}
// MARK: - Private
private func enqueue(indicator: UserIndicator) {
retractIndicatorWithId(indicator.id)
indicatorQueue.append(indicator)
displayTimes[indicator.id] = .now
announceIfNeeded(indicator)
}
private func announceIfNeeded(_ indicator: UserIndicator) {
guard case .toast = indicator.type else {
return
}
var announcement = AttributedString(indicator.title)
announcement.accessibilitySpeechAnnouncementPriority = .high
AccessibilityNotification.Announcement(announcement).post()
}
}