Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25238ee
Improve VoiceOver navigation and message accessibility
Kostenkov-2021 Jul 22, 2026
6a9b10a
Expand VoiceOver message actions and sharing accessibility
Kostenkov-2021 Jul 24, 2026
452baa3
Improve VoiceOver accessibility for modal interfaces
Kostenkov-2021 Jul 24, 2026
8591801
Improve Dynamic Type support for accessible modals
Kostenkov-2021 Jul 24, 2026
512637e
Improve VoiceOver support for alerts and Voice Control
Kostenkov-2021 Jul 24, 2026
41b9bd3
Improve VoiceOver reply navigation and peer info actions
Kostenkov-2021 Jul 24, 2026
2cff1ba
Improve VoiceOver accessibility for peer info members and header
Kostenkov-2021 Jul 24, 2026
2c9ee56
Improve VoiceOver accessibility for peer info media panes
Kostenkov-2021 Jul 24, 2026
1e328cb
Improve VoiceOver behavior in embedded peer info panes
Kostenkov-2021 Jul 24, 2026
d9f29dd
Improve VoiceOver accessibility in gifts and sharing
Kostenkov-2021 Jul 24, 2026
1864188
Improve VoiceOver accessibility in selection flows
Kostenkov-2021 Jul 24, 2026
047760a
Keep VoiceOver scroll feedback consistently localized
Kostenkov-2021 Jul 25, 2026
ca99e37
Improve VoiceOver selection flows and Share Extension
Kostenkov-2021 Jul 25, 2026
2108986
Improve VoiceOver accessibility for gifts and reply media
Kostenkov-2021 Jul 29, 2026
81e0eb2
Improve VoiceOver focus persistence and modal accessibility
Kostenkov-2021 Jul 29, 2026
e37e5ba
Complete VoiceOver focus persistence and regression gates
Kostenkov-2021 Aug 22, 2026
0f958f6
Expand VoiceOver regression coverage
Kostenkov-2021 Aug 22, 2026
693b4f9
Improve rich text scaling and Voice Control performance coverage
Kostenkov-2021 Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Improve VoiceOver navigation and message accessibility
- preserve VoiceOver focus by message ID across history transactions
- improve accessibility scrolling and focus restoration
- expose accessible history navigation buttons
- align input field hit testing, frame, and focus behavior
- unify accessibility data handling across message renderers
- move reply context, including voice message replies, into the accessibility hint
  • Loading branch information
Kostenkov-2021 committed Jul 22, 2026
commit 25238eeaed3b04afe902104329cadc4ea5ee5cd2
29 changes: 26 additions & 3 deletions submodules/Display/Source/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5442,7 +5442,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
if let (_, frame) = accessibilityFocusedNode {
for itemNode in self.itemNodes {
if frame.intersects(itemNode.frame) {
UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: itemNode.view)
UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view)
if let index = itemNode.index {
let scrollStatus: String
if let accessibilityPageScrolledString = self.accessibilityPageScrolledString {
Expand All @@ -5465,8 +5465,10 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
switch direction {
case .down:
scrollDirection = self.rotated ? .up : .down
default:
case .up:
scrollDirection = self.rotated ? .down : .up
default:
return false
}
return self.scrollWithDirection(scrollDirection, distance: distance)
}
Expand All @@ -5477,8 +5479,29 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
}

private func findAccessibilityFocus(_ node: ASDisplayNode) -> Bool {
if node.view.accessibilityElementIsFocused() {
return containsAccessibilityFocus(node.view)
}

private func containsAccessibilityFocus(_ view: UIView) -> Bool {
if view.accessibilityElementIsFocused() {
return true
}
for subview in view.subviews {
if containsAccessibilityFocus(subview) {
return true
}
}
return false
}

private func firstAccessibilityElement(in view: UIView) -> UIView? {
if view.isAccessibilityElement && !view.isHidden && view.alpha > 0.01 {
return view
}
for subview in view.subviews {
if let result = firstAccessibilityElement(in: subview) {
return result
}
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -801,19 +801,7 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView {
}

override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
super.updateAccessibilityData(accessibilityData)

self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label
self.messageAccessibilityArea.accessibilityValue = accessibilityData.value
self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint
self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits
if let customActions = accessibilityData.customActions {
self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in
return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action)
})
} else {
self.messageAccessibilityArea.accessibilityCustomActions = nil
}
super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:)))
}

@objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5681,19 +5681,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
}

override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
super.updateAccessibilityData(accessibilityData)

self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label
self.messageAccessibilityArea.accessibilityValue = accessibilityData.value
self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint
self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits
if let customActions = accessibilityData.customActions {
self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in
return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action)
})
} else {
self.messageAccessibilityArea.accessibilityCustomActions = nil
}
super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:)))
}

@objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,19 +246,7 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco
}

override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
super.updateAccessibilityData(accessibilityData)

self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label
self.messageAccessibilityArea.accessibilityValue = accessibilityData.value
self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint
self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits
if let customActions = accessibilityData.customActions {
self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in
return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action)
})
} else {
self.messageAccessibilityArea.accessibilityCustomActions = nil
}
super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:)))
}

@objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,12 +604,18 @@ public final class ChatMessageAccessibilityData {
}

if let replyValue {
value = "\(value). \(item.presentationData.strings.VoiceOver_Chat_ReplyingToMessage(replyValue).string)"
let replyHint = item.presentationData.strings.VoiceOver_Chat_ReplyingToMessage(replyValue).string
if let hint, !hint.isEmpty {
self.hint = "\(hint). \(replyHint)"
} else {
self.hint = replyHint
}
} else {
self.hint = hint
}

self.label = label
self.value = value
self.hint = hint
self.traits = traits
self.customActions = customActions.isEmpty ? nil : customActions
self.singleUrl = singleUrl
Expand Down Expand Up @@ -656,6 +662,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol {

open var item: ChatMessageItem?
open var accessibilityData: ChatMessageAccessibilityData?
private weak var messageAccessibilityNode: AccessibilityAreaNode?
open var safeInsets = UIEdgeInsets()

open var awaitingAppliedReaction: (MessageReaction.Reaction?, () -> Void)?
Expand Down Expand Up @@ -694,6 +701,31 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol {
open func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
self.accessibilityData = accessibilityData
}

public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData, accessibilityNode: AccessibilityAreaNode, customActionTarget: Any, customActionSelector: Selector) {
self.accessibilityData = accessibilityData
self.messageAccessibilityNode = accessibilityNode

accessibilityNode.accessibilityLabel = accessibilityData.label
accessibilityNode.accessibilityValue = accessibilityData.value
accessibilityNode.accessibilityHint = accessibilityData.hint
accessibilityNode.accessibilityTraits = accessibilityData.traits
if let customActions = accessibilityData.customActions {
accessibilityNode.accessibilityCustomActions = customActions.map { action in
return ChatMessageAccessibilityCustomAction(name: action.name, target: customActionTarget, selector: customActionSelector, action: action.action)
}
} else {
accessibilityNode.accessibilityCustomActions = nil
}
}

public func accessibilityContainsFocus() -> Bool {
return self.messageAccessibilityNode?.view.accessibilityElementIsFocused() == true || self.view.accessibilityElementIsFocused()
}

public func restoreAccessibilityFocus() {
UIAccessibility.post(notification: .layoutChanged, argument: self.messageAccessibilityNode?.view ?? self.view)
}

override open func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
if let item = item as? ChatMessageItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,19 +389,7 @@ public class ChatMessageStickerItemNode: ChatMessageItemView {
}

override public func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
super.updateAccessibilityData(accessibilityData)

self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label
self.messageAccessibilityArea.accessibilityValue = accessibilityData.value
self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint
self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits
if let customActions = accessibilityData.customActions {
self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in
return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action)
})
} else {
self.messageAccessibilityArea.accessibilityCustomActions = nil
}
super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:)))
}

@objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,9 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
}
old.resignInputFirstResponder()
old.asNode.removeFromSupernode()
if wasFirstResponder && UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .layoutChanged, argument: new.inputView)
}
}

private func loadTextInputNode(useNative: Bool = false) {
Expand Down Expand Up @@ -3198,6 +3201,8 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
richTextInputNode.textContainerInset = textInputViewRealInsets
richTextInputNode.textFieldFrame = actualTextFieldFrame
richTextInputNode.updateLayout(size: textFieldFrame.size)
let accessibilityBounds = richTextInputNode.inputView.bounds.inset(by: richTextInputNode.inputHitTestSlop)
richTextInputNode.inputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView)
self.updateInputField(textInputFrame: textFieldFrame, transition: ComponentTransition(transition))
if shouldUpdateLayout {
richTextInputNode.layoutInputField()
Expand Down
3 changes: 1 addition & 2 deletions submodules/TelegramUI/Sources/ChatControllerNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,8 +811,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
}
self.inputPanelBackgroundNode.isUserInteractionEnabled = false

self.navigateButtons = ChatHistoryNavigationButtons(theme: self.chatPresentationInterfaceState.theme, preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, backgroundNode: self.backgroundNode, isChatRotated: historyNodeRotated)
self.navigateButtons.accessibilityElementsHidden = true
self.navigateButtons = ChatHistoryNavigationButtons(theme: self.chatPresentationInterfaceState.theme, strings: self.chatPresentationInterfaceState.strings, preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, backgroundNode: self.backgroundNode, isChatRotated: historyNodeRotated)

super.init()

Expand Down
17 changes: 17 additions & 0 deletions submodules/TelegramUI/Sources/ChatHistoryListNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,15 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat
}
self.hasActiveTransition = true
let transition = self.enqueuedHistoryViewTransitions.removeFirst()

var accessibilityFocusedMessageId: MessageId?
if UIAccessibility.isVoiceOverRunning {
self.forEachVisibleMessageItemNode { itemNode in
if accessibilityFocusedMessageId == nil, itemNode.accessibilityContainsFocus(), let item = itemNode.item {
accessibilityFocusedMessageId = item.content.first?.0.id
}
}
}

var expiredMessageStableIds = Set<UInt32>()
if let previousHistoryView = self.historyView, transition.options.contains(.AnimateInsertion) {
Expand Down Expand Up @@ -4429,6 +4438,14 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat
}

strongSelf.hasActiveTransition = false

if let accessibilityFocusedMessageId {
strongSelf.forEachVisibleMessageItemNode { itemNode in
if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) {
itemNode.restoreAccessibilityFocus()
}
}
}

if let previousCloneView {
previousCloneView.transform = strongSelf.view.transform
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode {
var badge: String = "" {
didSet {
if self.badge != oldValue {
self.accessibilityValue = self.badge.isEmpty ? nil : self.badge
self.layoutBadge()
}
}
Expand All @@ -51,7 +52,7 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode {
private var preferClearGlass: Bool
private let type: ChatHistoryNavigationButtonType

init(theme: PresentationTheme, preferClearGlass: Bool, backgroundNode: WallpaperBackgroundNode, type: ChatHistoryNavigationButtonType) {
init(theme: PresentationTheme, strings: PresentationStrings, preferClearGlass: Bool, backgroundNode: WallpaperBackgroundNode, type: ChatHistoryNavigationButtonType) {
self.theme = theme
self.preferClearGlass = preferClearGlass
self.type = type
Expand Down Expand Up @@ -84,6 +85,21 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode {
self.badgeTextNode.reverseAnimationDirection = true

super.init()

self.isAccessibilityElement = true
self.accessibilityTraits = .button
switch type {
case .down:
self.accessibilityLabel = strings.KeyCommand_ScrollDown
case .up:
self.accessibilityLabel = strings.KeyCommand_ScrollUp
case .mentions:
self.accessibilityLabel = strings.Conversation_ContextMenuMention
case .reactions:
self.accessibilityLabel = strings.Conversation_ReadAllReactions
case .pollVotes:
self.accessibilityLabel = strings.Conversation_ReadAllPollVotes
}

let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))
self.tapRecognizer = tapRecognizer
Expand Down Expand Up @@ -159,6 +175,14 @@ class ChatHistoryNavigationButtonNode: ContextControllerSourceNode {
}
}
}

override func accessibilityActivate() -> Bool {
guard self.isEnabled, let tapped = self.tapped else {
return false
}
tapped()
return true
}

private var currentValue: Int = 0
private func layoutBadge() {
Expand Down
12 changes: 6 additions & 6 deletions submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,29 +112,29 @@ final class ChatHistoryNavigationButtons: ASDisplayNode {
}
}

init(theme: PresentationTheme, preferClearGlass: Bool, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode, isChatRotated: Bool) {
init(theme: PresentationTheme, strings: PresentationStrings, preferClearGlass: Bool, dateTimeFormat: PresentationDateTimeFormat, backgroundNode: WallpaperBackgroundNode, isChatRotated: Bool) {
self.isChatRotated = isChatRotated
self.theme = theme
self.preferClearGlass = preferClearGlass
self.dateTimeFormat = dateTimeFormat

self.mentionsButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .mentions)
self.mentionsButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .mentions)
self.mentionsButton.alpha = 0.0
self.mentionsButton.isHidden = true

self.reactionsButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .reactions)
self.reactionsButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .reactions)
self.reactionsButton.alpha = 0.0
self.reactionsButton.isHidden = true

self.pollVotesButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .pollVotes)
self.pollVotesButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: .pollVotes)
self.pollVotesButton.alpha = 0.0
self.pollVotesButton.isHidden = true

self.downButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .down : .up)
self.downButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .down : .up)
self.downButton.alpha = 0.0
self.downButton.isHidden = true

self.upButton = ChatHistoryNavigationButtonNode(theme: theme, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .up : .down)
self.upButton = ChatHistoryNavigationButtonNode(theme: theme, strings: strings, preferClearGlass: preferClearGlass, backgroundNode: backgroundNode, type: isChatRotated ? .up : .down)
self.upButton.alpha = 0.0
self.upButton.isHidden = true

Expand Down