From 25238eeaed3b04afe902104329cadc4ea5ee5cd2 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:47:40 +0500 Subject: [PATCH 01/18] 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 --- submodules/Display/Source/ListView.swift | 29 +++++++++++++-- .../ChatMessageAnimatedStickerItemNode.swift | 14 +------- .../Sources/ChatMessageBubbleItemNode.swift | 14 +------- .../ChatMessageInstantVideoItemNode.swift | 14 +------- .../Sources/ChatMessageItemView.swift | 36 +++++++++++++++++-- .../Sources/ChatMessageStickerItemNode.swift | 14 +------- .../Sources/ChatTextInputPanelNode.swift | 5 +++ .../Sources/ChatControllerNode.swift | 3 +- .../Sources/ChatHistoryListNode.swift | 17 +++++++++ .../ChatHistoryNavigationButtonNode.swift | 26 +++++++++++++- .../ChatHistoryNavigationButtons.swift | 12 +++---- 11 files changed, 118 insertions(+), 66 deletions(-) diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 6e945691f3a..dd000586849 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -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 { @@ -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) } @@ -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 +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index 06d1369916a..da2567dc2f3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -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) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2dcfc95ed57..2413f1bc580 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -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) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index f8c3faf6960..6daacd49eb1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -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) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index e6d467fbff3..42b5743520d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -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 @@ -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)? @@ -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 { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index 4d5cd55f59b..30a4ac9a9db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -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) { diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 826bdb4098d..0d0d35b31c4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -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) { @@ -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() diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 83c19884c8d..a8f8b31aa37 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -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() diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index b547dd3b6f7..c1b8580fda5 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -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() if let previousHistoryView = self.historyView, transition.options.contains(.AnimateInsertion) { @@ -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 diff --git a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift index 53e1f936985..158651f5d5e 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtonNode.swift @@ -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() } } @@ -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 @@ -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 @@ -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() { diff --git a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift index de472c88f3e..62b9dd5fd4b 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryNavigationButtons.swift @@ -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 From 6a9b10a887ab91d544254d6def54c8db7879a73f Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:36:16 +0500 Subject: [PATCH 02/18] Expand VoiceOver message actions and sharing accessibility - add reply, reaction, copy, forward, delete, and options accessibility actions - announce delivery, read, and playback states separately - apply selected traits consistently across message renderers - simplify reply hints by excluding timestamps and delivery states - improve accessibility of peer info media and editing controls - expose recipients, inputs, search, and action controls in the share extension --- .../Sources/ShareActionButtonNode.swift | 13 +++++ .../Sources/ShareControllerNode.swift | 7 +++ .../Sources/ShareControllerPeerGridItem.swift | 33 +++++++++++ .../Sources/ShareInputFieldNode.swift | 7 +++ .../Sources/ShareSearchBarNode.swift | 9 ++- .../Sources/ShareSearchContainerNode.swift | 2 +- .../ChatMessageAnimatedStickerItemNode.swift | 17 ++---- .../Sources/ChatMessageBubbleItemNode.swift | 29 +++------- .../ChatMessageInstantVideoItemNode.swift | 17 ++---- .../Sources/ChatMessageItemView.swift | 57 ++++++++++++++++--- .../Sources/ChatMessageStickerItemNode.swift | 17 ++---- .../Sources/ChatControllerInteraction.swift | 9 +++ .../Sources/Panes/PeerInfoGifPaneNode.swift | 40 +++++++++++++ ...PeerInfoHeaderMultiLineTextFieldNode.swift | 5 +- ...eerInfoHeaderSingleLineTextFieldNode.swift | 4 +- .../TelegramUI/Sources/ChatController.swift | 6 +- 16 files changed, 198 insertions(+), 74 deletions(-) diff --git a/submodules/ShareController/Sources/ShareActionButtonNode.swift b/submodules/ShareController/Sources/ShareActionButtonNode.swift index 13cf0857c2b..2b1dca4bc76 100644 --- a/submodules/ShareController/Sources/ShareActionButtonNode.swift +++ b/submodules/ShareController/Sources/ShareActionButtonNode.swift @@ -28,6 +28,7 @@ public final class ShareActionButtonNode: HighlightTrackingButtonNode { public var badge: String? { didSet { if self.badge != oldValue { + self.accessibilityValue = self.badge if let badge = self.badge { self.badgeText = NSAttributedString(string: badge, font: Font.regular(14.0), textColor: self.badgeTextColor, paragraphAlignment: .center) self.badgeLabel.isHidden = false @@ -68,6 +69,9 @@ public final class ShareActionButtonNode: HighlightTrackingButtonNode { self.badgeBackground.image = generateStretchableFilledCircleImage(diameter: 22.0, color: badgeBackgroundColor) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button self.containerNode.addSubnode(self.referenceNode) self.addSubnode(self.containerNode) @@ -147,6 +151,10 @@ public final class ShareStartAtTimestampNode: HighlightTrackingButtonNode { self.titleTextNode.displaysAsynchronously = false super.init() + + self.isAccessibilityElement = true + self.accessibilityLabel = titleText + self.accessibilityTraits = .button self.addSubnode(self.checkNode) self.addSubnode(self.titleTextNode) @@ -156,6 +164,11 @@ public final class ShareStartAtTimestampNode: HighlightTrackingButtonNode { @objc private func pressed() { self.checkNode.setSelected(!self.checkNode.selected, animated: true) + if self.checkNode.selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } self.updated?() } diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 7566da53641..6cac8695294 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -706,6 +706,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate self.addSubnode(self.wrappingScrollNode) self.cancelButtonNode.setTitle(self.presentationData.strings.Common_Cancel, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.cancelButtonNode.accessibilityLabel = self.presentationData.strings.Common_Cancel + self.cancelButtonNode.accessibilityTraits = .button self.wrappingScrollNode.addSubnode(self.cancelButtonNode) self.cancelButtonNode.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside) @@ -1840,19 +1842,23 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if count == 0 { if self.presetText != nil { self.actionButtonNode.setTitle(self.presentationData.strings.ShareMenu_Send, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.disabledActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = self.presentationData.strings.ShareMenu_Send self.actionButtonNode.isEnabled = false self.actionButtonNode.badge = nil } else if let segmentedValues = self.segmentedValues { let value = segmentedValues[self.selectedSegmentedIndex] self.actionButtonNode.setTitle(value.actionTitle, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = value.actionTitle self.actionButtonNode.isEnabled = true self.actionButtonNode.badge = nil } else if let defaultAction = self.defaultAction { self.actionButtonNode.setTitle(defaultAction.title, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = defaultAction.title self.actionButtonNode.isEnabled = true self.actionButtonNode.badge = nil } else { self.actionButtonNode.setTitle(self.presentationData.strings.ShareMenu_Send, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.disabledActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = self.presentationData.strings.ShareMenu_Send self.actionButtonNode.isEnabled = false self.actionButtonNode.badge = nil } @@ -1866,6 +1872,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } self.actionButtonNode.isEnabled = true self.actionButtonNode.setTitle(text, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) + self.actionButtonNode.accessibilityLabel = text self.actionButtonNode.badge = "\(count)" } } diff --git a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift index 1389f89037b..c4a1463ef47 100644 --- a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift +++ b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift @@ -77,6 +77,10 @@ final class ShareControllerGridSectionNode: ASDisplayNode { self.titleNode.truncationMode = .byTruncatingTail super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + self.peerNode.accessibilityElementsHidden = true self.addSubnode(self.backgroundNode) self.addSubnode(self.titleNode) @@ -255,6 +259,7 @@ final class ShareControllerPeerGridItemNode: GridItemNode { online: isOnline, synchronousLoad: synchronousLoad ) + self.accessibilityLabel = threadData?.info.title ?? peer.compactDisplayTitle if let shimmerNode = self.placeholderNode { self.placeholderNode = nil shimmerNode.removeFromSupernode() @@ -277,7 +282,9 @@ final class ShareControllerPeerGridItemNode: GridItemNode { synchronousLoad: synchronousLoad, storyMode: storyMode ) + self.accessibilityLabel = strings.StoryFeed_ContextAddStory } else { + self.isAccessibilityElement = false let shimmerNode: ShimmerEffectNode if let current = self.placeholderNode { shimmerNode = current @@ -304,6 +311,9 @@ final class ShareControllerPeerGridItemNode: GridItemNode { shimmerNode.update(backgroundColor: theme.list.itemBlocksBackgroundColor, foregroundColor: theme.list.mediaPlaceholderColor, shimmeringColor: theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, horizontal: true, size: self.bounds.size) } + if item != nil { + self.isAccessibilityElement = true + } self.currentState = (environment, context, theme, strings, item, search) self.setNeedsLayout() if let effectivePresence { @@ -322,6 +332,29 @@ final class ShareControllerPeerGridItemNode: GridItemNode { } self.peerNode.updateSelection(selected: selected, animated: animated) + self.accessibilityValue = selected ? self.currentState?.strings.VoiceOver_Chat_Selected : nil + if selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } + } + + override func accessibilityActivate() -> Bool { + guard let controllerInteraction = self.controllerInteraction, let item = self.currentState?.item else { + return false + } + switch item { + case let .peer(peer, _, _, _, requiresPremiumForMessaging, requiresStars): + if requiresPremiumForMessaging || requiresStars != nil { + controllerInteraction.disabledPeerSelected(peer) + } else { + controllerInteraction.togglePeer(peer, self.currentState?.search ?? false) + } + case .story: + controllerInteraction.shareStory?() + } + return true } override func layout() { diff --git a/submodules/ShareController/Sources/ShareInputFieldNode.swift b/submodules/ShareController/Sources/ShareInputFieldNode.swift index 014c980eb98..cab97511271 100644 --- a/submodules/ShareController/Sources/ShareInputFieldNode.swift +++ b/submodules/ShareController/Sources/ShareInputFieldNode.swift @@ -217,6 +217,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.attributedText = NSAttributedString(string: newValue, font: Font.regular(17.0), textColor: self.theme.textColor) self.placeholderNode.isHidden = !newValue.isEmpty || self.inputCopyText != nil self.clearButton.isHidden = newValue.isEmpty + self.clearButton.isAccessibilityElement = !newValue.isEmpty } } @@ -244,6 +245,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: 0.0, bottom: self.inputInsets.bottom, right: 0.0) self.textInputNode.keyboardAppearance = theme.keyboard.keyboardAppearance self.textInputNode.tintColor = theme.accentColor + self.textInputNode.textView.accessibilityHint = placeholder self.placeholderNode = ASTextNode() self.placeholderNode.isUserInteractionEnabled = false @@ -256,6 +258,9 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.clearButton.displaysAsynchronously = false self.clearButton.setImage(generateClearIcon(color: theme.clearButtonColor), for: []) self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false + self.clearButton.accessibilityLabel = strings.WebSearch_RecentSectionClear + self.clearButton.accessibilityTraits = .button super.init() @@ -352,6 +357,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { self.clearButton.isHidden = false + self.clearButton.isAccessibilityElement = true if self.selectTextOnce { self.selectTextOnce = false @@ -364,6 +370,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { self.placeholderNode.isHidden = !(editableTextNode.textView.text ?? "").isEmpty || self.inputCopyText != nil self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false } private func calculateTextFieldMetrics(width: CGFloat) -> CGFloat { diff --git a/submodules/ShareController/Sources/ShareSearchBarNode.swift b/submodules/ShareController/Sources/ShareSearchBarNode.swift index a9f76440429..67b01a43563 100644 --- a/submodules/ShareController/Sources/ShareSearchBarNode.swift +++ b/submodules/ShareController/Sources/ShareSearchBarNode.swift @@ -19,7 +19,7 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { var textUpdated: ((String) -> Void)? - init(theme: PresentationTheme, placeholder: String) { + init(theme: PresentationTheme, strings: PresentationStrings, placeholder: String) { self.backgroundNode = ASImageNode() self.backgroundNode.isLayerBacked = true self.backgroundNode.displaysAsynchronously = false @@ -38,6 +38,9 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { self.clearButton.displaysAsynchronously = false self.clearButton.setImage(generateClearIcon(color: theme.actionSheet.inputClearButtonColor), for: []) self.clearButton.isHidden = true + self.clearButton.isAccessibilityElement = false + self.clearButton.accessibilityLabel = strings.WebSearch_RecentSectionClear + self.clearButton.accessibilityTraits = .button self.textInputNode = TextFieldNode() self.textInputNode.fixOffset = false @@ -108,7 +111,9 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { } @objc func textFieldDidChangeText() { - self.clearButton.isHidden = self.textInputNode.textField.text?.isEmpty ?? true + let isEmpty = self.textInputNode.textField.text?.isEmpty ?? true + self.clearButton.isHidden = isEmpty + self.clearButton.isAccessibilityElement = !isEmpty self.textUpdated?(self.textInputNode.textField.text ?? "") } diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index faef506e643..a53d67687ac 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -241,7 +241,7 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.contentGridNode = GridNode() self.contentGridNode.isHidden = true - self.searchNode = ShareSearchBarNode(theme: theme, placeholder: strings.Common_Search) + self.searchNode = ShareSearchBarNode(theme: theme, strings: strings, placeholder: strings.Common_Search) self.cancelButtonNode = HighlightableButtonNode() self.cancelButtonNode.setTitle(strings.Common_Cancel, with: cancelFont, with: theme.actionSheet.controlAccentColor, for: []) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index da2567dc2f3..bb8d13d9835 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -804,19 +804,8 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.imageNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.imageNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -2798,6 +2787,8 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2413f1bc580..a60e8d488ce 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -5684,28 +5684,17 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - var subFrame = self.backgroundNode.frame - if case .group = item.content { - for contentNode in self.contentNodes { - if contentNode.item?.message.stableId == item.message.stableId { - subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) - break - } - } - } - item.controllerInteraction.openMessageContextMenu(item.message, false, self, subFrame, nil, nil) - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + var subFrame = self.backgroundNode.frame + if let item = self.item, case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == item.message.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } } } + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: subFrame) } override public func shouldAnimateHorizontalFrameTransition() -> Bool { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift index 6daacd49eb1..1e3104b5476 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift @@ -249,19 +249,8 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.interactiveVideoNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.interactiveVideoNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -1193,6 +1182,8 @@ public class ChatMessageInstantVideoItemNode: ChatMessageItemView, ASGestureReco guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 42b5743520d..9dc4d087af7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -66,7 +66,11 @@ private let fileSizeFormatter: ByteCountFormatter = { public enum ChatMessageAccessibilityCustomActionType { case reply + case react case options + case copy + case forward + case delete } public final class ChatMessageAccessibilityCustomAction: UIAccessibilityCustomAction { @@ -445,15 +449,16 @@ public final class ChatMessageAccessibilityData { if isSelected { result += item.presentationData.strings.VoiceOver_Chat_Selected result += "\n" + traits.insert(.selected) } - traits.insert(.startsMediaSession) } result += "\(text)" - let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(message.timestamp)), dateStyle: .medium, timeStyle: .short) - - result += "\n\(dateString)" + if !isReply { + let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(message.timestamp)), dateStyle: .medium, timeStyle: .short) + result += "\n\(dateString)" + } if !isIncoming && !isReply { result += "\n" if item.sending { @@ -462,14 +467,13 @@ public final class ChatMessageAccessibilityData { result += item.presentationData.strings.VoiceOver_Chat_Failed } else { if item.read { - if announceIncomingAuthors { - result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipients - } else { - result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipient - } + result += item.presentationData.strings.Conversation_ChecksTooltip_Read + } else { + result += item.presentationData.strings.Conversation_ChecksTooltip_Delivered } for attribute in message.attributes { if let attribute = attribute as? ConsumableContentMessageAttribute { + result += "\n" if !attribute.consumed { if announceIncomingAuthors { result += item.presentationData.strings.VoiceOver_Chat_NotPlayedByRecipients @@ -600,6 +604,16 @@ public final class ChatMessageAccessibilityData { if canReply { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextReply, target: nil, selector: #selector(self.noop), action: .reply)) } + if canAddMessageReactions(message: EngineMessage(item.message)) { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.MediaEditor_Shortcut_Reaction, target: nil, selector: #selector(self.noop), action: .react)) + } + if !item.message.text.isEmpty { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.Conversation_ContextMenuCopy, target: nil, selector: #selector(self.noop), action: .copy)) + } + if item.controllerInteraction.canPerformAccessibilityMessageActions { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextForward, target: nil, selector: #selector(self.noop), action: .forward)) + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextDelete, target: nil, selector: #selector(self.noop), action: .delete)) + } customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: nil, selector: #selector(self.noop), action: .options)) } @@ -726,6 +740,31 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { public func restoreAccessibilityFocus() { UIAccessibility.post(notification: .layoutChanged, argument: self.messageAccessibilityNode?.view ?? self.view) } + + public func performAccessibilityCustomAction(_ customAction: UIAccessibilityCustomAction, sourceNode: ASDisplayNode, sourceRect: CGRect) -> Bool { + guard let action = customAction as? ChatMessageAccessibilityCustomAction, let item = self.item else { + return false + } + + switch action.action { + case .reply: + item.controllerInteraction.setupReply(item.message.id) + case .react: + item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) + case .options: + item.controllerInteraction.openMessageContextMenu(item.message, false, sourceNode, sourceRect, nil, nil) + case .copy: + guard !item.message.text.isEmpty else { + return false + } + item.controllerInteraction.copyText(item.message.text) + case .forward: + item.controllerInteraction.accessibilityForwardMessage(item.message) + case .delete: + item.controllerInteraction.accessibilityDeleteMessage(item.message) + } + return true + } override open func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let item = item as? ChatMessageItem { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index 30a4ac9a9db..cd67ee899f8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -392,19 +392,8 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { super.updateAccessibilityData(accessibilityData, accessibilityNode: self.messageAccessibilityArea, customActionTarget: self, customActionSelector: #selector(self.performLocalAccessibilityCustomAction(_:))) } - @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { - if let action = action as? ChatMessageAccessibilityCustomAction { - switch action.action { - case .reply: - if let item = self.item { - item.controllerInteraction.setupReply(item.message.id) - } - case .options: - if let item = self.item { - item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.imageNode.frame, nil, nil) - } - } - } + @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) -> Bool { + return self.performAccessibilityCustomAction(action, sourceNode: self, sourceRect: self.imageNode.frame) } override public func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: ChatMessageHeaderSpec) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) { @@ -1787,6 +1776,8 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { guard let item = self.item else { return } + let isSelected = item.controllerInteraction.selectionState.map { $0.selectedIds.contains(item.message.id) } + self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) if case let .replyThread(replyThreadMessage) = item.chatLocation, replyThreadMessage.effectiveTopId == item.message.id { return diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 0546d4be9b9..9a11653344f 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -278,6 +278,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let openMessageStats: (EngineMessage.Id) -> Void public let editMessageMedia: (EngineMessage.Id, Bool) -> Void public let copyText: (String) -> Void + public let accessibilityForwardMessage: (EngineRawMessage) -> Void + public let accessibilityDeleteMessage: (EngineRawMessage) -> Void + public let canPerformAccessibilityMessageActions: Bool public let displayUndo: (UndoOverlayContent) -> Void public let isAnimatingMessage: (UInt32) -> Bool public let getMessageTransitionNode: () -> ChatMessageTransitionProtocol? @@ -504,6 +507,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol displayTodoToggleUnavailable: @escaping (EngineMessage.Id) -> Void, canEditMessageRichText: @escaping (EngineRawMessage) -> Bool = { _ in false }, toggleMessageRichTextCheckbox: @escaping (EngineMessage.Id, [Int], Bool) -> Void = { _, _, _ in }, + accessibilityForwardMessage: @escaping (EngineRawMessage) -> Void = { _ in }, + accessibilityDeleteMessage: @escaping (EngineRawMessage) -> Void = { _ in }, + canPerformAccessibilityMessageActions: Bool = false, openStarsPurchase: @escaping (Int64?) -> Void, openRankInfo: @escaping (EnginePeer, ChatRankInfoScreenRole, String) -> Void, openSetPeerAvatar: @escaping () -> Void, @@ -597,6 +603,9 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.openMessageStats = openMessageStats self.editMessageMedia = editMessageMedia self.copyText = copyText + self.accessibilityForwardMessage = accessibilityForwardMessage + self.accessibilityDeleteMessage = accessibilityDeleteMessage + self.canPerformAccessibilityMessageActions = canPerformAccessibilityMessageActions self.displayUndo = displayUndo self.isAnimatingMessage = isAnimatingMessage self.getMessageTransitionNode = getMessageTransitionNode diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift index 5365b2f8942..a5a2d832b0f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift @@ -80,6 +80,9 @@ private final class VisualMediaItemNode: ASDisplayNode { self.mediaBadgeNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: 50.0, height: 50.0)) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = [.button, .image] self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.imageNode) @@ -299,6 +302,17 @@ private final class VisualMediaItemNode: ASDisplayNode { self.mediaBadgeNode.isHidden = true } self.item = (item, media, size, mediaDimensions) + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + if media is TelegramMediaImage { + self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Photo + } else { + self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Video + } + self.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text + self.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) + ] self.updateHiddenMedia() } @@ -343,6 +357,11 @@ private final class VisualMediaItemNode: ASDisplayNode { if let selectedIds = self.interaction.selectedMessageIds { let selected = selectedIds.contains(item.message.id) + if selected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } if let selectionNode = self.selectionNode { selectionNode.updateSelected(selected, animated: animated) @@ -367,6 +386,7 @@ private final class VisualMediaItemNode: ASDisplayNode { } } } else { + self.accessibilityTraits.remove(.selected) if let selectionNode = self.selectionNode { self.selectionNode = nil if animated { @@ -380,6 +400,26 @@ private final class VisualMediaItemNode: ASDisplayNode { } } } + + override func accessibilityActivate() -> Bool { + guard let item = self.item?.0 else { + return false + } + if let selectedMessageIds = self.interaction.selectedMessageIds { + self.interaction.toggleSelection(item.message.id, !selectedMessageIds.contains(item.message.id)) + } else { + self.interaction.openMessage(item.message) + } + return true + } + + @objc private func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + guard let item = self.item?.0 else { + return false + } + self.interaction.openMessageContextActions(item.message, self.containerNode, self.containerNode.bounds, nil) + return true + } func transitionNode() -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { let imageNode = self.imageNode diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift index 9d253d3f71c..6834ba5c5e2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderMultiLineTextFieldNode.swift @@ -53,6 +53,7 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true self.clearButtonNode.isAccessibilityElement = false + self.clearButtonNode.accessibilityTraits = .button self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false @@ -130,7 +131,9 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT let attributedPlaceholderText = NSAttributedString(string: placeholder, font: titleFont, textColor: presentationData.theme.list.itemPlaceholderTextColor) if self.textNode.attributedPlaceholderText == nil || !self.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) { self.textNode.attributedPlaceholderText = attributedPlaceholderText + self.textNode.textView.accessibilityHint = attributedPlaceholderText.string } + self.clearButtonNode.accessibilityLabel = presentationData.strings.WebSearch_RecentSectionClear if let updateText = updateText { let attributedText = NSAttributedString(string: updateText, font: titleFont, textColor: presentationData.theme.list.itemPrimaryTextColor) @@ -184,7 +187,7 @@ final class PeerInfoHeaderMultiLineTextFieldNode: ASDisplayNode, PeerInfoHeaderT let isHidden = !self.textNode.isFirstResponder() || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden - self.clearButtonNode.isAccessibilityElement = isHidden + self.clearButtonNode.isAccessibilityElement = !isHidden } func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift index 762bb6b22a7..7c1bbb7af30 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderSingleLineTextFieldNode.swift @@ -36,6 +36,7 @@ final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeader self.clearButtonNode = HighlightableButtonNode() self.clearButtonNode.isHidden = true self.clearButtonNode.isAccessibilityElement = false + self.clearButtonNode.accessibilityTraits = .button self.topSeparator = ASDisplayNode() @@ -84,12 +85,13 @@ final class PeerInfoHeaderSingleLineTextFieldNode: ASDisplayNode, PeerInfoHeader let isHidden = !self.textNode.textField.isFirstResponder || self.text.isEmpty self.clearIconNode.isHidden = isHidden self.clearButtonNode.isHidden = isHidden - self.clearButtonNode.isAccessibilityElement = isHidden + self.clearButtonNode.isAccessibilityElement = !isHidden } func update(width: CGFloat, safeInset: CGFloat, isSettings: Bool, hasPrevious: Bool, hasNext: Bool, placeholder: String, isEnabled: Bool, presentationData: PresentationData, updateText: String?) -> CGFloat { let titleFont = Font.regular(presentationData.listsFontSize.itemListBaseFontSize) self.textNode.textField.font = titleFont + self.clearButtonNode.accessibilityLabel = presentationData.strings.WebSearch_RecentSectionClear if self.theme !== presentationData.theme { self.theme = presentationData.theme diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 23695c21df0..f40282c31c1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -5820,7 +5820,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, queue: .mainQueue()) self.richTextCheckboxDebounceTimers[messageId] = timer timer.start() - }, openStarsPurchase: { [weak self] amount in + }, accessibilityForwardMessage: { [weak self] message in + self?.interfaceInteraction?.forwardMessages([message]) + }, accessibilityDeleteMessage: { [weak self] message in + self?.interfaceInteraction?.deleteMessages([message], nil, { _ in }) + }, canPerformAccessibilityMessageActions: true, openStarsPurchase: { [weak self] amount in self?.interfaceInteraction?.openStarsPurchase(amount) }, openRankInfo: { [weak self] peer, role, rank in guard let self, let chatPeer = self.presentationInterfaceState.renderedPeer?.peer else { From 452baa37305e4e6397f81b4789e1bdf42fb42d34 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:04:28 +0500 Subject: [PATCH 03/18] Improve VoiceOver accessibility for modal interfaces Add modal focus containment and restoration for alerts and action sheets. Improve Archive Info semantics, Dynamic Type, Reduce Motion and Reduce Transparency support, initial alert focus, and VoiceOver escape handling. --- .../Source/ActionSheetController.swift | 44 +++++++++++++- .../Source/ActionSheetControllerNode.swift | 19 ++++++ .../Display/Source/AlertContentNode.swift | 4 ++ .../Display/Source/AlertController.swift | 39 +++++++++++- .../Display/Source/AlertControllerNode.swift | 21 +++++-- .../Display/Source/TextAlertController.swift | 11 +++- .../Sources/ArchiveInfoContentComponent.swift | 59 +++++++++++++++---- .../Sources/ArchiveInfoScreen.swift | 42 ++++++++++++- 8 files changed, 218 insertions(+), 21 deletions(-) diff --git a/submodules/Display/Source/ActionSheetController.swift b/submodules/Display/Source/ActionSheetController.swift index 8727b6e887b..9b808d284ea 100644 --- a/submodules/Display/Source/ActionSheetController.swift +++ b/submodules/Display/Source/ActionSheetController.swift @@ -22,6 +22,7 @@ open class ActionSheetController: ViewController, PresentableController, Standal private var groups: [ActionSheetItemGroup] = [] private var isDismissed: Bool = false + private weak var previousAccessibilityFocus: AnyObject? public var dismissed: ((Bool) -> Void)? @@ -48,13 +49,27 @@ open class ActionSheetController: ViewController, PresentableController, Standal } } + open override func accessibilityPerformEscape() -> Bool { + if self.isDismissed { + return false + } + self.isDismissed = true + self.actionSheetNode.animateOut(cancelled: true) + return true + } + open override func loadDisplayNode() { self.displayNode = ActionSheetControllerNode(theme: self.theme, allowInputInset: self.allowInputInset) self.displayNodeDidLoad() self.actionSheetNode.dismiss = { [weak self] cancelled in - self?.dismissed?(cancelled) - self?.presentingViewController?.dismiss(animated: false) + guard let self else { + return + } + self.dismissed?(cancelled) + self.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + }) } self.actionSheetNode.setGroups(self.groups) @@ -72,8 +87,31 @@ open class ActionSheetController: ViewController, PresentableController, Standal self.viewDidAppear(completion: {}) } + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? + } + } + public func viewDidAppear(completion: @escaping () -> Void) { - self.actionSheetNode.animateIn(completion: completion) + self.actionSheetNode.animateIn { [weak self] in + completion() + + guard let self else { + return + } + UIAccessibility.post(notification: .screenChanged, argument: self.actionSheetNode.view) + } + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) } public func setItemGroups(_ groups: [ActionSheetItemGroup]) { diff --git a/submodules/Display/Source/ActionSheetControllerNode.swift b/submodules/Display/Source/ActionSheetControllerNode.swift index fc4d61c7528..f50a3a0e206 100644 --- a/submodules/Display/Source/ActionSheetControllerNode.swift +++ b/submodules/Display/Source/ActionSheetControllerNode.swift @@ -63,6 +63,14 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { self.itemGroupsContainerNode.isUserInteractionEnabled = false super.init() + + self.view.accessibilityViewIsModal = true + self.dismissTapView.isAccessibilityElement = false + self.dismissTapView.accessibilityElementsHidden = true + self.leftDimView.accessibilityElementsHidden = true + self.rightDimView.accessibilityElementsHidden = true + self.topDimView.accessibilityElementsHidden = true + self.bottomDimView.accessibilityElementsHidden = true self.scrollView.delegate = self.wrappedScrollViewDelegate @@ -150,6 +158,12 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { func animateIn(completion: @escaping () -> Void) { + if UIAccessibility.isReduceMotionEnabled { + self.itemGroupsContainerNode.isUserInteractionEnabled = true + completion() + return + } + let tempDimView = UIView() tempDimView.backgroundColor = self.theme.dimColor tempDimView.frame = self.bounds.offsetBy(dx: 0.0, dy: -self.bounds.size.height) @@ -172,6 +186,11 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { } func animateOut(cancelled: Bool) { + if UIAccessibility.isReduceMotionEnabled { + self.dismiss(cancelled) + return + } + let tempDimView = UIView() tempDimView.backgroundColor = self.theme.dimColor tempDimView.frame = self.bounds.offsetBy(dx: 0.0, dy: -self.bounds.size.height) diff --git a/submodules/Display/Source/AlertContentNode.swift b/submodules/Display/Source/AlertContentNode.swift index b95b6e38ad1..ce205a3eae0 100644 --- a/submodules/Display/Source/AlertContentNode.swift +++ b/submodules/Display/Source/AlertContentNode.swift @@ -4,6 +4,10 @@ import AsyncDisplayKit open class AlertContentNode: ASDisplayNode { open var requestLayout: ((ContainedViewLayoutTransition) -> Void)? + + open var accessibilityInitialFocusNode: ASDisplayNode? { + return nil + } open var dismissOnOutsideTap: Bool { return true diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index 19e64a13fd0..0c59f97bb96 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -87,6 +87,7 @@ open class AlertController: ViewController, StandalonePresentableController, Key private let allowInputInset: Bool private weak var existingAlertController: AlertController? + private weak var previousAccessibilityFocus: AnyObject? public var willDismiss: (() -> Void)? public var dismissed: ((Bool) -> Void)? @@ -130,10 +131,22 @@ open class AlertController: ViewController, StandalonePresentableController, Key override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + self.existingAlertController?.previousAccessibilityFocus = nil self.existingAlertController?.dismiss(completion: nil) self.existingAlertController = nil self.controllerNode.animateIn() + UIAccessibility.post(notification: .screenChanged, argument: self.contentNode.accessibilityInitialFocusNode?.view ?? self.contentNode.view) + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let existingAlertController = self.existingAlertController { + self.previousAccessibilityFocus = existingAlertController.previousAccessibilityFocus + } else if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? + } } override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -147,7 +160,10 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.isDismissed = true self.dismissed?(false) } - self.presentingViewController?.dismiss(animated: false, completion: completion) + self.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) } open func dismissAnimated() { @@ -156,6 +172,27 @@ open class AlertController: ViewController, StandalonePresentableController, Key } } + override open func accessibilityPerformEscape() -> Bool { + guard self.contentNode.dismissOnOutsideTap, !self.isDismissed else { + return false + } + self.willDismiss?() + self.controllerNode.animateOut { [weak self] in + self?.dismissed?(true) + self?.isDismissed = true + self?.dismiss() + } + return true + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) + } + public var keyShortcuts: [KeyShortcut] { return [ KeyShortcut( diff --git a/submodules/Display/Source/AlertControllerNode.swift b/submodules/Display/Source/AlertControllerNode.swift index 8f4970eaef1..3dfce2befd9 100644 --- a/submodules/Display/Source/AlertControllerNode.swift +++ b/submodules/Display/Source/AlertControllerNode.swift @@ -50,18 +50,22 @@ final class AlertControllerNode: ASDisplayNode { self.containerNode.layer.masksToBounds = true self.backgroundNode = ASDisplayNode() - self.backgroundNode.backgroundColor = theme.backgroundColor + self.backgroundNode.backgroundColor = UIAccessibility.isReduceTransparencyEnabled ? theme.backgroundColor.withAlphaComponent(1.0) : theme.backgroundColor // self.effectNode = ASDisplayNode(viewBlock: { // return UIVisualEffectView(effect: UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) // }) - self.effectView = UIVisualEffectView(effect: UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) + self.effectView = UIVisualEffectView(effect: UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark)) self.contentNode = contentNode super.init() + self.view.accessibilityViewIsModal = true + self.dimContainerView.isAccessibilityElement = false + self.dimContainerView.accessibilityElementsHidden = true + self.view.addSubview(self.dimContainerView) self.dimContainerView.addSubview(self.centerDimView) self.dimContainerView.addSubview(self.topDimView) @@ -104,12 +108,16 @@ final class AlertControllerNode: ASDisplayNode { } func updateTheme(_ theme: AlertControllerTheme) { - self.effectView.effect = UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark) - self.backgroundNode.backgroundColor = theme.backgroundColor + self.effectView.effect = UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: theme.backgroundType == .light ? .light : .dark) + self.backgroundNode.backgroundColor = UIAccessibility.isReduceTransparencyEnabled ? theme.backgroundColor.withAlphaComponent(1.0) : theme.backgroundColor self.contentNode.updateTheme(theme) } func animateIn() { + if UIAccessibility.isReduceMotionEnabled { + return + } + if let previousNode = self.existingAlertControllerNode { let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring) @@ -146,6 +154,11 @@ final class AlertControllerNode: ASDisplayNode { } func animateOut(completion: @escaping () -> Void) { + if UIAccessibility.isReduceMotionEnabled { + completion() + return + } + self.containerNode.layer.removeAllAnimations() //self.centerDimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) //self.centerDimView.image = nil diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index 2224e1a4d0c..b19cfdd73de 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -134,7 +134,11 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { self.setAttributedTitle(attributedString, for: []) self.accessibilityLabel = self.action.title - self.accessibilityTraits = [.button] + var accessibilityTraits: UIAccessibilityTraits = [.button] + if !self.actionEnabled { + accessibilityTraits.insert(.notEnabled) + } + self.accessibilityTraits = accessibilityTraits } @objc func pressed() { @@ -171,6 +175,10 @@ public final class TextAlertContentNode: AlertContentNode { return self._dismissOnOutsideTap } + override public var accessibilityInitialFocusNode: ASDisplayNode? { + return self.titleNode ?? self.textNode + } + private var highlightedItemIndex: Int? = nil public var textAttributeAction: (NSAttributedString.Key, (Any) -> Void)? { @@ -209,6 +217,7 @@ public final class TextAlertContentNode: AlertContentNode { titleNode.truncationType = .end titleNode.isAccessibilityElement = true titleNode.accessibilityLabel = title.string + titleNode.accessibilityTraits = [.header] self.titleNode = titleNode } else { self.titleNode = nil diff --git a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift index fb4c0c5aa38..fcfda7714b5 100644 --- a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift +++ b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift @@ -56,6 +56,7 @@ public final class ArchiveInfoContentComponent: Component { private let title = ComponentView() private let mainText = ComponentView() + private let settingsButton: UIButton private var chevronImage: UIImage? @@ -68,6 +69,7 @@ public final class ArchiveInfoContentComponent: Component { self.iconBackground = UIImageView() self.iconForeground = UIImageView() + self.settingsButton = UIButton(type: .custom) super.init(frame: frame) @@ -86,6 +88,15 @@ public final class ArchiveInfoContentComponent: Component { self.scrollView.addSubview(self.iconBackground) self.scrollView.addSubview(self.iconForeground) + self.scrollView.addSubview(self.settingsButton) + + self.iconBackground.isAccessibilityElement = false + self.iconBackground.accessibilityElementsHidden = true + self.iconForeground.isAccessibilityElement = false + self.iconForeground.accessibilityElementsHidden = true + + self.settingsButton.accessibilityTraits = [.button, .link] + self.settingsButton.addTarget(self, action: #selector(self.openSettingsPressed), for: .touchUpInside) } required init(coder: NSCoder) { @@ -105,6 +116,8 @@ public final class ArchiveInfoContentComponent: Component { let sideInset: CGFloat = 16.0 let sideIconInset: CGFloat = 40.0 + let titleFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: 19.0) + let bodyFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: 15.0) var contentHeight: CGFloat = 0.0 @@ -128,7 +141,7 @@ public final class ArchiveInfoContentComponent: Component { contentHeight += 15.0 let titleString = NSMutableAttributedString() - titleString.append(NSAttributedString(string: component.strings.ArchiveInfo_Title, font: Font.semibold(19.0), textColor: component.theme.list.itemPrimaryTextColor)) + titleString.append(NSAttributedString(string: component.strings.ArchiveInfo_Title, font: Font.semibold(titleFontSize), textColor: component.theme.list.itemPrimaryTextColor)) let imageAttachment = NSTextAttachment() imageAttachment.image = self.iconBackground.image titleString.append(NSAttributedString(attachment: imageAttachment)) @@ -137,7 +150,8 @@ public final class ArchiveInfoContentComponent: Component { transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(titleString), - maximumNumberOfLines: 1 + horizontalAlignment: .center, + maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) @@ -146,6 +160,9 @@ public final class ArchiveInfoContentComponent: Component { if titleView.superview == nil { self.scrollView.addSubview(titleView) } + titleView.isAccessibilityElement = true + titleView.accessibilityLabel = component.strings.ArchiveInfo_Title + titleView.accessibilityTraits.insert(.header) transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)) } contentHeight += titleSize.height @@ -161,15 +178,15 @@ public final class ArchiveInfoContentComponent: Component { let mainText = NSMutableAttributedString() mainText.append(parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes( body: MarkdownAttributeSet( - font: Font.regular(15.0), + font: Font.regular(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor ), bold: MarkdownAttributeSet( - font: Font.semibold(15.0), + font: Font.semibold(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor ), link: MarkdownAttributeSet( - font: Font.regular(15.0), + font: Font.regular(bodyFontSize), textColor: component.theme.list.itemAccentColor, additionalAttributes: [:] ), @@ -214,7 +231,12 @@ public final class ArchiveInfoContentComponent: Component { if mainTextView.superview == nil { self.scrollView.addSubview(mainTextView) } - transition.setFrame(view: mainTextView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize)) + mainTextView.accessibilityElementsHidden = true + let mainTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize) + transition.setFrame(view: mainTextView, frame: mainTextFrame) + self.settingsButton.accessibilityLabel = mainText.string + transition.setFrame(view: self.settingsButton, frame: mainTextFrame) + self.scrollView.bringSubviewToFront(self.settingsButton) } contentHeight += mainTextSize.height @@ -269,7 +291,7 @@ public final class ArchiveInfoContentComponent: Component { let titleSize = item.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(15.0), textColor: component.theme.list.itemPrimaryTextColor)), + text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(bodyFontSize), textColor: component.theme.list.itemPrimaryTextColor)), maximumNumberOfLines: 0, lineSpacing: 0.2 )), @@ -279,7 +301,7 @@ public final class ArchiveInfoContentComponent: Component { let textSize = item.text.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: itemDesc.text, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)), + text: .plain(NSAttributedString(string: itemDesc.text, font: Font.regular(bodyFontSize), textColor: component.theme.list.itemSecondaryTextColor)), maximumNumberOfLines: 0, lineSpacing: 0.18 )), @@ -291,6 +313,8 @@ public final class ArchiveInfoContentComponent: Component { if iconView.superview == nil { self.scrollView.addSubview(iconView) } + iconView.isAccessibilityElement = false + iconView.accessibilityElementsHidden = true transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 4.0), size: iconSize)) } @@ -298,7 +322,10 @@ public final class ArchiveInfoContentComponent: Component { if titleView.superview == nil { self.scrollView.addSubview(titleView) } - transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: titleSize)) + titleView.isAccessibilityElement = true + titleView.accessibilityLabel = "\(itemDesc.title). \(itemDesc.text)" + let titleFrame = CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: titleSize) + transition.setFrame(view: titleView, frame: titleFrame) } contentHeight += titleSize.height contentHeight += 2.0 @@ -307,7 +334,15 @@ public final class ArchiveInfoContentComponent: Component { if textView.superview == nil { self.scrollView.addSubview(textView) } - transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: textSize)) + textView.accessibilityElementsHidden = true + let textFrame = CGRect(origin: CGPoint(x: sideInset + sideIconInset, y: contentHeight), size: textSize) + transition.setFrame(view: textView, frame: textFrame) + item.title.view?.accessibilityFrameInContainerSpace = CGRect( + x: sideInset, + y: textFrame.minY - titleSize.height - 2.0, + width: availableSize.width - sideInset * 2.0, + height: titleSize.height + 2.0 + textSize.height + ) } contentHeight += textSize.height } @@ -321,6 +356,10 @@ public final class ArchiveInfoContentComponent: Component { return size } + + @objc private func openSettingsPressed() { + self.component?.openSettings() + } } public func makeView() -> View { diff --git a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift index 4c79c49686b..a8252f59bb9 100644 --- a/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift @@ -36,6 +36,7 @@ private final class ArchiveInfoSheetContentComponent: Component { final class View: UIView { private let content = ComponentView() private let button = ComponentView() + private let accessibilityButton: UIButton fileprivate let playButtonAnimation = ActionSlot() private var didPlayAnimation = false @@ -43,7 +44,15 @@ private final class ArchiveInfoSheetContentComponent: Component { private var component: ArchiveInfoSheetContentComponent? override init(frame: CGRect) { + self.accessibilityButton = UIButton(type: .custom) + super.init(frame: frame) + + self.accessibilityViewIsModal = true + + self.accessibilityButton.accessibilityTraits = .button + self.accessibilityButton.addTarget(self, action: #selector(self.closePressed), for: .touchUpInside) + self.addSubview(self.accessibilityButton) } required init?(coder: NSCoder) { @@ -80,6 +89,9 @@ private final class ArchiveInfoSheetContentComponent: Component { contentHeight += contentSize.height contentHeight += 30.0 + let buttonFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: 17.0) + let buttonHeight = max(52.0, ceil(buttonFontSize + 35.0)) + var buttonTitle: [AnyComponentWithIdentity] = [] buttonTitle.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieComponent( content: LottieComponent.AppBundleContent(name: "anim_ok"), @@ -92,11 +104,12 @@ private final class ArchiveInfoSheetContentComponent: Component { text: environment.strings.ArchiveInfo_CloseAction, badge: 0, textColor: environment.theme.list.itemCheckColors.foregroundColor, + fontSize: buttonFontSize, badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, badgeForeground: environment.theme.list.itemCheckColors.fillColor )))) - let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0) + let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: buttonHeight, sideInset: 30.0) let buttonSize = self.button.update( transition: transition, component: AnyComponent(ButtonComponent( @@ -119,7 +132,7 @@ private final class ArchiveInfoSheetContentComponent: Component { } )), environment: {}, - containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0) + containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: buttonHeight) ) let buttonFrame = CGRect(origin: CGPoint(x: buttonInsets.left, y: contentHeight), size: buttonSize) if let buttonView = self.button.view { @@ -130,11 +143,18 @@ private final class ArchiveInfoSheetContentComponent: Component { } transition.setFrame(view: buttonView, frame: buttonFrame) } + self.accessibilityButton.accessibilityLabel = environment.strings.ArchiveInfo_CloseAction + transition.setFrame(view: self.accessibilityButton, frame: buttonFrame) + self.bringSubviewToFront(self.accessibilityButton) contentHeight += buttonSize.height contentHeight += buttonInsets.bottom return CGSize(width: availableSize.width, height: contentHeight) } + + @objc private func closePressed() { + self.component?.dismiss() + } } func makeView() -> View { @@ -260,6 +280,7 @@ private final class ArchiveInfoScreenComponent: Component { if let sheetView = self.sheet.view { if sheetView.superview == nil { self.addSubview(sheetView) + sheetView.accessibilityViewIsModal = true } transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize)) } @@ -278,7 +299,12 @@ private final class ArchiveInfoScreenComponent: Component { } public class ArchiveInfoScreen: ViewControllerComponentContainer { + private let buttonAction: (() -> Void)? + private var isDismissingFromAccessibility = false + public init(context: AccountContext, settings: GlobalPrivacySettings, buttonAction: (() -> Void)? = nil) { + self.buttonAction = buttonAction + super.init(context: context, component: ArchiveInfoScreenComponent( context: context, settings: settings, @@ -305,5 +331,17 @@ public class ArchiveInfoScreen: ViewControllerComponentContainer { super.viewDidAppear(animated) self.view.disablesInteractiveModalDismiss = true + UIAccessibility.post(notification: .screenChanged, argument: self.view) + } + + override public func accessibilityPerformEscape() -> Bool { + if self.isDismissingFromAccessibility { + return false + } + self.isDismissingFromAccessibility = true + self.dismiss(completion: { [buttonAction = self.buttonAction] in + buttonAction?() + }) + return true } } From 859180104972501ec9c550664356910f5063f5f5 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:57:37 +0500 Subject: [PATCH 04/18] Improve Dynamic Type support for accessible modals Scale and reflow action sheet and text alert content for accessibility text sizes. Update modal layouts when accessibility settings change, improve deterministic focus targeting, and respect Reduce Motion and Reduce Transparency. --- submodules/Display/Source/Accessibility.swift | 26 +++++ .../Source/ActionSheetButtonItem.swift | 18 ++-- .../Source/ActionSheetCheckboxItem.swift | 51 +++++++--- .../Source/ActionSheetController.swift | 27 ++++- .../Source/ActionSheetControllerNode.swift | 4 + .../Source/ActionSheetItemGroupNode.swift | 5 +- .../Source/ActionSheetSwitchItem.swift | 19 ++-- .../Display/Source/ActionSheetTextItem.swift | 5 +- .../Display/Source/AlertContentNode.swift | 4 + .../Display/Source/AlertController.swift | 21 ++++ submodules/Display/Source/ListView.swift | 12 --- .../Display/Source/TextAlertController.swift | 99 ++++++++++++------- 12 files changed, 204 insertions(+), 87 deletions(-) diff --git a/submodules/Display/Source/Accessibility.swift b/submodules/Display/Source/Accessibility.swift index 36be42c7ee8..86093e4c3fe 100644 --- a/submodules/Display/Source/Accessibility.swift +++ b/submodules/Display/Source/Accessibility.swift @@ -20,6 +20,32 @@ public func addAccessibilityChildren(of node: ASDisplayNode, container: Any, to } } +public func firstAccessibilityElement(in view: UIView) -> Any? { + guard !view.isHidden, view.alpha > 0.01, !view.accessibilityElementsHidden else { + return nil + } + if view.isAccessibilityElement { + return view + } + if let accessibilityElements = view.accessibilityElements { + for element in accessibilityElements { + if let elementView = element as? UIView { + if let result = firstAccessibilityElement(in: elementView) { + return result + } + } else { + return element + } + } + } + for subview in view.subviews { + if let result = firstAccessibilityElement(in: subview) { + return result + } + } + return nil +} + public func smartInvertColorsEnabled() -> Bool { if #available(iOSApplicationExtension 11.0, iOS 11.0, *), UIAccessibility.isInvertColorsEnabled { return true diff --git a/submodules/Display/Source/ActionSheetButtonItem.swift b/submodules/Display/Source/ActionSheetButtonItem.swift index 7a37d67160c..9211d152870 100644 --- a/submodules/Display/Source/ActionSheetButtonItem.swift +++ b/submodules/Display/Source/ActionSheetButtonItem.swift @@ -48,9 +48,6 @@ public class ActionSheetButtonItem: ActionSheetItem { public class ActionSheetButtonNode: ActionSheetItemNode { private let theme: ActionSheetControllerTheme - private let defaultFont: UIFont - private let boldFont: UIFont - private var item: ActionSheetButtonItem? private let button: HighlightTrackingButton @@ -61,16 +58,13 @@ public class ActionSheetButtonNode: ActionSheetItemNode { override public init(theme: ActionSheetControllerTheme) { self.theme = theme - - self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) - self.boldFont = Font.medium(floor(theme.baseFontSize * 20.0 / 17.0)) - + self.button = HighlightTrackingButton() self.button.isAccessibilityElement = false self.label = ImmediateTextNode() self.label.isUserInteractionEnabled = false - self.label.maximumNumberOfLines = 1 + self.label.maximumNumberOfLines = 0 self.label.displaysAsynchronously = false self.label.truncationType = .end @@ -146,9 +140,9 @@ public class ActionSheetButtonNode: ActionSheetItemNode { } switch item.font { case .default: - textFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + textFont = Font.regular(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) case .bold: - textFont = Font.medium(floor(theme.baseFontSize * 20.0 / 17.0)) + textFont = Font.medium(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) } self.label.attributedText = NSAttributedString(string: item.title, font: textFont, textColor: textColor) self.label.isAccessibilityElement = false @@ -165,11 +159,11 @@ public class ActionSheetButtonNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) + let labelSize = self.label.updateLayout(CGSize(width: max(1.0, constrainedSize.width - 32.0), height: constrainedSize.height)) + let size = CGSize(width: constrainedSize.width, height: max(57.0, labelSize.height + 28.0)) self.button.frame = CGRect(origin: CGPoint(), size: size) - let labelSize = self.label.updateLayout(CGSize(width: max(1.0, size.width - 10.0), height: size.height)) self.label.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - labelSize.width) / 2.0), y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/Display/Source/ActionSheetCheckboxItem.swift b/submodules/Display/Source/ActionSheetCheckboxItem.swift index ec1828d03e0..50eba712dc2 100644 --- a/submodules/Display/Source/ActionSheetCheckboxItem.swift +++ b/submodules/Display/Source/ActionSheetCheckboxItem.swift @@ -40,11 +40,10 @@ public class ActionSheetCheckboxItem: ActionSheetItem { } public class ActionSheetCheckboxItemNode: ActionSheetItemNode { - private let defaultFont: UIFont - private let theme: ActionSheetControllerTheme private var item: ActionSheetCheckboxItem? + private var usesVerticalTextLayout = false private let button: HighlightTrackingButton private let titleNode: ImmediateTextNode @@ -55,19 +54,18 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { override public init(theme: ActionSheetControllerTheme) { self.theme = theme - self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) self.button = HighlightTrackingButton() self.button.isAccessibilityElement = false self.titleNode = ImmediateTextNode() - self.titleNode.maximumNumberOfLines = 1 + self.titleNode.maximumNumberOfLines = 0 self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false self.titleNode.isAccessibilityElement = false self.labelNode = ImmediateTextNode() - self.labelNode.maximumNumberOfLines = 1 + self.labelNode.maximumNumberOfLines = 0 self.labelNode.isUserInteractionEnabled = false self.labelNode.displaysAsynchronously = false self.labelNode.isAccessibilityElement = false @@ -120,13 +118,17 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { func setItem(_ item: ActionSheetCheckboxItem) { self.item = item - let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + let baseFontSize = floor(theme.baseFontSize * 20.0 / 17.0) + let scaledFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: baseFontSize) + let defaultFont = Font.regular(scaledFontSize) + self.usesVerticalTextLayout = !item.label.isEmpty && scaledFontSize > baseFontSize * 1.2 self.titleNode.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: self.theme.primaryTextColor) self.labelNode.attributedText = NSAttributedString(string: item.label, font: defaultFont, textColor: self.theme.secondaryTextColor) self.checkNode.isHidden = !item.value self.accessibilityArea.accessibilityLabel = item.title + self.accessibilityArea.accessibilityValue = item.label.isEmpty ? nil : item.label var accessibilityTraits: UIAccessibilityTraits = [.button] if item.value { @@ -136,21 +138,40 @@ public class ActionSheetCheckboxItemNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) - - self.button.frame = CGRect(origin: CGPoint(), size: size) - var titleOrigin: CGFloat = 50.0 var checkOrigin: CGFloat = 27.0 + var rightInset: CGFloat = 15.0 if let item = self.item, item.style == .alignRight { titleOrigin = 24.0 - checkOrigin = size.width - 22.0 + checkOrigin = constrainedSize.width - 22.0 + rightInset = 50.0 + } + + let contentWidth = max(1.0, constrainedSize.width - titleOrigin - rightInset) + let labelSize: CGSize + let titleSize: CGSize + let textHeight: CGFloat + if self.usesVerticalTextLayout { + titleSize = self.titleNode.updateLayout(CGSize(width: contentWidth, height: constrainedSize.height)) + labelSize = self.labelNode.updateLayout(CGSize(width: contentWidth, height: constrainedSize.height)) + textHeight = titleSize.height + 4.0 + labelSize.height + } else { + labelSize = self.labelNode.updateLayout(CGSize(width: contentWidth * 0.45, height: constrainedSize.height)) + titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, contentWidth - labelSize.width - 8.0), height: constrainedSize.height)) + textHeight = max(titleSize.height, labelSize.height) } + let size = CGSize(width: constrainedSize.width, height: max(57.0, textHeight + 28.0)) + + self.button.frame = CGRect(origin: CGPoint(), size: size) - let labelSize = self.labelNode.updateLayout(CGSize(width: size.width - 44.0 - 15.0 - 8.0, height: size.height)) - let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 44.0 - labelSize.width - 15.0 - 8.0, height: size.height)) - self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) - self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) + if self.usesVerticalTextLayout { + let textOrigin = floorToScreenPixels((size.height - textHeight) / 2.0) + self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: textOrigin), size: titleSize) + self.labelNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: textOrigin + titleSize.height + 4.0), size: labelSize) + } else { + self.titleNode.frame = CGRect(origin: CGPoint(x: titleOrigin, y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize) + self.labelNode.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) + } if let image = self.checkNode.image { self.checkNode.frame = CGRect(origin: CGPoint(x: floor(checkOrigin - (image.size.width / 2.0)), y: floor((size.height - image.size.height) / 2.0)), size: image.size) diff --git a/submodules/Display/Source/ActionSheetController.swift b/submodules/Display/Source/ActionSheetController.swift index 9b808d284ea..91821ca42cf 100644 --- a/submodules/Display/Source/ActionSheetController.swift +++ b/submodules/Display/Source/ActionSheetController.swift @@ -23,6 +23,8 @@ open class ActionSheetController: ViewController, PresentableController, Standal private var isDismissed: Bool = false private weak var previousAccessibilityFocus: AnyObject? + private var contentSizeCategoryObserver: NSObjectProtocol? + private var reduceTransparencyObserver: NSObjectProtocol? public var dismissed: ((Bool) -> Void)? @@ -36,11 +38,34 @@ open class ActionSheetController: ViewController, PresentableController, Standal self.statusBar.statusBarStyle = .Ignore self.blocksBackgroundWhenInOverlay = true + + self.contentSizeCategoryObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.actionSheetNode.setGroups(self.groups) + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: self.actionSheetNode.view) ?? self.actionSheetNode.view) + }) + self.reduceTransparencyObserver = NotificationCenter.default.addObserver(forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.actionSheetNode.setGroups(self.groups) + }) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let contentSizeCategoryObserver = self.contentSizeCategoryObserver { + NotificationCenter.default.removeObserver(contentSizeCategoryObserver) + } + if let reduceTransparencyObserver = self.reduceTransparencyObserver { + NotificationCenter.default.removeObserver(reduceTransparencyObserver) + } + } public func dismissAnimated() { if !self.isDismissed { @@ -102,7 +127,7 @@ open class ActionSheetController: ViewController, PresentableController, Standal guard let self else { return } - UIAccessibility.post(notification: .screenChanged, argument: self.actionSheetNode.view) + UIAccessibility.post(notification: .screenChanged, argument: firstAccessibilityElement(in: self.actionSheetNode.view) ?? self.actionSheetNode.view) } } diff --git a/submodules/Display/Source/ActionSheetControllerNode.swift b/submodules/Display/Source/ActionSheetControllerNode.swift index f50a3a0e206..e49a8b3eb96 100644 --- a/submodules/Display/Source/ActionSheetControllerNode.swift +++ b/submodules/Display/Source/ActionSheetControllerNode.swift @@ -254,6 +254,10 @@ final class ActionSheetControllerNode: ASDisplayNode, ASScrollViewDelegate { func setGroups(_ groups: [ActionSheetItemGroup]) { self.itemGroupsContainerNode.setGroups(groups) + + if let validLayout = self.validLayout { + self.containerLayoutUpdated(validLayout, transition: .immediate) + } } func updateItem(groupIndex: Int, itemIndex: Int, _ f: (ActionSheetItem) -> ActionSheetItem) { diff --git a/submodules/Display/Source/ActionSheetItemGroupNode.swift b/submodules/Display/Source/ActionSheetItemGroupNode.swift index af9d79fc277..b4abcbe216f 100644 --- a/submodules/Display/Source/ActionSheetItemGroupNode.swift +++ b/submodules/Display/Source/ActionSheetItemGroupNode.swift @@ -40,8 +40,11 @@ final class ActionSheetItemGroupNode: ASDisplayNode, ASScrollViewDelegate { self.clippingNode = ASDisplayNode() self.clippingNode.clipsToBounds = true self.clippingNode.cornerRadius = 16.0 + if UIAccessibility.isReduceTransparencyEnabled { + self.clippingNode.backgroundColor = self.theme.itemBackgroundColor.withAlphaComponent(1.0) + } - self.backgroundEffectView = UIVisualEffectView(effect: UIBlurEffect(style: self.theme.backgroundType == .light ? .light : .dark)) + self.backgroundEffectView = UIVisualEffectView(effect: UIAccessibility.isReduceTransparencyEnabled ? nil : UIBlurEffect(style: self.theme.backgroundType == .light ? .light : .dark)) self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true diff --git a/submodules/Display/Source/ActionSheetSwitchItem.swift b/submodules/Display/Source/ActionSheetSwitchItem.swift index 75d0651a30f..6763dd1f2d2 100644 --- a/submodules/Display/Source/ActionSheetSwitchItem.swift +++ b/submodules/Display/Source/ActionSheetSwitchItem.swift @@ -49,7 +49,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { self.label = ImmediateTextNode() self.label.isUserInteractionEnabled = false - self.label.maximumNumberOfLines = 1 + self.label.maximumNumberOfLines = 0 self.label.displaysAsynchronously = false self.label.truncationType = .end self.label.isAccessibilityElement = false @@ -86,7 +86,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { func setItem(_ item: ActionSheetSwitchItem) { self.item = item - let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0)) + let defaultFont = Font.regular(UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(theme.baseFontSize * 20.0 / 17.0))) self.label.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: self.theme.primaryTextColor) self.label.isAccessibilityElement = false @@ -103,13 +103,6 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { } public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { - let size = CGSize(width: constrainedSize.width, height: 57.0) - - self.button.frame = CGRect(origin: CGPoint(), size: size) - - let labelSize = self.label.updateLayout(CGSize(width: max(1.0, size.width - 51.0 - 16.0 * 2.0), height: size.height)) - self.label.frame = CGRect(origin: CGPoint(x: 16.0, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) - var switchSize = CGSize(width: 51.0, height: 31.0) if let switchView = self.switchNode.view as? UISwitch { if self.switchNode.bounds.size.width.isZero { @@ -117,6 +110,12 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { } switchSize = switchView.bounds.size } + + let labelSize = self.label.updateLayout(CGSize(width: max(1.0, constrainedSize.width - switchSize.width - 16.0 * 3.0), height: constrainedSize.height)) + let size = CGSize(width: constrainedSize.width, height: max(57.0, labelSize.height + 28.0)) + + self.button.frame = CGRect(origin: CGPoint(), size: size) + self.label.frame = CGRect(origin: CGPoint(x: 16.0, y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize) self.switchNode.frame = CGRect(origin: CGPoint(x: size.width - 16.0 - switchSize.width, y: floor((size.height - switchSize.height) / 2.0)), size: switchSize) self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size) @@ -127,7 +126,7 @@ public class ActionSheetSwitchNode: ActionSheetItemNode { @objc func buttonPressed() { let value = !self.switchNode.isOn - self.switchNode.setOn(value, animated: true) + self.switchNode.setOn(value, animated: !UIAccessibility.isReduceMotionEnabled) self.item?.action(value) } } diff --git a/submodules/Display/Source/ActionSheetTextItem.swift b/submodules/Display/Source/ActionSheetTextItem.swift index 7921a5fe45a..ec7ecbfc750 100644 --- a/submodules/Display/Source/ActionSheetTextItem.swift +++ b/submodules/Display/Source/ActionSheetTextItem.swift @@ -78,8 +78,9 @@ public class ActionSheetTextNode: ActionSheetItemNode { fontSize = 15.0 } - let defaultFont = Font.regular(floor(self.theme.baseFontSize * fontSize / 17.0)) - let boldFont = Font.semibold(floor(self.theme.baseFontSize * fontSize / 17.0)) + let scaledFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: floor(self.theme.baseFontSize * fontSize / 17.0)) + let defaultFont = Font.regular(scaledFontSize) + let boldFont = Font.semibold(scaledFontSize) if item.parseMarkdown { let body = MarkdownAttributeSet(font: defaultFont, textColor: self.theme.secondaryTextColor) diff --git a/submodules/Display/Source/AlertContentNode.swift b/submodules/Display/Source/AlertContentNode.swift index ce205a3eae0..faee3a61f38 100644 --- a/submodules/Display/Source/AlertContentNode.swift +++ b/submodules/Display/Source/AlertContentNode.swift @@ -23,6 +23,10 @@ open class AlertContentNode: ASDisplayNode { } + open func contentSizeCategoryUpdated() { + + } + open func performHighlightedAction() { } diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index 0c59f97bb96..e2306b24d70 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -88,6 +88,8 @@ open class AlertController: ViewController, StandalonePresentableController, Key private weak var existingAlertController: AlertController? private weak var previousAccessibilityFocus: AnyObject? + private var contentSizeCategoryObserver: NSObjectProtocol? + private var reduceTransparencyObserver: NSObjectProtocol? public var willDismiss: (() -> Void)? public var dismissed: ((Bool) -> Void)? @@ -103,11 +105,30 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.blocksBackgroundWhenInOverlay = true self.statusBar.statusBarStyle = .Ignore + + self.contentSizeCategoryObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + self?.contentNode.contentSizeCategoryUpdated() + }) + self.reduceTransparencyObserver = NotificationCenter.default.addObserver(forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil, queue: .main, using: { [weak self] _ in + guard let self, self.isViewLoaded else { + return + } + self.controllerNode.updateTheme(self.theme) + }) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let contentSizeCategoryObserver = self.contentSizeCategoryObserver { + NotificationCenter.default.removeObserver(contentSizeCategoryObserver) + } + if let reduceTransparencyObserver = self.reduceTransparencyObserver { + NotificationCenter.default.removeObserver(reduceTransparencyObserver) + } + } private var isDismissed = false override open func loadDisplayNode() { diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index dd000586849..92ff34d3cc6 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -5493,15 +5493,3 @@ private func containsAccessibilityFocus(_ view: UIView) -> Bool { } 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 -} diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index b19cfdd73de..e1872c4c011 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -46,7 +46,7 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { super.init() - self.titleNode.maximumNumberOfLines = 2 + self.titleNode.maximumNumberOfLines = 0 self.highligthedChanged = { [weak self] value in if let strongSelf = self { @@ -110,7 +110,8 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { } private func updateTitle() { - var font = Font.regular(theme.baseFontSize) + let fontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: theme.baseFontSize) + var font = Font.regular(fontSize) var color: UIColor switch self.action.type { case .defaultAction, .genericAction: @@ -120,7 +121,7 @@ public final class TextAlertContentActionNode: HighlightableButtonNode { } switch self.action.type { case .defaultAction, .defaultDestructiveAction: - font = Font.semibold(theme.baseFontSize) + font = Font.semibold(fontSize) case .destructiveAction, .genericAction: break } @@ -167,6 +168,9 @@ public final class TextAlertContentNode: AlertContentNode { private let actionNodesSeparator: ASDisplayNode private let actionNodes: [TextAlertContentActionNode] private let actionVerticalSeparators: [ASDisplayNode] + private let dynamicTypeTitle: String? + private let dynamicTypeText: String? + private let dynamicTypeParseMarkdown: Bool private var validLayout: CGSize? @@ -204,16 +208,19 @@ public final class TextAlertContentNode: AlertContentNode { } } - public init(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout, dismissOnOutsideTap: Bool, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) { + public init(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout, dismissOnOutsideTap: Bool, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil, dynamicTypeTitle: String? = nil, dynamicTypeText: String? = nil, dynamicTypeParseMarkdown: Bool = false) { self.theme = theme self.actionLayout = actionLayout self._dismissOnOutsideTap = dismissOnOutsideTap + self.dynamicTypeTitle = dynamicTypeTitle + self.dynamicTypeText = dynamicTypeText + self.dynamicTypeParseMarkdown = dynamicTypeParseMarkdown if let title = title { let titleNode = ImmediateTextNode() titleNode.attributedText = title titleNode.displaysAsynchronously = false titleNode.isUserInteractionEnabled = false - titleNode.maximumNumberOfLines = 4 + titleNode.maximumNumberOfLines = 0 titleNode.truncationType = .end titleNode.isAccessibilityElement = true titleNode.accessibilityLabel = title.string @@ -362,6 +369,18 @@ public final class TextAlertContentNode: AlertContentNode { } } + override public func contentSizeCategoryUpdated() { + for actionNode in self.actionNodes { + actionNode.updateTheme(self.theme) + } + if let dynamicTypeText = self.dynamicTypeText { + let attributedStrings = standardTextAlertAttributedStrings(theme: self.theme, title: self.dynamicTypeTitle, text: dynamicTypeText, parseMarkdown: self.dynamicTypeParseMarkdown) + self.titleNode?.attributedText = attributedStrings.title + self.textNode.attributedText = attributedStrings.text + } + self.requestLayout?(.immediate) + } + override public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { self.validLayout = size @@ -376,24 +395,21 @@ public final class TextAlertContentNode: AlertContentNode { } let textSize = self.textNode.updateLayout(CGSize(width: size.width - insets.left - insets.right, height: CGFloat.greatestFiniteMagnitude)) - let actionButtonHeight: CGFloat = 44.0 - - var minActionsWidth: CGFloat = 0.0 - let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) - let actionTitleInsets: CGFloat = 8.0 + let minimumActionButtonHeight: CGFloat = 44.0 + let maxActionWidth: CGFloat = self.actionNodes.isEmpty ? size.width : floor(size.width / CGFloat(self.actionNodes.count)) var effectiveActionLayout = self.actionLayout + if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + effectiveActionLayout = .vertical + } + var actionHeights: [CGFloat] = [] for actionNode in self.actionNodes { - let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight)) - if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 { + let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: max(1.0, maxActionWidth - 16.0), height: CGFloat.greatestFiniteMagnitude)) + let actionHeight = max(minimumActionButtonHeight, actionTitleSize.height + 20.0) + actionHeights.append(actionHeight) + if case .horizontal = effectiveActionLayout, actionHeight > minimumActionButtonHeight { effectiveActionLayout = .vertical } - switch effectiveActionLayout { - case .horizontal: - minActionsWidth += actionTitleSize.width + actionTitleInsets - case .vertical: - minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets) - } } let resultSize: CGSize @@ -401,9 +417,9 @@ public final class TextAlertContentNode: AlertContentNode { var actionsHeight: CGFloat = 0.0 switch effectiveActionLayout { case .horizontal: - actionsHeight = actionButtonHeight + actionsHeight = actionHeights.max() ?? minimumActionButtonHeight case .vertical: - actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count) + actionsHeight = actionHeights.reduce(0.0, +) } let contentWidth = alertWidth - insets.left - insets.right @@ -423,10 +439,11 @@ public final class TextAlertContentNode: AlertContentNode { resultSize = CGSize(width: contentWidth + insets.left + insets.right, height: textSize.height + actionsHeight + insets.top + insets.bottom) } + self.actionNodesSeparator.isHidden = self.actionNodes.isEmpty self.actionNodesSeparator.frame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)) var actionOffset: CGFloat = 0.0 - let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count)) + let actionWidth: CGFloat = self.actionNodes.isEmpty ? resultSize.width : floor(resultSize.width / CGFloat(self.actionNodes.count)) var separatorIndex = -1 var nodeIndex = 0 for actionNode in self.actionNodes { @@ -456,11 +473,12 @@ public final class TextAlertContentNode: AlertContentNode { let actionNodeFrame: CGRect switch effectiveActionLayout { case .horizontal: - actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) + actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionsHeight)) actionOffset += currentActionWidth case .vertical: - actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) - actionOffset += actionButtonHeight + let actionHeight = actionHeights[nodeIndex] + actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionHeight)) + actionOffset += actionHeight } transition.updateFrame(node: actionNode, frame: actionNodeFrame) @@ -472,16 +490,18 @@ public final class TextAlertContentNode: AlertContentNode { } } -public func textAlertController(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { - return AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title, text: text, actions: actions, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction)) -} +private func standardTextAlertAttributedStrings(theme: AlertControllerTheme, title: String?, text: String, parseMarkdown: Bool) -> (title: NSAttributedString?, text: NSAttributedString) { + let titleFontSize = UIFontMetrics(forTextStyle: .headline).scaledValue(for: theme.baseFontSize) + let bodyBaseFontSize = title == nil ? theme.baseFontSize : floor(theme.baseFontSize * 13.0 / 17.0) + let bodyFontSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: bodyBaseFontSize) -public func standardTextAlertController(theme: AlertControllerTheme, title: String?, text: String, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, allowInputInset: Bool = true, parseMarkdown: Bool = false, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { - var dismissImpl: (() -> Void)? + let attributedTitle = title.flatMap { + NSAttributedString(string: $0, font: Font.semibold(titleFontSize), textColor: theme.primaryColor, paragraphAlignment: .center) + } let attributedText: NSAttributedString if parseMarkdown { - let font = title == nil ? Font.semibold(theme.baseFontSize) : Font.regular(floor(theme.baseFontSize * 13.0 / 17.0)) - let boldFont = title == nil ? Font.bold(theme.baseFontSize) : Font.semibold(floor(theme.baseFontSize * 13.0 / 17.0)) + let font = title == nil ? Font.semibold(bodyFontSize) : Font.regular(bodyFontSize) + let boldFont = title == nil ? Font.bold(bodyFontSize) : Font.semibold(bodyFontSize) let body = MarkdownAttributeSet(font: font, textColor: theme.primaryColor) let bold = MarkdownAttributeSet(font: boldFont, textColor: theme.primaryColor) let link = MarkdownAttributeSet(font: font, textColor: theme.accentColor) @@ -489,14 +509,25 @@ public func standardTextAlertController(theme: AlertControllerTheme, title: Stri return ("URL", url) }), textAlignment: .center) } else { - attributedText = NSAttributedString(string: text, font: title == nil ? Font.semibold(theme.baseFontSize) : Font.regular(floor(theme.baseFontSize * 13.0 / 17.0)), textColor: theme.primaryColor, paragraphAlignment: .center) + let font = title == nil ? Font.semibold(bodyFontSize) : Font.regular(bodyFontSize) + attributedText = NSAttributedString(string: text, font: font, textColor: theme.primaryColor, paragraphAlignment: .center) } - let controller = AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title != nil ? NSAttributedString(string: title!, font: Font.semibold(theme.baseFontSize), textColor: theme.primaryColor, paragraphAlignment: .center) : nil, text: attributedText, actions: actions.map { action in + return (attributedTitle, attributedText) +} + +public func textAlertController(theme: AlertControllerTheme, title: NSAttributedString?, text: NSAttributedString, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { + return AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: title, text: text, actions: actions, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction)) +} + +public func standardTextAlertController(theme: AlertControllerTheme, title: String?, text: String, actions: [TextAlertAction], actionLayout: TextAlertContentActionLayout = .horizontal, allowInputInset: Bool = true, parseMarkdown: Bool = false, dismissOnOutsideTap: Bool = true, linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil) -> AlertController { + var dismissImpl: (() -> Void)? + let attributedStrings = standardTextAlertAttributedStrings(theme: theme, title: title, text: text, parseMarkdown: parseMarkdown) + let controller = AlertController(theme: theme, contentNode: TextAlertContentNode(theme: theme, title: attributedStrings.title, text: attributedStrings.text, actions: actions.map { action in return TextAlertAction(type: action.type, title: action.title, action: { dismissImpl?() action.action() }) - }, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction), allowInputInset: allowInputInset) + }, actionLayout: actionLayout, dismissOnOutsideTap: dismissOnOutsideTap, linkAction: linkAction, dynamicTypeTitle: title, dynamicTypeText: text, dynamicTypeParseMarkdown: parseMarkdown), allowInputInset: allowInputInset) dismissImpl = { [weak controller] in controller?.dismissAnimated() } From 512637ef878207115e1af2fca12e2f144861dada Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:13:46 +0500 Subject: [PATCH 05/18] Improve VoiceOver support for alerts and Voice Control - expose alert links and text entities as accessibility actions - improve initial focus and Dynamic Type layouts in text alerts - add accessible URL authorization options and selected states - distinguish interactive messages for Voice Control - expose the actual chat input field as an interactive target --- .../TextAlertWithEntitiesController.swift | 94 ++++++++++--- .../Display/Source/AlertController.swift | 5 +- .../Display/Source/TextAlertController.swift | 54 ++++++++ .../Sources/ChatMessageItemView.swift | 3 + .../Sources/ChatTextInputPanelNode.swift | 19 ++- .../ChatMessageActionUrlAuthController.swift | 126 ++++++++++++++---- 6 files changed, 253 insertions(+), 48 deletions(-) diff --git a/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift b/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift index a9e506b81ce..9818396c02e 100644 --- a/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift +++ b/submodules/AlertUI/Sources/TextAlertWithEntitiesController.swift @@ -7,6 +7,16 @@ import TextNodeWithEntities private let alertWidth: CGFloat = 270.0 +private final class TextAlertWithEntitiesAccessibilityCustomAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + + super.init(name: name, target: target, selector: selector) + } +} + final class TextAlertWithEntitiesContentNode: AlertContentNode { private var theme: AlertControllerTheme private let actionLayout: TextAlertContentActionLayout @@ -25,6 +35,10 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { return self._dismissOnOutsideTap } + override public var accessibilityInitialFocusNode: ASDisplayNode? { + return self.titleNode ?? self.textNode + } + private var highlightedItemIndex: Int? = nil var textAttributeAction: (NSAttributedString.Key, (Any) -> Void)? { @@ -47,6 +61,7 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { self.textNode.highlightAttributeAction = nil self.textNode.tapAttributeAction = nil } + self.updateTextAccessibilityActions() } } @@ -59,10 +74,11 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { titleNode.attributedText = title titleNode.displaysAsynchronously = false titleNode.isUserInteractionEnabled = false - titleNode.maximumNumberOfLines = 4 + titleNode.maximumNumberOfLines = 0 titleNode.truncationType = .end titleNode.isAccessibilityElement = true titleNode.accessibilityLabel = title.string + titleNode.accessibilityTraits = [.header] self.titleNode = titleNode } else { self.titleNode = nil @@ -127,6 +143,38 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { for separatorNode in self.actionVerticalSeparators { self.addSubnode(separatorNode) } + + self.updateTextAccessibilityActions() + } + + private func updateTextAccessibilityActions() { + guard let attributedText = self.textNode.attributedText, let (attribute, textAttributeAction) = self.textAttributeAction, attributedText.length != 0 else { + self.textNode.accessibilityCustomActions = nil + return + } + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + attributedText.enumerateAttribute(attribute, in: NSRange(location: 0, length: attributedText.length), options: []) { [weak self] value, range, _ in + guard let self, let value else { + return + } + let actionName = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !actionName.isEmpty else { + return + } + accessibilityActions.append(TextAlertWithEntitiesAccessibilityCustomAction(name: actionName, target: self, selector: #selector(self.performTextAccessibilityAction(_:)), perform: { + textAttributeAction(value) + })) + } + self.textNode.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions + } + + @objc private func performTextAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? TextAlertWithEntitiesAccessibilityCustomAction else { + return false + } + action.perform() + return true } func setHighlightedItemIndex(_ index: Int?, update: Bool = false) { @@ -199,6 +247,13 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { _ = self.updateLayout(size: size, transition: .immediate) } } + + override func contentSizeCategoryUpdated() { + for actionNode in self.actionNodes { + actionNode.updateTheme(self.theme) + } + self.requestLayout?(.immediate) + } override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { self.validLayout = size @@ -214,24 +269,21 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { } let textSize = self.textNode.updateLayout(CGSize(width: size.width - insets.left - insets.right, height: CGFloat.greatestFiniteMagnitude)) - let actionButtonHeight: CGFloat = 44.0 - - var minActionsWidth: CGFloat = 0.0 - let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) - let actionTitleInsets: CGFloat = 8.0 + let minimumActionButtonHeight: CGFloat = 44.0 + let maxActionWidth: CGFloat = self.actionNodes.isEmpty ? size.width : floor(size.width / CGFloat(self.actionNodes.count)) var effectiveActionLayout = self.actionLayout + if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + effectiveActionLayout = .vertical + } + var actionHeights: [CGFloat] = [] for actionNode in self.actionNodes { - let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight)) - if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 { + let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: max(1.0, maxActionWidth - 16.0), height: CGFloat.greatestFiniteMagnitude)) + let actionHeight = max(minimumActionButtonHeight, actionTitleSize.height + 20.0) + actionHeights.append(actionHeight) + if case .horizontal = effectiveActionLayout, actionHeight > minimumActionButtonHeight { effectiveActionLayout = .vertical } - switch effectiveActionLayout { - case .horizontal: - minActionsWidth += actionTitleSize.width + actionTitleInsets - case .vertical: - minActionsWidth = max(minActionsWidth, actionTitleSize.width + actionTitleInsets) - } } let resultSize: CGSize @@ -239,9 +291,9 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { var actionsHeight: CGFloat = 0.0 switch effectiveActionLayout { case .horizontal: - actionsHeight = actionButtonHeight + actionsHeight = actionHeights.max() ?? minimumActionButtonHeight case .vertical: - actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count) + actionsHeight = actionHeights.reduce(0.0, +) } let contentWidth = alertWidth - insets.left - insets.right @@ -261,10 +313,11 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { resultSize = CGSize(width: contentWidth + insets.left + insets.right, height: textSize.height + actionsHeight + insets.top + insets.bottom) } + self.actionNodesSeparator.isHidden = self.actionNodes.isEmpty self.actionNodesSeparator.frame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel)) var actionOffset: CGFloat = 0.0 - let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count)) + let actionWidth: CGFloat = self.actionNodes.isEmpty ? resultSize.width : floor(resultSize.width / CGFloat(self.actionNodes.count)) var separatorIndex = -1 var nodeIndex = 0 for actionNode in self.actionNodes { @@ -294,11 +347,12 @@ final class TextAlertWithEntitiesContentNode: AlertContentNode { let actionNodeFrame: CGRect switch effectiveActionLayout { case .horizontal: - actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) + actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionsHeight)) actionOffset += currentActionWidth case .vertical: - actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) - actionOffset += actionButtonHeight + let actionHeight = actionHeights[nodeIndex] + actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionHeight)) + actionOffset += actionHeight } transition.updateFrame(node: actionNode, frame: actionNodeFrame) diff --git a/submodules/Display/Source/AlertController.swift b/submodules/Display/Source/AlertController.swift index e2306b24d70..fa7c3571d48 100644 --- a/submodules/Display/Source/AlertController.swift +++ b/submodules/Display/Source/AlertController.swift @@ -157,7 +157,10 @@ open class AlertController: ViewController, StandalonePresentableController, Key self.existingAlertController = nil self.controllerNode.animateIn() - UIAccessibility.post(notification: .screenChanged, argument: self.contentNode.accessibilityInitialFocusNode?.view ?? self.contentNode.view) + UIAccessibility.post( + notification: .screenChanged, + argument: self.contentNode.accessibilityInitialFocusNode?.view ?? firstAccessibilityElement(in: self.contentNode.view) ?? self.contentNode.view + ) } override open func viewWillAppear(_ animated: Bool) { diff --git a/submodules/Display/Source/TextAlertController.swift b/submodules/Display/Source/TextAlertController.swift index e1872c4c011..4b7cc74d0f5 100644 --- a/submodules/Display/Source/TextAlertController.swift +++ b/submodules/Display/Source/TextAlertController.swift @@ -158,6 +158,16 @@ public enum TextAlertContentActionLayout { case vertical } +private final class TextAlertAccessibilityCustomAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + + super.init(name: name, target: target, selector: selector) + } +} + public final class TextAlertContentNode: AlertContentNode { private var theme: AlertControllerTheme private let actionLayout: TextAlertContentActionLayout @@ -171,6 +181,7 @@ public final class TextAlertContentNode: AlertContentNode { private let dynamicTypeTitle: String? private let dynamicTypeText: String? private let dynamicTypeParseMarkdown: Bool + private let linkAction: (([NSAttributedString.Key: Any], Int) -> Void)? private var validLayout: CGSize? @@ -205,6 +216,7 @@ public final class TextAlertContentNode: AlertContentNode { self.textNode.highlightAttributeAction = nil self.textNode.tapAttributeAction = nil } + self.updateTextAccessibilityActions() } } @@ -215,6 +227,7 @@ public final class TextAlertContentNode: AlertContentNode { self.dynamicTypeTitle = dynamicTypeTitle self.dynamicTypeText = dynamicTypeText self.dynamicTypeParseMarkdown = dynamicTypeParseMarkdown + self.linkAction = linkAction if let title = title { let titleNode = ImmediateTextNode() titleNode.attributedText = title @@ -297,6 +310,46 @@ public final class TextAlertContentNode: AlertContentNode { for separatorNode in self.actionVerticalSeparators { self.addSubnode(separatorNode) } + + self.updateTextAccessibilityActions() + } + + private func updateTextAccessibilityActions() { + guard let attributedText = self.textNode.attributedText, attributedText.length != 0 else { + self.textNode.accessibilityCustomActions = nil + return + } + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + attributedText.enumerateAttributes(in: NSRange(location: 0, length: attributedText.length), options: []) { [weak self] attributes, range, _ in + guard let self else { + return + } + let actionName = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !actionName.isEmpty else { + return + } + + if self.textAttributeAction == nil, attributes[NSAttributedString.Key(rawValue: "URL")] != nil, let linkAction = self.linkAction { + accessibilityActions.append(TextAlertAccessibilityCustomAction(name: actionName, target: self, selector: #selector(self.performTextAccessibilityAction(_:)), perform: { + linkAction(attributes, range.location) + })) + } + if let (attribute, textAttributeAction) = self.textAttributeAction, let value = attributes[attribute] { + accessibilityActions.append(TextAlertAccessibilityCustomAction(name: actionName, target: self, selector: #selector(self.performTextAccessibilityAction(_:)), perform: { + textAttributeAction(value) + })) + } + } + self.textNode.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions + } + + @objc private func performTextAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? TextAlertAccessibilityCustomAction else { + return false + } + action.perform() + return true } func setHighlightedItemIndex(_ index: Int?, update: Bool = false) { @@ -377,6 +430,7 @@ public final class TextAlertContentNode: AlertContentNode { let attributedStrings = standardTextAlertAttributedStrings(theme: self.theme, title: self.dynamicTypeTitle, text: dynamicTypeText, parseMarkdown: self.dynamicTypeParseMarkdown) self.titleNode?.attributedText = attributedStrings.title self.textNode.attributedText = attributedStrings.text + self.updateTextAccessibilityActions() } self.requestLayout?(.immediate) } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 9dc4d087af7..71263876310 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -90,6 +90,7 @@ public final class ChatMessageAccessibilityData { public let traits: UIAccessibilityTraits public let customActions: [ChatMessageAccessibilityCustomAction]? public let singleUrl: String? + public let respondsToUserInteraction: Bool public init(item: ChatMessageItem, isSelected: Bool?) { var hint: String? @@ -633,6 +634,7 @@ public final class ChatMessageAccessibilityData { self.traits = traits self.customActions = customActions.isEmpty ? nil : customActions self.singleUrl = singleUrl + self.respondsToUserInteraction = singleUrl != nil || !item.message.media.isEmpty } @objc private func noop() { @@ -724,6 +726,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { accessibilityNode.accessibilityValue = accessibilityData.value accessibilityNode.accessibilityHint = accessibilityData.hint accessibilityNode.accessibilityTraits = accessibilityData.traits + accessibilityNode.view.accessibilityRespondsToUserInteraction = accessibilityData.respondsToUserInteraction if let customActions = accessibilityData.customActions { accessibilityNode.accessibilityCustomActions = customActions.map { action in return ChatMessageAccessibilityCustomAction(name: action.name, target: customActionTarget, selector: customActionSelector, action: action.action) diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 0d0d35b31c4..da7c676ff45 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -71,6 +71,21 @@ public let chatTextInputMinFontSize: CGFloat = 5.0 private let minInputFontSize = chatTextInputMinFontSize +private func accessibilityTextInputView(in view: UIView) -> UIView { + if view is UITextInput { + return view + } + for subview in view.subviews { + if !subview.isHidden && subview.alpha > 0.0 { + let result = accessibilityTextInputView(in: subview) + if result is UITextInput { + return result + } + } + } + return view +} + private func calclulateTextFieldMinHeight(_ presentationInterfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat { var baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) if "".isEmpty { @@ -3201,8 +3216,10 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg richTextInputNode.textContainerInset = textInputViewRealInsets richTextInputNode.textFieldFrame = actualTextFieldFrame richTextInputNode.updateLayout(size: textFieldFrame.size) + let accessibilityInputView = accessibilityTextInputView(in: richTextInputNode.inputView) let accessibilityBounds = richTextInputNode.inputView.bounds.inset(by: richTextInputNode.inputHitTestSlop) - richTextInputNode.inputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) + accessibilityInputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) + accessibilityInputView.accessibilityRespondsToUserInteraction = true self.updateInputField(textInputFrame: textFieldFrame, transition: ComponentTransition(transition)) if shouldUpdateLayout { richTextInputNode.layoutInputField() diff --git a/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift b/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift index 1b29e65e763..05431e45d48 100644 --- a/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift +++ b/submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift @@ -11,14 +11,22 @@ import TextFormat import AccountContext import Markdown -private let textFont = Font.regular(13.0) -private let boldTextFont = Font.semibold(13.0) - private func formattedText(_ text: String, color: UIColor, textAlignment: NSTextAlignment = .natural) -> NSAttributedString { + let textFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: Font.regular(13.0)) + let boldTextFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: Font.semibold(13.0)) return parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: color), bold: MarkdownAttributeSet(font: boldTextFont, textColor: color), link: MarkdownAttributeSet(font: textFont, textColor: color), linkAttribute: { _ in return nil}), textAlignment: textAlignment) } +private final class ChatMessageActionUrlAuthOptionNode: ASTextNode { + var activate: (() -> Bool)? + + override func accessibilityActivate() -> Bool { + return self.activate?() ?? false + } +} + private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { + private var theme: AlertControllerTheme private let strings: PresentationStrings private let nameDisplayOrder: PresentationPersonNameOrder private let defaultUrl: String @@ -29,9 +37,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { private let titleNode: ASTextNode private let textNode: ASTextNode private let authorizeCheckNode: InteractiveCheckNode - private let authorizeLabelNode: ASTextNode + private let authorizeLabelNode: ChatMessageActionUrlAuthOptionNode private let allowWriteCheckNode: InteractiveCheckNode - private let allowWriteLabelNode: ASTextNode + private let allowWriteLabelNode: ChatMessageActionUrlAuthOptionNode private let actionNodesSeparator: ASDisplayNode private let actionNodes: [TextAlertContentActionNode] @@ -42,6 +50,10 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { override var dismissOnOutsideTap: Bool { return self.isUserInteractionEnabled } + + override var accessibilityInitialFocusNode: ASDisplayNode? { + return self.titleNode + } var authorize: Bool = true { didSet { @@ -52,16 +64,19 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { if !self.authorize && self.allowWriteAccess { self.allowWriteAccess = false } + self.updateOptionAccessibility() } } var allowWriteAccess: Bool = true { didSet { self.allowWriteCheckNode.setSelected(self.allowWriteAccess, animated: true) + self.updateOptionAccessibility() } } init(theme: AlertControllerTheme, ptheme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, defaultUrl: String, domain: String, bot: EnginePeer, requestWriteAccess: Bool, displayName: String, actions: [TextAlertAction]) { + self.theme = theme self.strings = strings self.nameDisplayOrder = nameDisplayOrder self.defaultUrl = defaultUrl @@ -70,22 +85,26 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { self.displayName = displayName self.titleNode = ASTextNode() - self.titleNode.maximumNumberOfLines = 2 + self.titleNode.maximumNumberOfLines = 0 + self.titleNode.isAccessibilityElement = true + self.titleNode.accessibilityTraits = [.header] self.textNode = ASTextNode() self.textNode.maximumNumberOfLines = 0 self.authorizeCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false)) self.authorizeCheckNode.setSelected(true, animated: false) - self.authorizeLabelNode = ASTextNode() - self.authorizeLabelNode.maximumNumberOfLines = 4 + self.authorizeLabelNode = ChatMessageActionUrlAuthOptionNode() + self.authorizeLabelNode.maximumNumberOfLines = 0 self.authorizeLabelNode.isUserInteractionEnabled = true + self.authorizeLabelNode.isAccessibilityElement = true self.allowWriteCheckNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: theme.accentColor, strokeColor: theme.contrastColor, borderColor: theme.controlBorderColor, overlayBorder: false, hasInset: false, hasShadow: false)) self.allowWriteCheckNode.setSelected(true, animated: false) - self.allowWriteLabelNode = ASTextNode() - self.allowWriteLabelNode.maximumNumberOfLines = 4 + self.allowWriteLabelNode = ChatMessageActionUrlAuthOptionNode() + self.allowWriteLabelNode.maximumNumberOfLines = 0 self.allowWriteLabelNode.isUserInteractionEnabled = true + self.allowWriteLabelNode.isAccessibilityElement = true self.actionNodesSeparator = ASDisplayNode() self.actionNodesSeparator.isLayerBacked = true @@ -105,6 +124,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { self.actionVerticalSeparators = actionVerticalSeparators super.init() + + self.authorizeCheckNode.isAccessibilityElement = false + self.allowWriteCheckNode.isAccessibilityElement = false self.addSubnode(self.titleNode) self.addSubnode(self.textNode) @@ -136,6 +158,20 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { strongSelf.allowWriteAccess = !strongSelf.allowWriteAccess } } + self.authorizeLabelNode.activate = { [weak self] in + guard let self else { + return false + } + self.authorize = !self.authorize + return true + } + self.allowWriteLabelNode.activate = { [weak self] in + guard let self, self.authorize else { + return false + } + self.allowWriteAccess = !self.allowWriteAccess + return true + } self.updateTheme(theme) } @@ -158,11 +194,19 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { } override func updateTheme(_ theme: AlertControllerTheme) { - self.titleNode.attributedText = NSAttributedString(string: strings.Conversation_OpenBotLinkTitle, font: Font.bold(17.0), textColor: theme.primaryColor, paragraphAlignment: .center) + self.theme = theme + let titleFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: Font.bold(17.0)) + self.titleNode.attributedText = NSAttributedString(string: strings.Conversation_OpenBotLinkTitle, font: titleFont, textColor: theme.primaryColor, paragraphAlignment: .center) self.textNode.attributedText = formattedText(strings.Conversation_OpenBotLinkText(self.defaultUrl).string, color: theme.primaryColor, textAlignment: .center) self.authorizeLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkLogin(self.domain, self.displayName).string, color: theme.primaryColor) self.allowWriteLabelNode.attributedText = formattedText(strings.Conversation_OpenBotLinkAllowMessages(self.bot.displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder)).string, color: theme.primaryColor) + self.titleNode.accessibilityLabel = self.titleNode.attributedText?.string + self.textNode.isAccessibilityElement = true + self.textNode.accessibilityLabel = self.textNode.attributedText?.string + self.authorizeLabelNode.accessibilityLabel = self.authorizeLabelNode.attributedText?.string + self.allowWriteLabelNode.accessibilityLabel = self.allowWriteLabelNode.attributedText?.string + self.updateOptionAccessibility() self.actionNodesSeparator.backgroundColor = theme.separatorColor for actionNode in self.actionNodes { @@ -176,6 +220,26 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { _ = self.updateLayout(size: size, transition: .immediate) } } + + private func updateOptionAccessibility() { + self.authorizeLabelNode.accessibilityTraits = self.authorize ? [.button, .selected] : [.button] + var allowWriteTraits: UIAccessibilityTraits = [.button] + if self.allowWriteAccess { + allowWriteTraits.insert(.selected) + } + if !self.authorize { + allowWriteTraits.insert(.notEnabled) + } + self.allowWriteLabelNode.accessibilityTraits = allowWriteTraits + } + + override func contentSizeCategoryUpdated() { + self.updateTheme(self.theme) + for actionNode in self.actionNodes { + actionNode.updateTheme(self.theme) + } + self.requestLayout?(.immediate) + } override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { var size = size @@ -200,8 +264,11 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { var entriesHeight: CGFloat = 0.0 let authorizeSize = self.authorizeLabelNode.measure(condensedSize) - transition.updateFrame(node: self.authorizeLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: authorizeSize)) - transition.updateFrame(node: self.authorizeCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize)) + let authorizeLabelFrame = CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: authorizeSize) + let authorizeCheckFrame = CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize) + transition.updateFrame(node: self.authorizeLabelNode, frame: authorizeLabelFrame) + transition.updateFrame(node: self.authorizeCheckNode, frame: authorizeCheckFrame) + self.authorizeLabelNode.view.accessibilityFrameInContainerSpace = authorizeLabelFrame.union(authorizeCheckFrame) origin.y += authorizeSize.height entriesHeight += authorizeSize.height @@ -210,21 +277,27 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { entriesHeight += 16.0 let allowWriteSize = self.allowWriteLabelNode.measure(condensedSize) - transition.updateFrame(node: self.allowWriteLabelNode, frame: CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize)) - transition.updateFrame(node: self.allowWriteCheckNode, frame: CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize)) + let allowWriteLabelFrame = CGRect(origin: CGPoint(x: 46.0, y: origin.y), size: allowWriteSize) + let allowWriteCheckFrame = CGRect(origin: CGPoint(x: 12.0, y: origin.y - 2.0), size: checkSize) + transition.updateFrame(node: self.allowWriteLabelNode, frame: allowWriteLabelFrame) + transition.updateFrame(node: self.allowWriteCheckNode, frame: allowWriteCheckFrame) + self.allowWriteLabelNode.view.accessibilityFrameInContainerSpace = allowWriteLabelFrame.union(allowWriteCheckFrame) origin.y += allowWriteSize.height entriesHeight += allowWriteSize.height } - let actionButtonHeight: CGFloat = 44.0 + let minimumActionButtonHeight: CGFloat = 44.0 var minActionsWidth: CGFloat = 0.0 - let maxActionWidth: CGFloat = floor(size.width / CGFloat(self.actionNodes.count)) + let maxActionWidth: CGFloat = self.actionNodes.isEmpty ? size.width : floor(size.width / CGFloat(self.actionNodes.count)) let actionTitleInsets: CGFloat = 8.0 - var effectiveActionLayout = TextAlertContentActionLayout.horizontal + var effectiveActionLayout: TextAlertContentActionLayout = self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory ? .vertical : .horizontal + var actionHeights: [CGFloat] = [] for actionNode in self.actionNodes { - let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: maxActionWidth, height: actionButtonHeight)) - if case .horizontal = effectiveActionLayout, actionTitleSize.height > actionButtonHeight * 0.6667 { + let actionTitleSize = actionNode.titleNode.updateLayout(CGSize(width: max(1.0, maxActionWidth - 16.0), height: CGFloat.greatestFiniteMagnitude)) + let actionHeight = max(minimumActionButtonHeight, actionTitleSize.height + 20.0) + actionHeights.append(actionHeight) + if case .horizontal = effectiveActionLayout, actionHeight > minimumActionButtonHeight { effectiveActionLayout = .vertical } switch effectiveActionLayout { @@ -243,9 +316,9 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { var actionsHeight: CGFloat = 0.0 switch effectiveActionLayout { case .horizontal: - actionsHeight = actionButtonHeight + actionsHeight = actionHeights.max() ?? minimumActionButtonHeight case .vertical: - actionsHeight = actionButtonHeight * CGFloat(self.actionNodes.count) + actionsHeight = actionHeights.reduce(0.0, +) } let resultWidth = contentWidth + insets.left + insets.right @@ -254,7 +327,7 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { transition.updateFrame(node: self.actionNodesSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight - UIScreenPixel), size: CGSize(width: resultSize.width, height: UIScreenPixel))) var actionOffset: CGFloat = 0.0 - let actionWidth: CGFloat = floor(resultSize.width / CGFloat(self.actionNodes.count)) + let actionWidth: CGFloat = self.actionNodes.isEmpty ? resultSize.width : floor(resultSize.width / CGFloat(self.actionNodes.count)) var separatorIndex = -1 var nodeIndex = 0 for actionNode in self.actionNodes { @@ -284,11 +357,12 @@ private final class ChatMessageActionUrlAuthAlertContentNode: AlertContentNode { let actionNodeFrame: CGRect switch effectiveActionLayout { case .horizontal: - actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) + actionNodeFrame = CGRect(origin: CGPoint(x: actionOffset, y: resultSize.height - actionsHeight), size: CGSize(width: currentActionWidth, height: actionsHeight)) actionOffset += currentActionWidth case .vertical: - actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionButtonHeight)) - actionOffset += actionButtonHeight + let actionHeight = actionHeights[nodeIndex] + actionNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: resultSize.height - actionsHeight + actionOffset), size: CGSize(width: currentActionWidth, height: actionHeight)) + actionOffset += actionHeight } transition.updateFrame(node: actionNode, frame: actionNodeFrame) From 41b9bd32dc4944847940755569dd73df04c76b53 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:12:03 +0500 Subject: [PATCH 06/18] Improve VoiceOver reply navigation and peer info actions - add an accessible action to navigate from a reply to its original message - preserve and restore VoiceOver focus during reply navigation - distinguish interactive accessibility areas for Voice Control - expose functional VoiceOver activation across peer info rows - add contact, address, business hours, and personal channel semantics --- .../Source/AccessibilityAreaNode.swift | 35 +++++++++++- .../Sources/ChatMessageItemView.swift | 14 ++++- .../Sources/ChatControllerInteraction.swift | 1 + .../ListItems/PeerInfoScreenActionItem.swift | 10 ++++ .../ListItems/PeerInfoScreenAddressItem.swift | 12 ++++ .../PeerInfoScreenBusinessHoursItem.swift | 45 +++++++++++++++ .../PeerInfoScreenCommunityItem.swift | 5 ++ .../PeerInfoScreenContactInfoItem.swift | 56 ++++++++++++++++++- .../PeerInfoScreenDisclosureItem.swift | 10 ++++ .../PeerInfoScreenLabeledValueItem.swift | 10 ++++ .../PeerInfoScreenPersonalChannelItem.swift | 11 ++++ .../ListItems/PeerInfoScreenSwitchItem.swift | 5 +- .../Sources/ChatHistoryListNode.swift | 21 ++++++- 13 files changed, 226 insertions(+), 9 deletions(-) diff --git a/submodules/Display/Source/AccessibilityAreaNode.swift b/submodules/Display/Source/AccessibilityAreaNode.swift index 5975b2fa319..d7d35607524 100644 --- a/submodules/Display/Source/AccessibilityAreaNode.swift +++ b/submodules/Display/Source/AccessibilityAreaNode.swift @@ -7,9 +7,21 @@ public protocol AccessibilityFocusableNode { } public final class AccessibilityAreaNode: ASDisplayNode { - public var activate: (() -> Bool)? - public var increment: (() -> Void)? - public var decrement: (() -> Void)? + public var activate: (() -> Bool)? { + didSet { + self.updateRespondsToUserInteraction() + } + } + public var increment: (() -> Void)? { + didSet { + self.updateRespondsToUserInteraction() + } + } + public var decrement: (() -> Void)? { + didSet { + self.updateRespondsToUserInteraction() + } + } public var focused: (() -> Void)? override public init() { @@ -17,6 +29,23 @@ public final class AccessibilityAreaNode: ASDisplayNode { self.isAccessibilityElement = true } + + override public func didLoad() { + super.didLoad() + + self.updateRespondsToUserInteraction() + } + + private func updateRespondsToUserInteraction() { + if self.isNodeLoaded { + self.view.accessibilityRespondsToUserInteraction = self.activate != nil + || self.increment != nil + || self.decrement != nil + || self.accessibilityTraits.contains(.button) + || self.accessibilityTraits.contains(.link) + || self.accessibilityTraits.contains(.adjustable) + } + } override public func accessibilityActivate() -> Bool { return self.activate?() ?? false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 71263876310..1ef092933d7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -66,6 +66,7 @@ private let fileSizeFormatter: ByteCountFormatter = { public enum ChatMessageAccessibilityCustomActionType { case reply + case navigateToReply(EngineMessage.Id) case react case options case copy @@ -514,6 +515,7 @@ public final class ChatMessageAccessibilityData { var (label, value) = dataForMessage(item.message, false) var replyValue: String? + var replyMessageId: EngineMessage.Id? for attribute in item.message.attributes { if let attribute = attribute as? TextEntitiesMessageAttribute { @@ -542,7 +544,11 @@ public final class ChatMessageAccessibilityData { break } } - } else if let attribute = attribute as? ReplyMessageAttribute, let replyMessage = item.message.associatedMessages[attribute.messageId] { + } else if let attribute = attribute as? ReplyMessageAttribute { + replyMessageId = attribute.messageId + guard let replyMessage = item.message.associatedMessages[attribute.messageId] else { + continue + } var replyLabel: String if replyMessage.flags.contains(.Incoming) { if let author = replyMessage.author { @@ -605,6 +611,9 @@ public final class ChatMessageAccessibilityData { if canReply { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextReply, target: nil, selector: #selector(self.noop), action: .reply)) } + if let replyMessageId { + customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_Chat_GoToOriginalMessage, target: nil, selector: #selector(self.noop), action: .navigateToReply(replyMessageId))) + } if canAddMessageReactions(message: EngineMessage(item.message)) { customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.MediaEditor_Shortcut_Reaction, target: nil, selector: #selector(self.noop), action: .react)) } @@ -752,6 +761,9 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { switch action.action { case .reply: item.controllerInteraction.setupReply(item.message.id) + case let .navigateToReply(messageId): + item.controllerInteraction.accessibilityNavigationTargetMessageId = messageId + item.controllerInteraction.navigateToMessage(item.message.id, messageId, NavigateToMessageParams(timestamp: nil, quote: nil)) case .react: item.controllerInteraction.updateMessageReaction(item.message, .default, false, nil) case .options: diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 9a11653344f..74b28aa7394 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -281,6 +281,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let accessibilityForwardMessage: (EngineRawMessage) -> Void public let accessibilityDeleteMessage: (EngineRawMessage) -> Void public let canPerformAccessibilityMessageActions: Bool + public var accessibilityNavigationTargetMessageId: EngineMessage.Id? public let displayUndo: (UndoOverlayContent) -> Void public let isAnimatingMessage: (UInt32) -> Bool public let getMessageTransitionNode: () -> ChatMessageTransitionProtocol? diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift index 64ab4e9b1d1..872e6ae9e6a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift @@ -100,6 +100,16 @@ private final class PeerInfoScreenActionItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left var leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift index c67a082d945..46f875cfa6c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenAddressItem.swift @@ -177,6 +177,17 @@ private final class PeerInfoScreenAddressItemNode: PeerInfoScreenItemNode { self.item = item self.presentationData = presentationData + + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } self.containerNode.isGestureEnabled = item.contextAction != nil @@ -231,6 +242,7 @@ private final class PeerInfoScreenAddressItemNode: PeerInfoScreenItemNode { self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) self.activateArea.accessibilityLabel = item.label + self.activateArea.accessibilityValue = item.text let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift index a4c4781af56..817af7543a4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenBusinessHoursItem.swift @@ -14,6 +14,15 @@ import BundleIconComponent import PlainButtonComponent import AccountContext +private final class PeerInfoBusinessHoursAccessibilityAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + super.init(name: name, target: target, selector: selector) + } +} + func businessHoursTextToCopy(businessHours: TelegramBusinessHours, presentationData: PresentationData, displayLocalTimezone: Bool) -> String { var text = "" @@ -297,6 +306,15 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode self.item = item self.presentationData = presentationData self.theme = presentationData.theme + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { [weak self] in + guard let self else { + return false + } + self.isExpanded = !self.isExpanded + self.item?.requestLayout(true) + return true + } self.containerNode.isGestureEnabled = item.contextAction != nil @@ -445,6 +463,7 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode } var timezoneSwitchButtonSize: CGSize? + var accessibilityTimezoneSwitchTitle: String? if hasTimezoneDependentEntries { let timezoneSwitchButton: ComponentView if let current = self.timezoneSwitchButton { @@ -459,6 +478,7 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode } else { timezoneSwitchTitle = presentationData.strings.PeerInfo_BusinessHours_TimezoneSwitchBusiness } + accessibilityTimezoneSwitchTitle = timezoneSwitchTitle timezoneSwitchButtonSize = timezoneSwitchButton.update( transition: .immediate, component: AnyComponent(PlainButtonComponent( @@ -651,6 +671,23 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) self.activateArea.accessibilityLabel = item.label + self.activateArea.accessibilityValue = businessHoursTextToCopy(businessHours: item.businessHours, presentationData: presentationData, displayLocalTimezone: self.displayLocalTimezone) + if let accessibilityTimezoneSwitchTitle { + self.activateArea.accessibilityCustomActions = [ + PeerInfoBusinessHoursAccessibilityAction(name: accessibilityTimezoneSwitchTitle, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayLocalTimezone = !self.displayLocalTimezone + if !self.isExpanded { + self.isExpanded = true + } + self.item?.requestLayout(true) + }) + ] + } else { + self.activateArea.accessibilityCustomActions = nil + } let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) @@ -671,6 +708,14 @@ private final class PeerInfoScreenBusinessHoursItemNode: PeerInfoScreenItemNode return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoBusinessHoursAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift index 4ccf64b2ea6..89cfdf443b2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommunityItem.swift @@ -101,6 +101,11 @@ private final class PeerInfoScreenCommunityItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + item.action() + return true + } let sideInset: CGFloat = 16.0 + safeInsets.left let avatarSize: CGFloat = 30.0 diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift index a8313a54f2e..5b538a4e2a2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenContactInfoItem.swift @@ -8,6 +8,15 @@ import AppBundle import TelegramStringFormatting import ContextUI +private final class PeerInfoContactAccessibilityAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + super.init(name: name, target: target, selector: selector) + } +} + final class PeerInfoScreenContactInfoItem: PeerInfoScreenItem { let id: AnyHashable let username: String @@ -244,6 +253,34 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { self.item = item self.theme = presentationData.theme + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + if let usernameAction = item.usernameAction, !item.username.isEmpty { + accessibilityActions.append(PeerInfoContactAccessibilityAction(name: item.username, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + usernameAction(self.contextSourceNode) + })) + } + if let phoneAction = item.phoneAction, !item.phoneNumber.isEmpty { + accessibilityActions.append(PeerInfoContactAccessibilityAction(name: item.phoneNumber, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + phoneAction(self.contextSourceNode) + })) + } + self.activateArea.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions + self.activateArea.accessibilityTraits = accessibilityActions.isEmpty ? [.staticText] : [.button] + if let primaryAction = accessibilityActions.first as? PeerInfoContactAccessibilityAction { + self.activateArea.activate = { + primaryAction.perform() + return true + } + } else { + self.activateArea.activate = nil + } // if let action = item.action { // self.selectionNode.pressed = { [weak self] in @@ -324,8 +361,15 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { self.bottomSeparatorNode.isHidden = hasBottomCorners self.activateArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)) - self.activateArea.accessibilityLabel = item.username - self.activateArea.accessibilityValue = item.phoneNumber + self.activateArea.accessibilityLabel = item.username.isEmpty ? item.phoneNumber : item.username + var accessibilityValues: [String] = [] + if !item.username.isEmpty && !item.phoneNumber.isEmpty { + accessibilityValues.append(item.phoneNumber) + } + if let additionalText = item.additionalText, !additionalText.isEmpty { + accessibilityValues.append(additionalText) + } + self.activateArea.accessibilityValue = accessibilityValues.isEmpty ? nil : accessibilityValues.joined(separator: ". ") let contentSize = CGSize(width: width, height: height) self.containerNode.frame = CGRect(origin: CGPoint(), size: contentSize) @@ -347,6 +391,14 @@ private final class PeerInfoScreenContactInfoItemNode: PeerInfoScreenItemNode { return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoContactAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { guard let _ = self.item, let theme = self.theme else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift index f788f65067d..9f0f3ca89c4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift @@ -152,6 +152,16 @@ private final class PeerInfoScreenDisclosureItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left let leftInset = (item.icon == nil && item.iconSignal == nil ? sideInset : sideInset + 29.0 + 16.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift index 37d162fdaa8..9a675ae6c90 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenLabeledValueItem.swift @@ -484,8 +484,18 @@ private final class PeerInfoScreenLabeledValueItemNode: PeerInfoScreenItemNode { action(strongSelf.contextSourceNode, nil) } } + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { [weak self] in + guard let self else { + return false + } + action(self.contextSourceNode, nil) + return true + } } else { self.selectionNode.pressed = nil + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil } let sideInset: CGFloat = 16.0 + safeInsets.left diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift index 1bf164df4ea..e539bec5076 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenPersonalChannelItem.swift @@ -430,6 +430,17 @@ private final class PeerInfoScreenPersonalChannelItemNode: PeerInfoScreenItemNod self.item = item self.presentationData = presentationData self.theme = presentationData.theme + self.activateArea.isAccessibilityElement = !item.data.isLoading + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + item.action() + return true + } + if let peer = item.data.peer.chatMainPeer { + self.activateArea.accessibilityLabel = EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + } else { + self.activateArea.accessibilityLabel = nil + } self.selectionNode.pressed = { [weak self] in if let strongSelf = self { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift index 10cff7c74cc..cd2f9909a0c 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenSwitchItem.swift @@ -86,7 +86,7 @@ private final class PeerInfoScreenSwitchItemNode: PeerInfoScreenItemNode { guard let strongSelf = self, let item = strongSelf.item else { return false } - let value = !strongSelf.switchNode.isOn + let value = item.isLocked ? strongSelf.switchNode.isOn : !strongSelf.switchNode.isOn item.toggled?(value) return true } @@ -163,7 +163,8 @@ private final class PeerInfoScreenSwitchItemNode: PeerInfoScreenItemNode { self.activateArea.accessibilityLabel = item.text self.activateArea.accessibilityValue = item.value ? presentationData.strings.VoiceOver_Common_On : presentationData.strings.VoiceOver_Common_Off - self.activateArea.accessibilityHint = presentationData.strings.VoiceOver_Common_SwitchHint + self.activateArea.accessibilityHint = item.isLocked ? nil : presentationData.strings.VoiceOver_Common_SwitchHint + self.activateArea.accessibilityTraits = [.button] let textSize = self.textNode.updateLayout(CGSize(width: width - leftInset - rightInset, height: .greatestFiniteMagnitude)) let textFrame = CGRect(origin: CGPoint(x: leftInset, y: 16.0), size: textSize) diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index c1b8580fda5..5ac5770692a 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -3874,6 +3874,7 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat self.hasActiveTransition = true let transition = self.enqueuedHistoryViewTransitions.removeFirst() + let accessibilityNavigationTargetMessageId = UIAccessibility.isVoiceOverRunning ? self.controllerInteraction.accessibilityNavigationTargetMessageId : nil var accessibilityFocusedMessageId: MessageId? if UIAccessibility.isVoiceOverRunning { self.forEachVisibleMessageItemNode { itemNode in @@ -4439,7 +4440,25 @@ public final class ChatHistoryListNodeImpl: ASDisplayNode, ChatHistoryNode, Chat strongSelf.hasActiveTransition = false - if let accessibilityFocusedMessageId { + if let accessibilityNavigationTargetMessageId { + var didRestoreAccessibilityNavigationTarget = false + strongSelf.forEachVisibleMessageItemNode { itemNode in + if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityNavigationTargetMessageId }) { + didRestoreAccessibilityNavigationTarget = true + if strongSelf.controllerInteraction.accessibilityNavigationTargetMessageId == accessibilityNavigationTargetMessageId { + strongSelf.controllerInteraction.accessibilityNavigationTargetMessageId = nil + } + itemNode.restoreAccessibilityFocus() + } + } + if !didRestoreAccessibilityNavigationTarget, let accessibilityFocusedMessageId { + strongSelf.forEachVisibleMessageItemNode { itemNode in + if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) { + itemNode.restoreAccessibilityFocus() + } + } + } + } else if let accessibilityFocusedMessageId { strongSelf.forEachVisibleMessageItemNode { itemNode in if let item = itemNode.item, item.content.contains(where: { $0.0.id == accessibilityFocusedMessageId }) { itemNode.restoreAccessibilityFocus() From 2cff1ba8883502765e90562df4f97d26aac153c2 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:24:42 +0500 Subject: [PATCH 07/18] Improve VoiceOver accessibility for peer info members and header Add accessible activation and custom member actions to peer info rows. Improve encryption key, interactive subtitle, hidden status badge, and edit photo semantics while avoiding duplicate accessibility elements. --- ...nfoScreenDisclosureEncryptionKeyItem.swift | 16 +++++ .../ListItems/PeerInfoScreenMemberItem.swift | 58 +++++++++++++++++++ .../PeerInfoHeaderEditingContentNode.swift | 2 + .../Sources/PeerInfoHeaderNode.swift | 4 ++ .../Sources/PeerInfoSubtitleBadgeView.swift | 6 ++ 5 files changed, 86 insertions(+) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift index 4a942dc0bec..224797a7db7 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureEncryptionKeyItem.swift @@ -32,6 +32,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree private let arrowNode: ASImageNode private let bottomSeparatorNode: ASDisplayNode private let maskNode: ASImageNode + private let activateArea: AccessibilityAreaNode private var item: PeerInfoScreenDisclosureEncryptionKeyItem? @@ -59,6 +60,8 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false + + self.activateArea = AccessibilityAreaNode() super.init() @@ -72,6 +75,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.addSubnode(self.keyNode) self.addSubnode(self.arrowNode) self.addSubnode(self.maskNode) + self.addSubnode(self.activateArea) } override func update(context: AccountContext, width: CGFloat, safeInsets: UIEdgeInsets, presentationData: PresentationData, item: PeerInfoScreenItem, topItem: PeerInfoScreenItem?, bottomItem: PeerInfoScreenItem?, hasCorners: Bool, transition: ContainedViewLayoutTransition) -> CGFloat { @@ -86,6 +90,17 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityLabel = item.text + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action() + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } let sideInset: CGFloat = 16.0 + safeInsets.left @@ -128,6 +143,7 @@ private final class PeerInfoScreenDisclosureEncryptionKeyItemNode: PeerInfoScree transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: sideInset, y: height - UIScreenPixel), size: CGSize(width: width - sideInset, height: UIScreenPixel))) transition.updateAlpha(node: self.bottomSeparatorNode, alpha: bottomItem == nil ? 0.0 : 1.0) + self.activateArea.frame = CGRect(origin: .zero, size: CGSize(width: width, height: height)) return height } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift index b6527b86b1c..dfea2cd23b1 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift @@ -9,6 +9,15 @@ import AccountContext import TelegramCore import ItemListUI +private final class PeerInfoMemberAccessibilityAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + super.init(name: name, target: target, selector: selector) + } +} + enum PeerInfoScreenMemberItemAction { case open case promote @@ -58,6 +67,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { private let selectionNode: PeerInfoScreenSelectableBackgroundNode private let maskNode: ASImageNode private let bottomSeparatorNode: ASDisplayNode + private let activateArea: AccessibilityAreaNode private var item: PeerInfoScreenMemberItem? private var itemNode: ItemListPeerItemNode? @@ -72,6 +82,8 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { self.bottomSeparatorNode = ASDisplayNode() self.bottomSeparatorNode.isLayerBacked = true + + self.activateArea = AccessibilityAreaNode() super.init() @@ -81,6 +93,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { self.addSubnode(self.bottomSeparatorNode) self.addSubnode(self.selectionNode) + self.addSubnode(self.activateArea) } override func didLoad() { @@ -258,6 +271,42 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { } itemNode.visibility = .visible(1.0, .infinite) + itemNode.isAccessibilityElement = false + + self.activateArea.accessibilityLabel = itemNode.accessibilityLabel + self.activateArea.accessibilityValue = itemNode.accessibilityValue + if let action = item.action { + self.activateArea.accessibilityTraits = [.button] + self.activateArea.activate = { + action(.open) + return true + } + } else { + self.activateArea.accessibilityTraits = [.staticText] + self.activateArea.activate = nil + } + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + if let memberAction = item.action, actions.contains(.promote), case .channel = item.enclosingPeer { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.GroupInfo_ActionPromote, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.promote) + })) + } + if let memberAction = item.action, actions.contains(.restrict), case .channel = item.enclosingPeer { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.GroupInfo_ActionRestrict, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.restrict) + })) + } + if let memberAction = item.action, actions.contains(.restrict) { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.Common_Delete, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.remove) + })) + } else if let memberAction = item.action, actions.contains(.logout) { + accessibilityActions.append(PeerInfoMemberAccessibilityAction(name: presentationData.strings.Settings_Context_Logout, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { + memberAction(.remove) + })) + } + self.activateArea.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions let height = itemNode.contentSize.height @@ -279,6 +328,7 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { let highlightNodeOffset: CGFloat = topItem == nil ? 0.0 : UIScreenPixel self.selectionNode.update(size: CGSize(width: width, height: height + highlightNodeOffset), theme: presentationData.theme, transition: transition) transition.updateFrame(node: self.selectionNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -highlightNodeOffset), size: CGSize(width: width, height: height + highlightNodeOffset))) + self.activateArea.frame = CGRect(origin: .zero, size: CGSize(width: width, height: height)) var separatorInset: CGFloat = sideInset if bottomItem != nil { @@ -292,6 +342,14 @@ private final class PeerInfoScreenMemberItemNode: PeerInfoScreenItemNode { return height } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoMemberAccessibilityAction else { + return false + } + action.perform() + return true + } private func updateTouchesAtPoint(_ point: CGPoint?) { guard let item = self.item else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift index 8a704e1cb13..950aaac88c9 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift @@ -27,6 +27,8 @@ final class PeerInfoHeaderEditingContentNode: ASDisplayNode { self.avatarTextNode = ImmediateTextNode() self.avatarButtonNode = HighlightableButtonNode() + self.avatarButtonNode.isAccessibilityElement = true + self.avatarButtonNode.accessibilityTraits = [.button] super.init() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index fdfd1cee79e..15b320a8e52 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -1382,6 +1382,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleNodeLayout = self.subtitleNode.updateLayout(text: subtitleStringText, states: subtitleStates, mainState: TitleNodeStateRegular) } self.subtitleNode.accessibilityLabel = subtitleStringText + self.subtitleNode.isAccessibilityElement = !subtitleIsButton var subtitleButtonHorizontalOffset: CGFloat = 0.0 if subtitleIsButton { @@ -1401,6 +1402,8 @@ final class PeerInfoHeaderNode: ASDisplayNode { subtitleBackgroundButton = HighlightTrackingButtonNode() self.subtitleBackgroundButton = subtitleBackgroundButton self.subtitleNode.addSubnode(subtitleBackgroundButton) + subtitleBackgroundButton.isAccessibilityElement = true + subtitleBackgroundButton.accessibilityTraits = [.button] subtitleBackgroundButton.addTarget(self, action: #selector(self.subtitleBackgroundPressed), forControlEvents: .touchUpInside) subtitleBackgroundButton.highligthedChanged = { [weak self] highlighted in @@ -1416,6 +1419,7 @@ final class PeerInfoHeaderNode: ASDisplayNode { } } } + subtitleBackgroundButton.accessibilityLabel = subtitleStringText let subtitleArrowNode: ASImageNode if let current = self.subtitleArrowNode { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift index 0e194b6af97..94332864224 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSubtitleBadgeView.swift @@ -20,6 +20,9 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { self.backgroundView.isUserInteractionEnabled = false super.init(frame: CGRect()) + + self.isAccessibilityElement = true + self.accessibilityTraits = [.button] self.addSubview(self.backgroundView) @@ -64,6 +67,8 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { } func update(title: String, fillColor: UIColor, foregroundColor: UIColor) -> CGSize { + self.accessibilityLabel = title + let labelSize = self.labelView.update( transition: .immediate, component: AnyComponent(Text(text: title, font: Font.regular(11.0), color: foregroundColor)), @@ -80,6 +85,7 @@ final class PeerInfoSubtitleBadgeView: HighlightTrackingButton { if let labelComponentView = self.labelView.view { if labelComponentView.superview == nil { labelComponentView.isUserInteractionEnabled = false + labelComponentView.accessibilityElementsHidden = true self.addSubview(labelComponentView) } labelComponentView.frame = CGRect(origin: CGPoint(x: floor((size.width - labelSize.width) * 0.5), y: floor((size.height - labelSize.height) * 0.5)), size: labelSize) From 2c9ee5661b60c635b09e37a1e8985762974b9abc Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:01:52 +0500 Subject: [PATCH 08/18] Improve VoiceOver accessibility for peer info media panes Add accessible avatar and header semantics, expose Story and shared-media grid items to VoiceOver, preserve focus identity, selection state, traversal order, and context actions, and improve Recommended Peers controls. --- .../Panes/PeerInfoRecommendedPeersPane.swift | 6 +- ...PeerInfoAvatarTransformContainerNode.swift | 21 ++++ .../Sources/PeerInfoEditingAvatarNode.swift | 17 +++ .../Sources/PeerInfoHeaderNode.swift | 51 +++++++++ .../Sources/PeerInfoStoryPaneNode.swift | 99 +++++++++++++++++ .../Sources/PeerInfoVisualMediaPaneNode.swift | 105 ++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift index c518841d851..2998ab90689 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift @@ -363,12 +363,16 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { unlockButton.animationLoopTime = 2.5 unlockButton.animation = "premium_unlock" unlockButton.iconPosition = .right - unlockButton.title = isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMore : presentationData.strings.Channel_SimilarChannels_ShowMore unlockButton.pressed = { [weak self] in self?.unlockPressed() } } + let unlockTitle = isBots ? presentationData.strings.PeerInfo_SimilarBots_ShowMore : presentationData.strings.Channel_SimilarChannels_ShowMore + unlockButton.title = unlockTitle + unlockButton.isAccessibilityElement = true + unlockButton.accessibilityLabel = unlockTitle + unlockButton.accessibilityTraits = [.button] if themeUpdated { let topColor = presentationData.theme.list.plainBackgroundColor.withAlphaComponent(0.0) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift index 89dc09d0d02..30308bb378e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift @@ -24,6 +24,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { let containerNode: ContextControllerSourceNode let avatarNode: AvatarNode + private let accessibilityArea: AccessibilityAreaNode private(set) var avatarStoryView: ComponentView? var videoNode: UniversalVideoNode? var markupNode: AvatarVideoNode? @@ -60,13 +61,18 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) + self.accessibilityArea = AccessibilityAreaNode() super.init() self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.avatarNode) + self.containerNode.addSubnode(self.accessibilityArea) self.containerNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) self.avatarNode.frame = self.containerNode.bounds + self.accessibilityArea.frame = self.containerNode.bounds + self.accessibilityArea.accessibilityTraits = [.button, .image] + self.avatarNode.isAccessibilityElement = false let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) self.avatarNode.view.addGestureRecognizer(tapGestureRecognizer) @@ -252,8 +258,22 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { func update(peer: EnginePeer?, threadId: Int64?, threadInfo: EngineMessageHistoryThread.Info?, item: PeerInfoAvatarListItem?, theme: PresentationTheme, avatarSize: CGFloat, isExpanded: Bool, isSettings: Bool) { self.params = Params(peer: peer, threadId: threadId, threadInfo: threadInfo, item: item, theme: theme, avatarSize: avatarSize, isExpanded: isExpanded, isSettings: isSettings) + self.accessibilityArea.isAccessibilityElement = peer != nil if let peer = peer { + self.accessibilityArea.accessibilityLabel = threadInfo?.title ?? peer.compactDisplayTitle + self.accessibilityArea.activate = { [weak self] in + guard let self else { + return false + } + if threadInfo != nil { + self.emojiTapped?() + } else { + self.tapped?() + } + return true + } + let previousItem = self.item var item = item self.item = item @@ -343,6 +363,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { self.containerNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.frame = self.containerNode.bounds + self.accessibilityArea.frame = self.containerNode.bounds self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) if let item = item { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift index 98fc8ba70d5..f9761f0759e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift @@ -16,6 +16,7 @@ import GalleryUI final class PeerInfoEditingAvatarNode: ASDisplayNode { private let context: AccountContext let avatarNode: AvatarNode + private let accessibilityArea: AccessibilityAreaNode fileprivate var videoNode: UniversalVideoNode? fileprivate var markupNode: AvatarVideoNode? private var videoContent: NativeVideoContent? @@ -30,11 +31,23 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { self.context = context let avatarFont = avatarPlaceholderFont(size: floor(100.0 * 16.0 / 37.0)) self.avatarNode = AvatarNode(font: avatarFont) + self.accessibilityArea = AccessibilityAreaNode() super.init() self.addSubnode(self.avatarNode) + self.addSubnode(self.accessibilityArea) self.avatarNode.frame = CGRect(origin: CGPoint(x: -50.0, y: -50.0), size: CGSize(width: 100.0, height: 100.0)) + self.accessibilityArea.frame = self.avatarNode.frame + self.accessibilityArea.accessibilityTraits = [.button, .image] + self.accessibilityArea.activate = { [weak self] in + guard let self else { + return false + } + self.tapped?(false) + return true + } + self.avatarNode.isAccessibilityElement = false self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } @@ -59,8 +72,11 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { var removedPhotoResourceIds = Set() func update(peer: EnginePeer?, threadData: MessageHistoryThreadData?, chatLocation: ChatLocation, item: PeerInfoAvatarListItem?, updatingAvatar: PeerInfoUpdatingAvatar?, uploadProgress: AvatarUploadProgress?, theme: PresentationTheme, avatarSize: CGFloat, isEditing: Bool) { guard let peer = peer else { + self.accessibilityArea.isAccessibilityElement = false return } + self.accessibilityArea.isAccessibilityElement = true + self.accessibilityArea.accessibilityLabel = peer.compactDisplayTitle let canEdit = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) @@ -86,6 +102,7 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { self.avatarNode.font = avatarPlaceholderFont(size: floor(avatarSize * 16.0 / 37.0)) self.avatarNode.setPeer(context: self.context, theme: theme, peer: peer, overrideImage: overrideImage, clipStyle: .none, synchronousLoad: false, displayDimensions: CGSize(width: avatarSize, height: avatarSize)) self.avatarNode.frame = CGRect(origin: CGPoint(x: -avatarSize / 2.0, y: -avatarSize / 2.0), size: CGSize(width: avatarSize, height: avatarSize)) + self.accessibilityArea.frame = self.avatarNode.frame var isForum = false let avatarCornerRadius: CGFloat diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index 15b320a8e52..51823b4fc88 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -45,6 +45,15 @@ import BundleIconComponent import MarqueeComponent import EdgeEffect +private final class PeerInfoHeaderAccessibilityAction: UIAccessibilityCustomAction { + let perform: () -> Void + + init(name: String, target: Any?, selector: Selector, perform: @escaping () -> Void) { + self.perform = perform + super.init(name: name, target: target, selector: selector) + } +} + final class PeerInfoHeaderNavigationTransition { let sourceNavigationBar: NavigationBar let sourceTitleView: ChatTitleView @@ -1370,6 +1379,13 @@ final class PeerInfoHeaderNode: ASDisplayNode { TitleNodeStateRegular: MultiScaleTextState(attributes: titleAttributes, constrainedSize: titleConstrainedSize), TitleNodeStateExpanded: MultiScaleTextState(attributes: smallTitleAttributes, constrainedSize: titleConstrainedSize) ], mainState: TitleNodeStateRegular) + if let peer, peer.isScam { + self.titleNode.accessibilityLabel = "\(titleStringText), \(presentationData.strings.Message_ScamAccount)" + } else if let peer, peer.isFake { + self.titleNode.accessibilityLabel = "\(titleStringText), \(presentationData.strings.Message_FakeAccount)" + } else { + self.titleNode.accessibilityLabel = titleStringText + } let subtitleStates: [AnyHashable: MultiScaleTextState] = [ TitleNodeStateRegular: MultiScaleTextState(attributes: subtitleAttributes, constrainedSize: titleConstrainedSize), @@ -1383,6 +1399,26 @@ final class PeerInfoHeaderNode: ASDisplayNode { } self.subtitleNode.accessibilityLabel = subtitleStringText self.subtitleNode.isAccessibilityElement = !subtitleIsButton + var subtitleAccessibilityActions: [UIAccessibilityCustomAction] = [] + if self.isSettings, case let .user(user) = peer { + if let phone = user.phone, !phone.isEmpty { + subtitleAccessibilityActions.append(PeerInfoHeaderAccessibilityAction(name: presentationData.strings.Settings_CopyPhoneNumber, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayCopyContextMenu?(self.subtitleNodeRawContainer, true, false) + })) + } + if let username = user.addressName, !username.isEmpty { + subtitleAccessibilityActions.append(PeerInfoHeaderAccessibilityAction(name: presentationData.strings.Settings_CopyUsername, target: self, selector: #selector(self.performAccessibilityAction(_:)), perform: { [weak self] in + guard let self else { + return + } + self.displayCopyContextMenu?(self.subtitleNodeRawContainer, false, true) + })) + } + } + self.subtitleNode.accessibilityCustomActions = subtitleAccessibilityActions.isEmpty ? nil : subtitleAccessibilityActions var subtitleButtonHorizontalOffset: CGFloat = 0.0 if subtitleIsButton { @@ -2711,6 +2747,13 @@ final class PeerInfoHeaderNode: ASDisplayNode { musicView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } + musicView.isAccessibilityElement = true + musicView.accessibilityLabel = musicString.string + musicView.accessibilityTraits = [.button] + musicView.accessibilityElementsHidden = false + for subview in musicView.subviews { + subview.accessibilityElementsHidden = true + } if additive { musicTransition.updateFrameAdditiveToCenter(view: musicView, frame: musicFrame) } else { @@ -2760,6 +2803,14 @@ final class PeerInfoHeaderNode: ASDisplayNode { private func actionButtonPressed(_ buttonNode: PeerInfoHeaderActionButtonNode, gesture: ContextGesture?) { self.performButtonAction?(buttonNode.key, nil, gesture) } + + @objc private func performAccessibilityAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? PeerInfoHeaderAccessibilityAction else { + return false + } + action.perform() + return true + } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var result = super.point(inside: point, with: event) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index 921303febf3..ce181a144b8 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -1468,6 +1468,9 @@ private final class StorySearchHeaderComponent: Component { let insets = UIEdgeInsets(top: 7.0, left: 16.0, bottom: 7.0, right: 16.0) let titleString = component.strings.StoryList_GridHeaderLocationSearch(Int32(component.count)) + self.isAccessibilityElement = true + self.accessibilityLabel = titleString + self.accessibilityTraits = [.staticText] let titleSize = self.title.update( transition: .immediate, @@ -1479,6 +1482,7 @@ private final class StorySearchHeaderComponent: Component { ) if let titleView = self.title.view { if titleView.superview == nil { + titleView.accessibilityElementsHidden = true self.addSubview(titleView) } titleView.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: titleSize) @@ -1497,6 +1501,27 @@ private final class StorySearchHeaderComponent: Component { } } +private final class StoryGridAccessibilityElement: UIAccessibilityElement { + var activate: () -> Bool = { + return false + } + var openContextMenu: () -> Bool = { + return false + } + + init(accessibilityContainer container: Any) { + super.init(accessibilityContainer: container) + } + + override func accessibilityActivate() -> Bool { + return self.activate() + } + + @objc func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + return self.openContextMenu() + } +} + public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { public enum Scope { case peer(id: EnginePeer.Id, isSaved: Bool, isArchived: Bool) @@ -1579,6 +1604,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr private let itemGrid: SparseItemGrid private let itemGridBinding: SparseItemGridBindingImpl + private var storyAccessibilityElements: [EngineStoryId: StoryGridAccessibilityElement] = [:] private let directMediaImageCache: DirectMediaImageCache private var items: SparseItemGrid.Items? private var pinnedIds: Set = Set() @@ -2046,6 +2072,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } strongSelf.paneDidScroll?() strongSelf.cancelPreviewGestures() + strongSelf.updateAccessibilityElements() if strongSelf.locationViewState.displayingMapModeOptions { strongSelf.locationViewState.displayingMapModeOptions = false @@ -3500,6 +3527,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: isSelected, animated: animated) } + self.updateAccessibilityElements() var isSelecting = false if let selectedIds = self._itemInteraction?.selectedIds, !selectedIds.isEmpty { @@ -3546,6 +3574,67 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.gridSelectionGesture = nil } } + + private func updateAccessibilityElements() { + var accessibilityElements: [UIAccessibilityElement] = [] + var validStoryIds: Set = [] + self.itemGrid.forEachVisibleItem { [weak self] displayItem in + guard let self, let itemLayer = displayItem.layer as? ItemLayer, let item = itemLayer.item, !itemLayer.isHidden else { + return + } + validStoryIds.insert(item.storyId) + + let accessibilityElement: StoryGridAccessibilityElement + if let current = self.storyAccessibilityElements[item.storyId] { + accessibilityElement = current + } else { + accessibilityElement = StoryGridAccessibilityElement(accessibilityContainer: self.itemGrid.view) + self.storyAccessibilityElements[item.storyId] = accessibilityElement + } + accessibilityElement.activate = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + self.itemGridBinding.onTap(item: item, itemLayer: itemLayer, point: CGPoint(x: itemLayer.bounds.midX, y: itemLayer.bounds.midY)) + return true + } + accessibilityElement.openContextMenu = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + let rect = self.itemGrid.frameForItem(layer: itemLayer) + self.openContextMenu(item: item.story, itemLayer: itemLayer, rect: rect, gesture: nil) + return true + } + var label = item.story.media._asMedia() is TelegramMediaFile ? self.presentationData.strings.VoiceOver_Chat_Video : self.presentationData.strings.VoiceOver_Chat_Photo + if let authorPeer = item.authorPeer { + label += ", \(authorPeer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder))" + } + accessibilityElement.accessibilityLabel = label + accessibilityElement.accessibilityTraits = [.button, .image] + accessibilityElement.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: self.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: accessibilityElement, selector: #selector(StoryGridAccessibilityElement.accessibilityOpenContextMenu(_:))) + ] + if self.itemInteraction.selectedIds?.contains(item.story.id) == true { + accessibilityElement.accessibilityTraits.insert(.selected) + accessibilityElement.accessibilityValue = self.presentationData.strings.VoiceOver_Chat_Selected + } + accessibilityElement.accessibilityFrameInContainerSpace = self.itemGrid.frameForItem(layer: itemLayer) + accessibilityElements.append(accessibilityElement) + } + accessibilityElements.sort { lhs, rhs in + let lhsFrame = lhs.accessibilityFrameInContainerSpace + let rhsFrame = rhs.accessibilityFrameInContainerSpace + if abs(lhsFrame.minY - rhsFrame.minY) > UIScreenPixel { + return lhsFrame.minY < rhsFrame.minY + } else { + return lhsFrame.minX < rhsFrame.minX + } + } + self.storyAccessibilityElements = self.storyAccessibilityElements.filter { validStoryIds.contains($0.key) } + self.itemGrid.view.isAccessibilityElement = false + self.itemGrid.view.accessibilityElements = accessibilityElements + } private func updateHiddenItems() { self.itemGrid.forEachVisibleItem { itemValue in @@ -3564,6 +3653,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr } } } + self.updateAccessibilityElements() } private func presentDeleteConfirmation(ids: Set) { @@ -4854,6 +4944,9 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr self.itemGrid.pinchEnabled = items.count > 2 && !self.isReordering self.itemGrid.update(size: size, insets: UIEdgeInsets(top: gridTopInset, left: sideInset, bottom: listBottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect, adjustForSmallCount: adjustForSmallCount, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none, transition: animateGridItems ? .spring(duration: 0.35) : .immediate) + DispatchQueue.main.async { [weak self] in + self?.updateAccessibilityElements() + } } self.listBottomInset = listBottomInset @@ -5760,6 +5853,12 @@ private final class BottomActionsPanelComponent: Component { if itemComponenView.superview == nil { self.addSubview(itemComponenView) } + itemComponenView.isAccessibilityElement = true + itemComponenView.accessibilityLabel = item.title + itemComponenView.accessibilityTraits = item.isEnabled ? [.button] : [.button, .notEnabled] + for subview in itemComponenView.subviews { + subview.accessibilityElementsHidden = true + } itemComponenView.frame = itemFrame } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift index 40b4fd0bf53..2f4de550bc8 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift @@ -1094,6 +1094,27 @@ public protocol PeerInfoScreenNodeProtocol: AnyObject { func displaySharedMediaFastScrollingTooltip() } +private final class VisualMediaGridAccessibilityElement: UIAccessibilityElement { + var activate: () -> Bool = { + return false + } + var openContextMenu: () -> Bool = { + return false + } + + init(accessibilityContainer container: Any) { + super.init(accessibilityContainer: container) + } + + override func accessibilityActivate() -> Bool { + return self.activate() + } + + @objc func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + return self.openContextMenu() + } +} + public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { public enum ContentType { case photoOrVideo @@ -1134,6 +1155,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, private let contextGestureContainerNode: ContextControllerSourceNode private let itemGrid: SparseItemGrid private let itemGridBinding: SparseItemGridBindingImpl + private var gridAccessibilityElements: [MessageId: VisualMediaGridAccessibilityElement] = [:] private let listBackgroundView: UIImageView private let listMaskView: UIImageView private let directMediaImageCache: DirectMediaImageCache @@ -1352,6 +1374,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, strongSelf.paneDidScroll?() strongSelf.cancelPreviewGestures() + strongSelf.updateGridAccessibilityElements() } self.itemGridBinding.coveringInsetOffsetUpdatedImpl = { [weak self] transition in @@ -1894,6 +1917,83 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, } return nil } + + private func updateGridAccessibilityElements() { + switch self.contentType { + case .files, .voiceAndVideoMessages, .music: + self.gridAccessibilityElements.removeAll() + self.itemGrid.view.accessibilityElements = nil + return + case .photo, .video, .photoOrVideo, .gifs: + break + } + + var accessibilityElements: [UIAccessibilityElement] = [] + var validMessageIds: Set = [] + self.itemGrid.forEachVisibleItem { [weak self] displayItem in + guard let self, let itemLayer = displayItem.layer as? GenericItemLayer, let item = itemLayer.item, !itemLayer.isHidden else { + return + } + validMessageIds.insert(item.message.id) + + let accessibilityElement: VisualMediaGridAccessibilityElement + if let current = self.gridAccessibilityElements[item.message.id] { + accessibilityElement = current + } else { + accessibilityElement = VisualMediaGridAccessibilityElement(accessibilityContainer: self.itemGrid.view) + self.gridAccessibilityElements[item.message.id] = accessibilityElement + } + accessibilityElement.activate = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + self.itemGridBinding.onTap(item: item, itemLayer: itemLayer, point: CGPoint(x: itemLayer.bounds.midX, y: itemLayer.bounds.midY)) + return true + } + accessibilityElement.openContextMenu = { [weak self, weak itemLayer] in + guard let self, let itemLayer else { + return false + } + let rect = self.itemGrid.frameForItem(layer: itemLayer) + self.chatControllerInteraction.openMessageContextActions(item.message, self, rect, nil) + return true + } + + var isVideo = false + for media in item.message.effectiveMedia { + if let image = media as? TelegramMediaImage, image.video != nil { + isVideo = true + break + } else if let file = media as? TelegramMediaFile, file.isVideo || file.isAnimated { + isVideo = true + break + } + } + accessibilityElement.accessibilityLabel = isVideo ? self.presentationData.strings.VoiceOver_Chat_Video : self.presentationData.strings.VoiceOver_Chat_Photo + accessibilityElement.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text + accessibilityElement.accessibilityTraits = [.button, .image] + accessibilityElement.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: self.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: accessibilityElement, selector: #selector(VisualMediaGridAccessibilityElement.accessibilityOpenContextMenu(_:))) + ] + if self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id) == true { + accessibilityElement.accessibilityTraits.insert(.selected) + } + accessibilityElement.accessibilityFrameInContainerSpace = self.itemGrid.frameForItem(layer: itemLayer) + accessibilityElements.append(accessibilityElement) + } + accessibilityElements.sort { lhs, rhs in + let lhsFrame = lhs.accessibilityFrameInContainerSpace + let rhsFrame = rhs.accessibilityFrameInContainerSpace + if abs(lhsFrame.minY - rhsFrame.minY) > UIScreenPixel { + return lhsFrame.minY < rhsFrame.minY + } else { + return lhsFrame.minX < rhsFrame.minX + } + } + self.gridAccessibilityElements = self.gridAccessibilityElements.filter { validMessageIds.contains($0.key) } + self.itemGrid.view.isAccessibilityElement = false + self.itemGrid.view.accessibilityElements = accessibilityElements + } public func updateHiddenMedia() { self.itemGrid.forEachVisibleItem { item in @@ -1912,6 +2012,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, itemLayer.isHidden = false } } + self.updateGridAccessibilityElements() } public func transferVelocity(_ velocity: CGFloat) { @@ -2173,6 +2274,7 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, } itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id), animated: animated) } + self.updateGridAccessibilityElements() let isSelecting = self.chatControllerInteraction.selectionState != nil self.itemGrid.pinchEnabled = !isSelecting @@ -2285,6 +2387,9 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, let listSideInset = isList ? sideInset + 16.0 : sideInset self.itemGrid.update(size: size, insets: UIEdgeInsets(top: topInset, left: listSideInset, bottom: bottomInset, right: listSideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: nil, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none) + DispatchQueue.main.async { [weak self] in + self?.updateGridAccessibilityElements() + } if let initialMessageIndexValue = self.initialMessageIndex, items.items.contains(where: { item in if let _ = item as? VisualMediaItem { return true From 1e328cb629f6aea05a48e36a1dba50c7be1c396f Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:14:40 +0500 Subject: [PATCH 09/18] Improve VoiceOver behavior in embedded peer info panes Align GIF activation and selection actions with touch behavior, contain accessibility focus during Saved Messages search, and pass the active VoiceOver state to embedded chat and Story map layouts. --- .../Sources/PeerInfoChatListPaneNode.swift | 7 +- .../Sources/PeerInfoChatPaneNode.swift | 2 +- .../Sources/Panes/PeerInfoGifPaneNode.swift | 74 +++++++++++-------- .../Sources/PeerInfoStoryPaneNode.swift | 2 +- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift index bdc20e8af83..e11b62000fb 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift @@ -414,6 +414,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS self.insertSubnode(chatController.displayNode, aboveSubnode: self.chatListNode) chatController.displayNode.alpha = 0.0 chatController.displayNode.clipsToBounds = true + chatController.displayNode.accessibilityElementsHidden = true self.updateChatController(transition: .immediate) @@ -442,6 +443,8 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS } if let contentNode = chatController.customNavigationBarContentNode { self.removeChatWhenNotSearching = true + chatController.displayNode.accessibilityElementsHidden = false + self.chatListNode.accessibilityElementsHidden = true chatController.displayNode.layer.allowsGroupOpacity = true if transition.isAnimated { @@ -480,6 +483,8 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS self.chatController = nil let displayNode = chatController.displayNode + displayNode.accessibilityElementsHidden = true + self.chatListNode.accessibilityElementsHidden = false chatController.displayNode.layer.allowsGroupOpacity = true chatController.displayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak displayNode] _ in displayNode?.removeFromSupernode() @@ -583,7 +588,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS let combinedBottomInset = bottomInset transition.updateFrame(node: chatController.displayNode, frame: chatFrame) chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop) - chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: topInset + navigationHeight, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: navigationHeight + topInset + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: UIAccessibility.isVoiceOverRunning), transition: transition) } public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift index 31a2d6d09df..68c57aaa20f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatPaneNode/Sources/PeerInfoChatPaneNode.swift @@ -304,7 +304,7 @@ public final class PeerInfoChatPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScro let combinedBottomInset = bottomInset transition.updateFrame(node: self.chatController.displayNode, frame: chatFrame) self.chatController.updateIsScrollingLockedAtTop(isScrollingLockedAtTop: isScrollingLockedAtTop) - self.chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: 0.0 + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + self.chatController.containerLayoutUpdated(ContainerViewLayout(size: chatFrame.size, metrics: LayoutMetrics(widthClass: .compact, heightClass: .compact, orientation: nil), deviceMetrics: deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), safeInsets: UIEdgeInsets(top: 0.0 + 4.0, left: sideInset, bottom: combinedBottomInset, right: sideInset), additionalInsets: UIEdgeInsets(), statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: UIAccessibility.isVoiceOverRunning), transition: transition) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift index a5a2d832b0f..99c2bbffb30 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift @@ -119,34 +119,38 @@ private final class VisualMediaItemNode: ASDisplayNode { if case .ended = recognizer.state { if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { if case .tap = gesture { - if let (item, _, _, _) = self.item { - var media: EngineRawMedia? - for value in item.message.effectiveMedia { - if let image = value as? TelegramMediaImage { - media = image - break - } else if let file = value as? TelegramMediaFile { - media = file - break - } - } - - if let media = media { - if let file = media as? TelegramMediaFile { - if isMediaStreamable(message: EngineMessage(item.message), media: file) { - self.interaction.openMessage(item.message) - } else { - self.progressPressed() - } - } else { - self.interaction.openMessage(item.message) - } - } - } + let _ = self.activateMedia() } } } } + + private func activateMedia() -> Bool { + guard let item = self.item?.0 else { + return false + } + + var media: EngineRawMedia? + for value in item.message.effectiveMedia { + if let image = value as? TelegramMediaImage { + media = image + break + } else if let file = value as? TelegramMediaFile { + media = file + break + } + } + + guard let media else { + return false + } + if let file = media as? TelegramMediaFile, !isMediaStreamable(message: EngineMessage(item.message), media: file) { + self.progressPressed() + } else { + self.interaction.openMessage(item.message) + } + return true + } private func progressPressed() { guard let message = self.item?.0.message else { @@ -310,9 +314,7 @@ private final class VisualMediaItemNode: ASDisplayNode { self.accessibilityLabel = presentationData.strings.VoiceOver_Chat_Video } self.accessibilityValue = item.message.text.isEmpty ? nil : item.message.text - self.accessibilityCustomActions = [ - UIAccessibilityCustomAction(name: presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) - ] + self.updateAccessibilityActions(strings: presentationData.strings) self.updateHiddenMedia() } @@ -354,6 +356,8 @@ private final class VisualMediaItemNode: ASDisplayNode { func updateSelectionState(animated: Bool) { if let (item, _, _, _) = self.item, let theme = self.theme { self.containerNode.isGestureEnabled = self.interaction.selectedMessageIds == nil + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.updateAccessibilityActions(strings: presentationData.strings) if let selectedIds = self.interaction.selectedMessageIds { let selected = selectedIds.contains(item.message.id) @@ -407,14 +411,24 @@ private final class VisualMediaItemNode: ASDisplayNode { } if let selectedMessageIds = self.interaction.selectedMessageIds { self.interaction.toggleSelection(item.message.id, !selectedMessageIds.contains(item.message.id)) + return true } else { - self.interaction.openMessage(item.message) + return self.activateMedia() + } + } + + private func updateAccessibilityActions(strings: PresentationStrings) { + if self.interaction.selectedMessageIds == nil { + self.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: strings.VoiceOver_MessageContextOpenMessageMenu, target: self, selector: #selector(self.accessibilityOpenContextMenu(_:))) + ] + } else { + self.accessibilityCustomActions = nil } - return true } @objc private func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { - guard let item = self.item?.0 else { + guard self.interaction.selectedMessageIds == nil, let item = self.item?.0 else { return false } self.interaction.openMessageContextActions(item.message, self.containerNode, self.containerNode.bounds, nil) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift index ce181a144b8..48d60356684 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -3798,7 +3798,7 @@ public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, ASScr statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, - inVoiceOver: false + inVoiceOver: UIAccessibility.isVoiceOverRunning ), navigationBarHeight: 0.0, topPadding: mapOverscrollInset + self.additionalNavigationHeight, From d9f29ddeb4d8f1b3ead18a0f5fcae8360e39217b Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:38:41 +0500 Subject: [PATCH 10/18] Improve VoiceOver accessibility in gifts and sharing Improve VoiceOver focus containment across Share Extension transitions. Add accessible labels, traits, loading states, topic selection, link copying, and account switching. Improve Gifts pane controls, empty states, collection transitions, and removal animations. --- .../Sources/ShareControllerNode.swift | 54 +++++-------------- .../Sources/ShareInputFieldNode.swift | 9 +++- .../Sources/ShareLoadingContainerNode.swift | 28 +++++++++- .../Sources/SharePeersContainerNode.swift | 11 ++++ .../Sources/ShareSearchContainerNode.swift | 2 + .../Sources/ShareTopicGridItem.swift | 18 +++++++ .../Sources/ShareTopicsContainerNode.swift | 4 ++ .../Sources/GiftsListView.swift | 25 +++++++++ .../Sources/PeerInfoGiftsPaneNode.swift | 20 +++++++ 9 files changed, 128 insertions(+), 43 deletions(-) diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 6cac8695294..7d69d239609 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -972,6 +972,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate func setActionNodesHidden(_ hidden: Bool, inputField: Bool = false, actions: Bool = false, animated: Bool = true) { func updateActionNodesAlpha(_ nodes: [ASDisplayNode], alpha: CGFloat) { for node in nodes { + node.accessibilityElementsHidden = alpha.isZero if !node.alpha.isEqual(to: alpha) { let previousAlpha = node.alpha node.alpha = alpha @@ -1007,6 +1008,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if let previous = previous { previous.setDidBeginDragging(nil) previous.setContentOffsetUpdated(nil) + previous.accessibilityElementsHidden = true if animated { transition = .animated(duration: 0.4, curve: .spring) self.previousContentNode = previous @@ -1034,6 +1036,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if let (layout, navigationBarHeight, bottomGridInset) = self.containerLayout { if let contentNode = contentNode, let previous = previous { contentNode.frame = previous.frame + contentNode.accessibilityElementsHidden = false contentNode.updateLayout(size: previous.bounds.size, isLandscape: layout.size.width > layout.size.height, bottomInset: bottomGridInset, transition: .immediate) contentNode.setDidBeginDragging({ [weak self] in @@ -1067,6 +1070,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } } else { if let contentNode = self.contentNode { + contentNode.accessibilityElementsHidden = false contentNode.setDidBeginDragging({ [weak self] in self?.contentNodeDidBeginDragging() }) @@ -1079,6 +1083,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } } else if let contentNode = contentNode { + contentNode.accessibilityElementsHidden = false contentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in self?.contentNodeOffsetUpdated(contentOffset, transition: transition) }) @@ -1202,7 +1207,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, - inVoiceOver: false + inVoiceOver: layout.inVoiceOver ) controller.presentationContext.containerLayoutUpdated(subLayout, transition: transition) } @@ -1412,19 +1417,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } self.inputFieldNode.deactivateInput() - let transition: ContainedViewLayoutTransition - if peerId == nil { - transition = .animated(duration: 0.12, curve: .easeInOut) - } else { - transition = .immediate - } - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true, animated: peerId == nil) let peerIds: [PeerId] var topicIds: [PeerId: Int64] = [:] @@ -1502,7 +1495,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate if long { strongSelf.transitionToContentNode(ShareProlongedLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true, environment: strongSelf.environment), fastOut: true) } else { - strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, forceNativeAppearance: true), fastOut: true) + strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true), fastOut: true) } } @@ -1717,15 +1710,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate case .preparing: if loadingTimestamp == nil { strongSelf.inputFieldNode.deactivateInput() - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: strongSelf.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = strongSelf.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } - strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, forceNativeAppearance: true), fastOut: true) + strongSelf.setActionNodesHidden(true, inputField: true, actions: true) + strongSelf.transitionToContentNode(ShareLoadingContainerNode(theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, forceNativeAppearance: true), fastOut: true) loadingTimestamp = CACurrentMediaTime() if reportReady { strongSelf.ready.set(.single(true)) @@ -1879,14 +1865,7 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate func transitionToProgress(signal: Signal) { self.inputFieldNode.deactivateInput() - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true) self.transitionToContentNode(ShareProlongedLoadingContainerNode(theme: self.presentationData.theme, strings: self.presentationData.strings, forceNativeAppearance: true, environment: self.environment), fastOut: true) let timestamp = CACurrentMediaTime() @@ -1920,16 +1899,9 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate completion() })) } else { - let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut) - transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0) - transition.updateAlpha(node: self.inputFieldNode, alpha: 0.0) - transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0) - transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0) - if let startAtTimestampNode = self.startAtTimestampNode { - transition.updateAlpha(node: startAtTimestampNode, alpha: 0.0) - } + self.setActionNodesHidden(true, inputField: true, actions: true) - self.transitionToContentNode(ShareLoadingContainerNode(theme: self.presentationData.theme, forceNativeAppearance: true), fastOut: true) + self.transitionToContentNode(ShareLoadingContainerNode(theme: self.presentationData.theme, strings: self.presentationData.strings, forceNativeAppearance: true), fastOut: true) let timestamp = CACurrentMediaTime() var wasDone = false diff --git a/submodules/ShareController/Sources/ShareInputFieldNode.swift b/submodules/ShareController/Sources/ShareInputFieldNode.swift index cab97511271..826e9fb3ab7 100644 --- a/submodules/ShareController/Sources/ShareInputFieldNode.swift +++ b/submodules/ShareController/Sources/ShareInputFieldNode.swift @@ -135,6 +135,9 @@ private final class ShareInputCopyComponent: Component { textView.mask = self.textMask } textView.frame = textFrame + textView.isAccessibilityElement = true + textView.accessibilityLabel = component.text + textView.accessibilityTraits = [.staticText] } let buttonSize = self.button.update( @@ -159,6 +162,9 @@ private final class ShareInputCopyComponent: Component { self.addSubview(buttonView) } buttonView.frame = buttonFrame + buttonView.isAccessibilityElement = true + buttonView.accessibilityLabel = component.strings.Conversation_LinkDialogCopy + buttonView.accessibilityTraits = [.button] } if self.textMask.image == nil { @@ -224,6 +230,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat public var placeholder: String = "" { didSet { self.placeholderNode.attributedText = NSAttributedString(string: self.placeholder, font: Font.regular(17.0), textColor: self.theme.placeholderColor) + self.textInputNode.textView.accessibilityLabel = self.placeholder } } @@ -245,7 +252,7 @@ public final class ShareInputFieldNode: ASDisplayNode, ASEditableTextNodeDelegat self.textInputNode.textContainerInset = UIEdgeInsets(top: self.inputInsets.top, left: 0.0, bottom: self.inputInsets.bottom, right: 0.0) self.textInputNode.keyboardAppearance = theme.keyboard.keyboardAppearance self.textInputNode.tintColor = theme.accentColor - self.textInputNode.textView.accessibilityHint = placeholder + self.textInputNode.textView.accessibilityLabel = placeholder self.placeholderNode = ASTextNode() self.placeholderNode.isUserInteractionEnabled = false diff --git a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift index 06ef0c4c471..f1056cf063a 100644 --- a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift +++ b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift @@ -47,6 +47,7 @@ public final class ShareLoadingContainerNode: ASDisplayNode, ShareContentContain private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)? private var theme: PresentationTheme + private let strings: PresentationStrings private let activityIndicator: ActivityIndicator private let statusNode: RadialStatusNode private let doneStatusNode: RadialStatusNode @@ -67,22 +68,42 @@ public final class ShareLoadingContainerNode: ASDisplayNode, ShareContentContain self.statusNode.transitionToState(.progress(color: self.theme.actionSheet.controlAccentColor, lineWidth: 2.0, value: 1.0, cancelEnabled: false, animateRotation: true), completion: {}) self.doneStatusNode.transitionToState(.check(self.theme.actionSheet.controlAccentColor), completion: {}) } + self.updateAccessibilityLabel() } } - public init(theme: PresentationTheme, forceNativeAppearance: Bool) { + public init(theme: PresentationTheme, strings: PresentationStrings, forceNativeAppearance: Bool) { self.theme = theme + self.strings = strings self.activityIndicator = ActivityIndicator(type: .custom(theme.actionSheet.controlAccentColor, !forceNativeAppearance ? 22.0 : 50.0, 2.0, forceNativeAppearance)) self.statusNode = RadialStatusNode(backgroundNodeColor: .clear) self.doneStatusNode = RadialStatusNode(backgroundNodeColor: .clear) super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = [.staticText, .updatesFrequently] + self.updateAccessibilityLabel() + self.activityIndicator.isAccessibilityElement = false + self.statusNode.isAccessibilityElement = false + self.doneStatusNode.isAccessibilityElement = false self.addSubnode(self.activityIndicator) self.addSubnode(self.statusNode) self.addSubnode(self.doneStatusNode) self.doneStatusNode.transitionToState(.progress(color: self.theme.actionSheet.controlAccentColor, lineWidth: 2.0, value: 0.0, cancelEnabled: false, animateRotation: true), completion: {}) } + + private func updateAccessibilityLabel() { + switch self.state { + case .preparing: + self.accessibilityLabel = self.strings.Channel_NotificationLoading + case let .progress(value): + self.accessibilityLabel = self.strings.Share_UploadProgress(Int(value * 100.0)).string + case .done: + self.accessibilityLabel = self.strings.Share_UploadDone + } + } public func activate() { } @@ -249,6 +270,8 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte self.progressTextNode = ImmediateTextNode() self.progressTextNode.textAlignment = .center + self.progressTextNode.isAccessibilityElement = true + self.progressTextNode.accessibilityTraits = [.staticText, .updatesFrequently] self.progressBackgroundNode = ASDisplayNode() self.progressBackgroundNode.backgroundColor = theme.actionSheet.controlAccentColor.withMultipliedAlpha(0.2) @@ -350,6 +373,7 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte } self.progressTextNode.attributedText = NSAttributedString(string: progressText, font: Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]), textColor: self.theme.actionSheet.primaryTextColor) + self.progressTextNode.accessibilityLabel = progressText let progressTextSize = self.progressTextNode.updateLayout(size) let progressTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - progressTextSize.width) / 2.0), y: progressFrame.minY - spacing - 9.0 - progressTextSize.height), size: progressTextSize) self.progressTextNode.frame = progressTextFrame @@ -359,9 +383,11 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte let animationFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: (progressTextFrame.minY - imageSize.height - 20.0)), size: imageSize) self.animationNode.frame = animationFrame + self.animationNode.isAccessibilityElement = false self.animationNode.updateLayout(size: imageSize) self.doneAnimationNode.frame = animationFrame + self.doneAnimationNode.isAccessibilityElement = false self.doneAnimationNode.updateLayout(size: imageSize) self.contentOffsetUpdated?(-size.height + nodeHeight * 0.5, transition) diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index 1bc44a39f92..1a41fd845e5 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -236,15 +236,24 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { emptyColor: nil, synchronousLoad: false ) + self.contentTitleAccountNode.isAccessibilityElement = true + self.contentTitleAccountNode.accessibilityLabel = strings.Shortcut_SwitchAccount + self.contentTitleAccountNode.accessibilityValue = info.peer.compactDisplayTitle + self.contentTitleAccountNode.accessibilityTraits = [.button] } else { self.contentTitleAccountNode.isHidden = true + self.contentTitleAccountNode.isAccessibilityElement = false } self.searchButtonNode = HighlightableButtonNode() self.searchButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/SearchIcon"), color: self.theme.actionSheet.controlAccentColor), for: []) + self.searchButtonNode.accessibilityLabel = strings.Common_Search + self.searchButtonNode.accessibilityTraits = [.button] self.shareButtonNode = HighlightableButtonNode() self.shareButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Share/ShareIcon"), color: self.theme.actionSheet.controlAccentColor), for: []) + self.shareButtonNode.accessibilityLabel = strings.ShareMenu_ShareTo + self.shareButtonNode.accessibilityTraits = [.button] self.shareReferenceNode = ContextReferenceContentNode() self.shareContainerNode = ContextControllerSourceNode() @@ -272,6 +281,8 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } super.init() + + self.contentTitleNode.accessibilityTraits = [.header] self.addSubnode(self.contentGridNode) self.addSubnode(self.headerNode) diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index a53d67687ac..29220c1508e 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -246,6 +246,8 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.cancelButtonNode = HighlightableButtonNode() self.cancelButtonNode.setTitle(strings.Common_Cancel, with: cancelFont, with: theme.actionSheet.controlAccentColor, for: []) self.cancelButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) + self.cancelButtonNode.accessibilityLabel = strings.Common_Cancel + self.cancelButtonNode.accessibilityTraits = [.button] self.contentSeparatorNode = ASDisplayNode() self.contentSeparatorNode.isLayerBacked = true diff --git a/submodules/ShareController/Sources/ShareTopicGridItem.swift b/submodules/ShareController/Sources/ShareTopicGridItem.swift index 015d0b76488..e27f92c389b 100644 --- a/submodules/ShareController/Sources/ShareTopicGridItem.swift +++ b/submodules/ShareController/Sources/ShareTopicGridItem.swift @@ -72,6 +72,10 @@ final class ShareTopicGridItemNode: GridItemNode { self.textNode.textAlignment = .center super.init() + + self.isAccessibilityElement = false + self.accessibilityTraits = [.button] + self.textNode.isAccessibilityElement = false self.addSubnode(self.textNode) } @@ -91,6 +95,14 @@ final class ShareTopicGridItemNode: GridItemNode { } } } + + override func accessibilityActivate() -> Bool { + guard self.currentItem?.peer != nil else { + return false + } + self.tapped() + return true + } override func updateAbsoluteRect(_ absoluteRect: CGRect, within containerSize: CGSize) { let rect = absoluteRect @@ -107,8 +119,11 @@ final class ShareTopicGridItemNode: GridItemNode { return } self.currentItem = item + self.isAccessibilityElement = item.peer != nil + self.accessibilityLabel = nil if let threadInfo = item.threadInfo { + self.accessibilityLabel = threadInfo.info.title self.textNode.attributedText = NSAttributedString(string: threadInfo.info.title, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) let iconContent: EmojiStatusComponent.Content @@ -138,9 +153,11 @@ final class ShareTopicGridItemNode: GridItemNode { if iconComponentView.superview == nil { self.view.addSubview(iconComponentView) } + iconComponentView.accessibilityElementsHidden = true iconComponentView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: 7.0), size: iconSize) } } else if let peer = item.peer, let mainPeer = peer.chatMainPeer { + self.accessibilityLabel = mainPeer.compactDisplayTitle self.textNode.attributedText = NSAttributedString(string: mainPeer.compactDisplayTitle, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) let avatarNode: AvatarNode @@ -148,6 +165,7 @@ final class ShareTopicGridItemNode: GridItemNode { avatarNode = current } else { avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0)) + avatarNode.isAccessibilityElement = false self.avatarNode = avatarNode self.addSubnode(avatarNode) } diff --git a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift index 18d5b6e4a23..0989a2c9db7 100644 --- a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift +++ b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift @@ -95,6 +95,8 @@ private class CancelButtonNode: ASDisplayNode { self.strings = strings self.buttonNode = HighlightTrackingButtonNode() + self.buttonNode.accessibilityLabel = strings.Common_Back + self.buttonNode.accessibilityTraits = [.button] self.arrowNode = ASImageNode() self.arrowNode.displaysAsynchronously = false @@ -237,6 +239,8 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { self.backNode = CancelButtonNode(theme: theme, strings: strings) super.init() + + self.contentTitleNode.accessibilityTraits = [.header] self.addSubnode(self.contentGridNode) self.addSubnode(self.headerNode) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift index e1c98a1ac32..24f2630ba91 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -747,6 +747,7 @@ final class GiftsListView: UIView { if !validIds.contains(id) { removeIds.append(id) if let itemView = item.1.view { + itemView.accessibilityElementsHidden = true if !transition.animation.isImmediate { itemView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.25, removeOnCompletion: false) itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in @@ -859,6 +860,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Title + view.accessibilityTraits = [.header] } if let view = self.emptyResultsText.view { if view.superview == nil { @@ -868,6 +872,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTextFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Text + view.accessibilityTraits = [.staticText] } if let view = self.emptyResultsAction.view { if view.superview == nil { @@ -877,6 +884,12 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_EmptyCollection_Action + view.accessibilityTraits = [.button] + for subview in view.subviews { + subview.accessibilityElementsHidden = true + } } } else if self.filteredResultsAreEmpty { let sideInset: CGFloat = 44.0 @@ -951,6 +964,7 @@ final class GiftsListView: UIView { self.emptyResultsClippingView.addSubview(view) view.playOnce() } + view.accessibilityElementsHidden = true view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) panelTransition.setPosition(view: view, position: emptyResultsAnimationFrame.center) } @@ -962,6 +976,9 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) panelTransition.setPosition(view: view, position: emptyResultsTitleFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_NoResults + view.accessibilityTraits = [.header] } if let view = self.emptyResultsAction.view { if view.superview == nil { @@ -971,8 +988,15 @@ final class GiftsListView: UIView { } view.bounds = CGRect(origin: .zero, size: emptyResultsActionFrame.size) panelTransition.setPosition(view: view, position: emptyResultsActionFrame.center) + view.isAccessibilityElement = true + view.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_NoResults_ViewAll + view.accessibilityTraits = [.button] + for subview in view.subviews { + subview.accessibilityElementsHidden = true + } } } else { + self.emptyResultsClippingView.accessibilityElementsHidden = true if let view = self.emptyResultsAnimation.view { fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in view.removeFromSuperview() @@ -997,6 +1021,7 @@ final class GiftsListView: UIView { } fadeTransition.setAlpha(view: self.emptyResultsClippingView, alpha: visibleHeight < 300.0 ? 0.0 : 1.0) + self.emptyResultsClippingView.accessibilityElementsHidden = (!self.resultsAreEmpty && !self.filteredResultsAreEmpty) || self.emptyResultsClippingView.isHidden || visibleHeight < 300.0 if self.peerId == self.context.account.peerId, !self.canSelect && !self.filteredResultsAreEmpty && self.profileGifts.collectionId == nil && self.emptyResultsClippingView.isHidden { let footerText: ComponentView diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index ee9ea2d9fc6..d893925f24a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -470,6 +470,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr } self.giftsListView.parentController = self.parentController self.giftsListView.frame = previousGiftsListView.frame + previousGiftsListView.accessibilityElementsHidden = true + self.giftsListView.accessibilityElementsHidden = false self.scrollNode.view.insertSubview(self.giftsListView, aboveSubview: previousGiftsListView) @@ -709,6 +711,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr if let tabSelectorView = self.tabSelector.view { if tabSelectorView.superview == nil { tabSelectorView.alpha = 1.0 + tabSelectorView.accessibilityElementsHidden = false self.scrollNode.view.insertSubview(tabSelectorView, at: 0) if !transition.animation.isImmediate { @@ -720,6 +723,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr topInset += tabSelectorSize.height + 15.0 } } else if let tabSelectorView = self.tabSelector.view { + tabSelectorView.accessibilityElementsHidden = true tabSelectorView.alpha = 0.0 tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, completion: { _ in tabSelectorView.removeFromSuperview() @@ -834,6 +838,12 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelContentContainer.addSubview(panelButtonView) } panelButtonView.frame = CGRect(origin: CGPoint(x: buttonInsets.left, y: 8.0), size: panelButtonSize) + panelButtonView.isAccessibilityElement = true + panelButtonView.accessibilityLabel = buttonTitle + panelButtonView.accessibilityTraits = [.button] + for subview in panelButtonView.subviews { + subview.accessibilityElementsHidden = true + } } panelTransition.setFrame(view: panelContentContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - bottomPanelHeight), size: CGSize(width: size.width, height: bottomPanelHeight))) @@ -904,6 +914,15 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelContentContainer.addSubview(panelCheckView) } panelCheckView.frame = CGRect(origin: CGPoint(x: floor((size.width - panelCheckSize.width) / 2.0), y: 16.0 + 16.0), size: panelCheckSize) + panelCheckView.isAccessibilityElement = true + panelCheckView.accessibilityLabel = presentationData.strings.PeerInfo_Gifts_ChannelNotify + panelCheckView.accessibilityTraits = [.button] + if self.profileGifts.currentState?.notificationsEnabled == true { + panelCheckView.accessibilityTraits.insert(.selected) + } + for subview in panelCheckView.subviews { + subview.accessibilityElementsHidden = true + } } if let panelButtonView = panelButton.view { panelButtonView.isHidden = true @@ -915,6 +934,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr panelEdgeEffectView.update(content: presentationData.theme.list.blocksBackgroundColor, blur: false, rect: edgeEffectFrame, edge: .bottom, edgeSize: 40.0, transition: panelTransition) ComponentTransition.spring(duration: 0.4).setSublayerTransform(view: panelContentContainer, transform: CATransform3DMakeTranslation(0.0, bottomPanelHeight * (1.0 - panelVisibility), 0.0)) + panelContentContainer.accessibilityElementsHidden = panelVisibility == 0.0 contentHeight += bottomPanelHeight bottomScrollInset = bottomPanelHeight - 40.0 From 186418800fdfb9461e5fdb354316e39f37ec5c14 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:48:48 +0500 Subject: [PATCH 11/18] Improve VoiceOver accessibility in selection flows Add accessible navigation labels to gift, contact, chat, and search selection screens. Hide the offscreen Add Gifts action panel when no gifts are selected. Keep VoiceOver scroll status localized when presentation data changes. --- .../Sources/AddGiftsScreen.swift | 9 ++++++++- .../Sources/ChatSearchResultsContollerNode.swift | 10 +++++++--- .../Sources/ChatSearchResultsController.swift | 5 ++++- .../Sources/ContactMultiselectionController.swift | 10 ++++++++-- .../Sources/ContactMultiselectionControllerNode.swift | 5 +++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift index fe41b91c02b..404ac155b22 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/AddGiftsScreen.swift @@ -184,6 +184,7 @@ final class AddGiftsScreenComponent: Component { if buttonPanelView.superview == nil { self.addSubview(buttonPanelView) } + buttonPanelView.accessibilityElementsHidden = giftsListView.selectedItems.isEmpty transition.setFrame(view: buttonPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - bottomPanelSize.height + bottomPanelOffset), size: bottomPanelSize)) } @@ -266,7 +267,9 @@ public final class AddGiftsScreen: ViewControllerComponentContainer { } self.filterButton.addTarget(self, action: #selector(self.filterPressed), forControlEvents: .touchUpInside) - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(self.cancelPressed)) + closeButtonItem.accessibilityLabel = presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.filterButton) } @@ -426,6 +429,10 @@ private final class FilterHeaderButton: HighlightableButtonNode { super.init() + self.isAccessibilityElement = true + self.accessibilityLabel = presentationData.strings.Common_More + self.accessibilityTraits = [.button] + self.containerNode.addSubnode(self.referenceNode) self.addSubnode(self.containerNode) diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index b29ac66d01c..2d23d0a97ba 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -200,11 +200,15 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } super.init() + + self.listNode.accessibilityPageScrolledString = { [weak self] row, count in + guard let self else { + return "" + } + return self.presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } self.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.isOpaque = false diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsController.swift b/submodules/TelegramUI/Sources/ChatSearchResultsController.swift index 82bb22f0c1c..a30815f552f 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsController.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsController.swift @@ -43,6 +43,7 @@ final class ChatSearchResultsController: ViewController { if let strongSelf = self { strongSelf.presentationData = presentationData strongSelf.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationTheme: presentationData.theme, presentationStrings: presentationData.strings), transition: .immediate) + strongSelf.navigationItem.rightBarButtonItem?.accessibilityLabel = presentationData.strings.Common_Done strongSelf.controllerNode.updatePresentationData(presentationData) } }) @@ -51,7 +52,9 @@ final class ChatSearchResultsController: ViewController { self.title = searchQuery self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: UIView()) - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(donePressed)) + let doneButtonItem = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(donePressed)) + doneButtonItem.accessibilityLabel = self.presentationData.strings.Common_Done + self.navigationItem.rightBarButtonItem = doneButtonItem } required init(coder aDecoder: NSCoder) { diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index 908f39f5e6d..0b6ad80c33b 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -299,16 +299,22 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.PrivacyLastSeenSettings_EmpryUsersPlaceholder, counter: "") if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done self.rightNavigationButton = rightNavigationButton - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + closeButtonItem.accessibilityLabel = self.presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = self.rightNavigationButton } case let .chatSelection(chatSelection): self.titleView.title = CounterControllerTitle(title: self.params.title ?? chatSelection.title, counter: "") if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done self.rightNavigationButton = rightNavigationButton - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + let closeButtonItem = UIBarButtonItem(title: "___close", style: .plain, target: self, action: #selector(cancelPressed)) + closeButtonItem.accessibilityLabel = self.presentationData.strings.Common_Close + self.navigationItem.leftBarButtonItem = closeButtonItem self.navigationItem.rightBarButtonItem = self.rightNavigationButton } } diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift index 881bfd5b67d..451938718af 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift @@ -449,6 +449,11 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData self.backgroundColor = presentationData.theme.chatList.backgroundColor + if case let .chats(chatListNode) = self.contentNode { + chatListNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + } } func scrollToTop() { From 047760a60de518caf010383a77569a854e298d2e Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:53:33 +0500 Subject: [PATCH 12/18] Keep VoiceOver scroll feedback consistently localized Update accessibility scroll status from current presentation data across peer info and input context lists. Remove stale initialization-time localization captures. Expose multiselection titles as headings with the current selection count. --- .../PeerInfoGroupsInCommonPaneNode.swift | 7 ++-- .../Sources/Panes/PeerInfoMembersPane.swift | 7 ++-- .../Panes/PeerInfoRecommendedPeersPane.swift | 7 ++-- .../CommandChatInputContextPanelNode.swift | 6 +-- ...CommandMenuChatInputContextPanelNode.swift | 6 +-- .../ContactMultiselectionController.swift | 41 ++++++++----------- .../EmojisChatInputContextPanelNode.swift | 6 +-- .../HashtagChatInputContextPanelNode.swift | 6 +-- ...textResultsChatInputContextPanelNode.swift | 6 +-- .../MentionChatInputContextPanelNode.swift | 6 +-- ...textResultsChatInputContextPanelNode.swift | 6 +-- 11 files changed, 46 insertions(+), 58 deletions(-) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift index cb27d285c69..362ea9f2b0e 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift @@ -112,11 +112,7 @@ final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { self.openPeerContextAction = openPeerContextAction self.groupsInCommonContext = groupsInCommonContext - let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.listNode = ListViewImpl() - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } self.listBackgroundView = UIImageView() self.listBackgroundView.image = generateStretchableFilledCircleImage(diameter: 26.0 * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate) @@ -194,6 +190,9 @@ final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { let isFirstLayout = self.currentParams == nil self.currentParams = (size, isScrollingLockedAtTop, presentationData) + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } self.ignoreListBackgroundUpdates = true transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift index 82f9ed6b502..5655bdcfd68 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift @@ -322,11 +322,7 @@ final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { self.addMemberAction = addMemberAction self.action = action - let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.listNode = ListViewImpl() - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } self.listBackgroundView = UIImageView() self.listBackgroundView.image = generateStretchableFilledCircleImage(diameter: 26.0 * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate) @@ -408,6 +404,9 @@ final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { let isFirstLayout = self.currentParams == nil self.currentParams = (size, isScrollingLockedAtTop) + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } self.presentationDataPromise.set(.single(presentationData)) self.ignoreListBackgroundUpdates = true diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift index 2998ab90689..95a7038fe2f 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift @@ -156,11 +156,7 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { self.chatControllerInteraction = chatControllerInteraction self.openPeerContextAction = openPeerContextAction - let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.listNode = ListViewImpl() - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } super.init() @@ -222,6 +218,9 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, deviceMetrics: DeviceMetrics, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, navigationHeight: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { let isFirstLayout = self.currentParams == nil self.currentParams = (size, sideInset, bottomInset, isScrollingLockedAtTop, presentationData) + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } self.presentationDataPromise.set(.single(presentationData)) transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size)) diff --git a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift index 2accdfc015a..e66861332be 100644 --- a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift @@ -305,9 +305,6 @@ final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true self.listView.view.disablesInteractiveTransitionGestureRecognizer = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) @@ -498,6 +495,9 @@ final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) diff --git a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift index 6c548240928..f5684e09c7d 100644 --- a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift @@ -84,9 +84,6 @@ final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode { self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true self.listView.view.disablesInteractiveTransitionGestureRecognizer = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } self.listMaskView = UIImageView() @@ -241,6 +238,9 @@ final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode { } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) diff --git a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift index 0b6ad80c33b..535ecdb000d 100644 --- a/submodules/TelegramUI/Sources/ContactMultiselectionController.swift +++ b/submodules/TelegramUI/Sources/ContactMultiselectionController.swift @@ -231,6 +231,14 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection } private func updateTitle() { + func updateTitleView(title: String, counter: String?) { + self.titleView.title = CounterControllerTitle(title: title, counter: counter) + self.titleView.isAccessibilityElement = true + self.titleView.accessibilityLabel = title + self.titleView.accessibilityValue = counter.flatMap { $0.isEmpty ? nil : $0 } + self.titleView.accessibilityTraits = [.header] + } + var updatedCount: Int = 0 switch self.contactsNode.contentNode { case let .contacts(contactsNode): @@ -261,13 +269,13 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection count = chatsNode.currentState.selectedPeerIds.count } if isCall && count <= 1 { - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: nil) + updateTitleView(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: nil) } else { var count = count if isCall { count += 1 } - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: "\(count)/\(maxCount)") + updateTitleView(title: self.params.title ?? self.presentationData.strings.Compose_NewGroupTitle, counter: "\(count)/\(maxCount)") } if self.rightNavigationButton == nil && !isCall { let rightNavigationButton = UIBarButtonItem(title: self.presentationData.strings.Common_Next, style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) @@ -280,23 +288,23 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection if case let .contacts(contactsNode) = self.contactsNode.contentNode { count = contactsNode.selectionState?.selectedPeerIndices.count ?? 0 } - self.titleView.title = CounterControllerTitle(title: self.params.title ?? (hasActions ? self.presentationData.strings.Premium_Gift_ContactSelection_Title : self.presentationData.strings.Stars_Purchase_GiftStars), counter: "\(count)/\(maxCount)") + updateTitleView(title: self.params.title ?? (hasActions ? self.presentationData.strings.Premium_Gift_ContactSelection_Title : self.presentationData.strings.Stars_Purchase_GiftStars), counter: "\(count)/\(maxCount)") case .requestedUsersSelection: let maxCount: Int32 = self.limit ?? 10 var count = 0 if case let .contacts(contactsNode) = self.contactsNode.contentNode { count = contactsNode.selectionState?.selectedPeerIndices.count ?? 0 } - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.RequestPeer_SelectUsers, counter: "\(count)/\(maxCount)") + updateTitleView(title: self.params.title ?? self.presentationData.strings.RequestPeer_SelectUsers, counter: "\(count)/\(maxCount)") case .channelCreation: - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.GroupInfo_AddParticipantTitle, counter: "") + updateTitleView(title: self.params.title ?? self.presentationData.strings.GroupInfo_AddParticipantTitle, counter: nil) if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: self.presentationData.strings.Common_Next, style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) self.rightNavigationButton = rightNavigationButton self.navigationItem.rightBarButtonItem = self.rightNavigationButton } case .peerSelection: - self.titleView.title = CounterControllerTitle(title: self.params.title ?? self.presentationData.strings.PrivacyLastSeenSettings_EmpryUsersPlaceholder, counter: "") + updateTitleView(title: self.params.title ?? self.presentationData.strings.PrivacyLastSeenSettings_EmpryUsersPlaceholder, counter: nil) if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done @@ -307,7 +315,7 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection self.navigationItem.rightBarButtonItem = self.rightNavigationButton } case let .chatSelection(chatSelection): - self.titleView.title = CounterControllerTitle(title: self.params.title ?? chatSelection.title, counter: "") + updateTitleView(title: self.params.title ?? chatSelection.title, counter: nil) if self.rightNavigationButton == nil { let rightNavigationButton = UIBarButtonItem(title: "___done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) rightNavigationButton.accessibilityLabel = self.presentationData.strings.Common_Done @@ -588,24 +596,7 @@ class ContactMultiselectionControllerImpl: ViewController, ContactMultiselection case .channelCreation, .premiumGifting, .requestedUsersSelection: break } - switch strongSelf.mode { - case let .groupCreation(isCall): - let maxCount: Int32 - if isCall { - maxCount = strongSelf.context.userLimits.maxConferenceParticipantCount - } else { - maxCount = strongSelf.limitsConfiguration?.maxSupergroupMemberCount ?? 5000 - } - strongSelf.titleView.title = CounterControllerTitle(title: strongSelf.presentationData.strings.Compose_NewGroupTitle, counter: "\(updatedCount)/\(maxCount)") - case .premiumGifting: - let maxCount: Int32 = strongSelf.limit ?? 10 - strongSelf.titleView.title = CounterControllerTitle(title: strongSelf.presentationData.strings.Premium_Gift_ContactSelection_Title, counter: "\(updatedCount)/\(maxCount)") - case .requestedUsersSelection: - let maxCount: Int32 = strongSelf.limit ?? 10 - strongSelf.titleView.title = CounterControllerTitle(title: strongSelf.presentationData.strings.RequestPeer_SelectUsers, counter: "\(updatedCount)/\(maxCount)") - case .peerSelection, .channelCreation, .chatSelection: - break - } + strongSelf.updateTitle() } if let removedTokenId = removedTokenId { diff --git a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift index a883bd5f438..158a0872c7b 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift @@ -160,9 +160,6 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { self.listView.isOpaque = false self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) @@ -548,6 +545,9 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) self.presentationInterfaceState = interfaceState diff --git a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift index fdd72a5cd69..4a473f241d8 100644 --- a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift @@ -100,9 +100,6 @@ final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true self.listView.view.disablesInteractiveTransitionGestureRecognizer = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) @@ -319,6 +316,9 @@ final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index 76fcf7bc4b2..038541918c0 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -111,9 +111,6 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont self.listView.isOpaque = false self.listView.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) self.listView.isHidden = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } self.batchVideoContext = QueueLocalObject(queue: .mainQueue(), generate: { return BatchVideoRenderingContext(context: context) @@ -374,6 +371,9 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let listHeight: CGFloat = 105.0 let sideInset: CGFloat = 8.0 let innerInset: CGFloat = 4.0 diff --git a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift index 41ee2830328..d7d5b4e1d71 100644 --- a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift @@ -85,9 +85,6 @@ final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true self.listView.view.disablesInteractiveTransitionGestureRecognizer = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) @@ -261,6 +258,9 @@ final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) diff --git a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift index a352a13a086..84c174dcf62 100644 --- a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift @@ -150,9 +150,6 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex self.listView.limitHitTestToNodes = true self.listView.isHidden = true self.listView.view.disablesInteractiveTransitionGestureRecognizer = true - self.listView.accessibilityPageScrolledString = { row, count in - return strings.VoiceOver_ScrollStatus(row, count).string - } self.listMaskView = UIImageView() @@ -324,6 +321,9 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + self.listView.accessibilityPageScrolledString = { row, count in + return interfaceState.strings.VoiceOver_ScrollStatus(row, count).string + } let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) From ca99e370a6395a0c9799ee92807a18e55b998faa Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:20:31 +0500 Subject: [PATCH 13/18] Improve VoiceOver selection flows and Share Extension Add selected and disabled accessibility states to share and gift selection flows. Preserve VoiceOver focus across peer, topic, search, and gift transactions. Improve Share Extension focus transitions, modal containment, Escape handling, context menu dismissal, and send error presentation. Exclude hidden animated content from the accessibility tree. --- .../Sources/ShareController.swift | 7 ++ .../Sources/ShareControllerNode.swift | 75 ++++++++++++++++++- .../Sources/ShareControllerPeerGridItem.swift | 27 +++++-- .../Sources/SharePeersContainerNode.swift | 43 ++++++++++- .../Sources/ShareSearchBarNode.swift | 4 + .../Sources/ShareSearchContainerNode.swift | 57 +++++++++++++- .../Sources/ShareTopicGridItem.swift | 16 ++++ .../Sources/ShareTopicsContainerNode.swift | 24 +++++- .../Sources/GiftsListView.swift | 55 ++++++++++++++ 9 files changed, 296 insertions(+), 12 deletions(-) diff --git a/submodules/ShareController/Sources/ShareController.swift b/submodules/ShareController/Sources/ShareController.swift index b69ebe61d24..b410d50121b 100644 --- a/submodules/ShareController/Sources/ShareController.swift +++ b/submodules/ShareController/Sources/ShareController.swift @@ -2376,8 +2376,15 @@ public final class ShareController: ViewController { if !self.immediateExternalShare { self.controllerNode.animateIn() } + Queue.mainQueue().after(0.1) { [weak self] in + self?.controllerNode.activateInitialAccessibilityFocus() + } } } + + override public func accessibilityPerformEscape() -> Bool { + return self.controllerNode.performAccessibilityEscape() + } override public func dismiss(completion: (() -> Void)? = nil) { self.controllerNode.view.endEditing(true) diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 7d69d239609..484dfda7b77 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -591,7 +591,12 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } })) ]) - return ContextController.Items(content: .list(items), animationCache: nil) + return ContextController.Items(content: .list(items), dismissed: { [weak self] in + guard let self, UIAccessibility.isVoiceOverRunning, !self.actionButtonNode.accessibilityElementsHidden, self.actionButtonNode.alpha > 0.0, self.actionButtonNode.view.window != nil else { + return + } + UIAccessibility.post(notification: .layoutChanged, argument: self.actionButtonNode.view) + }, animationCache: nil) } let contextController = makeContextController(presentationData: presentationData, source: .reference(ShareContextReferenceContentSource(sourceNode: node, customPosition: CGPoint(x: 0.0, y: fromForeignApp ? -116.0 : 0.0))), items: items, gesture: gesture) contextController.immediateItemsTransitionAnimation = true @@ -784,6 +789,8 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate override func didLoad() { super.didLoad() + + self.view.accessibilityViewIsModal = true if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never @@ -861,6 +868,10 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate self?.contentNodeOffsetUpdated(contentOffset, transition: transition) }) strongSelf.contentNodeOffsetUpdated(topicsContentNode.contentGridNode.scrollView.contentOffset.y, transition: .animated(duration: 0.4, curve: .spring)) + + if UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .screenChanged, argument: topicsContentNode.accessibilityInitialFocusTarget) + } strongSelf.view.endEditing(true) } @@ -901,6 +912,9 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } }) } + if UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .screenChanged, argument: searchContentNode.accessibilityFocusTarget(peerId: peerId) ?? searchContentNode.accessibilityInitialFocusTarget) + } } else if let peersContentNode = self.peersContentNode { peersContentNode.setDidBeginDragging({ [weak self] in self?.contentNodeDidBeginDragging() @@ -919,7 +933,40 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } }) } + if UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .screenChanged, argument: peersContentNode.accessibilityFocusTarget(peerId: peerId)) + } + } + } + + func activateInitialAccessibilityFocus() { + guard UIAccessibility.isVoiceOverRunning else { + return + } + let target: Any? + if let topicsContentNode = self.topicsContentNode { + target = topicsContentNode.accessibilityInitialFocusTarget + } else if let searchContentNode = self.contentNode as? ShareSearchContainerNode { + target = searchContentNode.accessibilityInitialFocusTarget + } else if let peersContentNode = self.peersContentNode { + target = peersContentNode.accessibilityFocusTarget() + } else { + target = self.contentNode?.view } + UIAccessibility.post(notification: .screenChanged, argument: target) + } + + func performAccessibilityEscape() -> Bool { + if let topicsContentNode = self.topicsContentNode { + topicsContentNode.backPressed() + return true + } + if self.contentNode is ShareSearchContainerNode, let peersContentNode = self.peersContentNode { + self.transitionToContentNode(peersContentNode) + return true + } + self.cancel?() + return true } func updatePresentationData(_ presentationData: PresentationData) { @@ -1068,6 +1115,12 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate } else if !(contentNode is ShareLoadingContainer) { self.setActionNodesHidden(false, inputField: !self.controllerInteraction!.selectedPeers.isEmpty || self.presetText != nil || self.mediaParameters?.publicLinkPrefix != nil, actions: true) } + + if let searchContentNode = contentNode as? ShareSearchContainerNode, UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .screenChanged, argument: searchContentNode.accessibilityInitialFocusTarget) + } else if contentNode === self.peersContentNode, previous is ShareSearchContainerNode, let peersContentNode = self.peersContentNode, UIAccessibility.isVoiceOverRunning { + UIAccessibility.post(notification: .screenChanged, argument: peersContentNode.accessibilitySearchFocusTarget) + } } else { if let contentNode = self.contentNode { contentNode.accessibilityElementsHidden = false @@ -1525,8 +1578,24 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate strongSelf.dismiss?(true) } } - }, error: { _ in - + }, error: { [weak self] error in + guard let self else { + return + } + if let peersContentNode = self.peersContentNode { + self.transitionToContentNode(peersContentNode, fastOut: true) + } + switch error { + case .generic: + self.presentError(nil, self.presentationData.strings.Login_UnknownError) + case let .fileTooBig(size): + self.presentError( + self.presentationData.strings.Notifications_UploadError_TooLarge_Title, + self.presentationData.strings.Notifications_UploadError_TooLarge_Text( + dataSizeString(size, formatting: DataSizeStringFormatting(presentationData: self.presentationData)) + ).string + ) + } }, completed: { if !wasDone && fromForeignApp { doneImpl(false) diff --git a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift index c4a1463ef47..ba2237eca02 100644 --- a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift +++ b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift @@ -79,8 +79,9 @@ final class ShareControllerGridSectionNode: ASDisplayNode { super.init() self.isAccessibilityElement = true - self.accessibilityTraits = .button - self.peerNode.accessibilityElementsHidden = true + self.accessibilityLabel = title + self.accessibilityTraits = .header + self.titleNode.accessibilityElementsHidden = true self.addSubnode(self.backgroundNode) self.addSubnode(self.titleNode) @@ -183,6 +184,10 @@ final class ShareControllerPeerGridItemNode: GridItemNode { self.peerNode = SelectablePeerNode() super.init() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + self.peerNode.accessibilityElementsHidden = true self.peerNode.toggleSelection = { [weak self] isDisabled in if let strongSelf = self { @@ -325,10 +330,17 @@ final class ShareControllerPeerGridItemNode: GridItemNode { func updateSelection(animated: Bool) { var selected = false + var isDisabled = false if let controllerInteraction = self.controllerInteraction, let (_, _, _, _, maybeItem, _) = self.currentState, let item = maybeItem { - if case let .peer(peer, _, _, _, _, _) = item { + if case let .peer(peer, _, _, _, requiresPremiumForMessaging, _) = item { selected = controllerInteraction.selectedPeerIds.contains(peer.peerId) + isDisabled = requiresPremiumForMessaging + self.accessibilityHint = requiresPremiumForMessaging ? self.currentState?.strings.Chat_ToastMessagingRestrictedToPremium_Text(peer.peer?.compactDisplayTitle ?? "").string : nil + } else { + self.accessibilityHint = nil } + } else { + self.accessibilityHint = nil } self.peerNode.updateSelection(selected: selected, animated: animated) @@ -338,6 +350,11 @@ final class ShareControllerPeerGridItemNode: GridItemNode { } else { self.accessibilityTraits.remove(.selected) } + if isDisabled { + self.accessibilityTraits.insert(.notEnabled) + } else { + self.accessibilityTraits.remove(.notEnabled) + } } override func accessibilityActivate() -> Bool { @@ -345,8 +362,8 @@ final class ShareControllerPeerGridItemNode: GridItemNode { return false } switch item { - case let .peer(peer, _, _, _, requiresPremiumForMessaging, requiresStars): - if requiresPremiumForMessaging || requiresStars != nil { + case let .peer(peer, _, _, _, requiresPremiumForMessaging, _): + if requiresPremiumForMessaging { controllerInteraction.disabledPeerSelected(peer) } else { controllerInteraction.togglePeer(peer, self.currentState?.search ?? false) diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index 1a41fd845e5..12c883e396e 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -363,18 +363,56 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { private func dequeueTransition() { if let (transition, _) = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) + + var focusedPeerId: EnginePeer.Id? + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.view.accessibilityElementIsFocused() { + focusedPeerId = itemNode.peerId + } + } var itemTransition: ContainedViewLayoutTransition = .immediate if transition.animated { itemTransition = .animated(duration: 0.3, curve: .spring) } - self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { [weak self] _ in + guard let self, let focusedPeerId else { + return + } + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == focusedPeerId, !itemNode.view.accessibilityElementIsFocused() { + UIAccessibility.post(notification: .layoutChanged, argument: itemNode.view) + } + } + }) } } func setEnsurePeerVisibleOnLayout(_ peerId: EnginePeer.Id?) { self.ensurePeerVisibleOnLayout = peerId } + + func accessibilityFocusTarget(peerId: EnginePeer.Id? = nil) -> Any? { + if let peerId { + var result: Any? + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + result = itemNode.view + } + } + if let result { + return result + } + } + if self.segmentedValues != nil { + return self.segmentedNode.view + } + return self.contentGridNode.view + } + + var accessibilitySearchFocusTarget: Any { + return self.searchButtonNode.view + } func setDidBeginDragging(_ f: (() -> Void)?) { self.contentDidBeginDragging = f @@ -430,6 +468,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } func prepareForAnimateIn() { + self.accessibilityElementsHidden = true self.searchButtonNode.alpha = 0.0 self.shareButtonNode.alpha = 0.0 self.contentTitleNode.alpha = 0.0 @@ -438,6 +477,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { + self.accessibilityElementsHidden = false self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.searchButtonNode.alpha = 1.0 @@ -510,6 +550,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } func animateOut(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { + self.accessibilityElementsHidden = true self.headerNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.searchButtonNode.alpha = 0.0 diff --git a/submodules/ShareController/Sources/ShareSearchBarNode.swift b/submodules/ShareController/Sources/ShareSearchBarNode.swift index 67b01a43563..7b68b779dba 100644 --- a/submodules/ShareController/Sources/ShareSearchBarNode.swift +++ b/submodules/ShareController/Sources/ShareSearchBarNode.swift @@ -18,6 +18,10 @@ final class ShareSearchBarNode: ASDisplayNode, UITextFieldDelegate { private let inputInsets = UIEdgeInsets(top: 10.0, left: 26.0, bottom: 10.0, right: 10.0 + 16.0) var textUpdated: ((String) -> Void)? + + var accessibilityFocusTarget: UIView { + return self.textInputNode.textField + } init(theme: PresentationTheme, strings: PresentationStrings, placeholder: String) { self.backgroundNode = ASImageNode() diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index 29220c1508e..82366a3fa40 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -240,6 +240,7 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.recentGridNode = GridNode() self.contentGridNode = GridNode() self.contentGridNode.isHidden = true + self.contentGridNode.accessibilityElementsHidden = true self.searchNode = ShareSearchBarNode(theme: theme, strings: strings, placeholder: strings.Common_Search) @@ -442,11 +443,15 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { if (previousEntries.0 == nil) != (entries == nil) { if previousEntries.0 == nil { strongSelf.recentGridNode.isHidden = true + strongSelf.recentGridNode.accessibilityElementsHidden = true strongSelf.contentGridNode.isHidden = false + strongSelf.contentGridNode.accessibilityElementsHidden = false strongSelf.transitionToContentGridLayout() } else { strongSelf.recentGridNode.isHidden = false + strongSelf.recentGridNode.accessibilityElementsHidden = false strongSelf.contentGridNode.isHidden = true + strongSelf.contentGridNode.accessibilityElementsHidden = true strongSelf.transitionToRecentGridLayout() } } @@ -505,6 +510,20 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { func setEnsurePeerVisibleOnLayout(_ peerId: EnginePeer.Id?) { self.ensurePeerVisibleOnLayout = peerId } + + var accessibilityInitialFocusTarget: Any { + return self.searchNode.accessibilityFocusTarget + } + + func accessibilityFocusTarget(peerId: EnginePeer.Id) -> Any? { + var result: Any? + self.effectiveGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + result = itemNode.view + } + } + return result + } func setDidBeginDragging(_ f: (() -> Void)?) { self.contentDidBeginDragging = f @@ -716,6 +735,13 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { private func dequeueTransition() { if let (transition, _) = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) + + var focusedPeerId: EnginePeer.Id? + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.view.accessibilityElementIsFocused() { + focusedPeerId = itemNode.peerId + } + } var itemTransition: ContainedViewLayoutTransition = .immediate if transition.animated { @@ -733,7 +759,17 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { } } - self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil, synchronousLoads: true), completion: { _ in }) + self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil, synchronousLoads: true), completion: { [weak self] _ in + guard let self, let focusedPeerId else { + return + } + if let target = self.accessibilityFocusTarget(peerId: focusedPeerId) { + if let targetView = target as? UIView, targetView.accessibilityElementIsFocused() { + return + } + UIAccessibility.post(notification: .layoutChanged, argument: target) + } + }) } } @@ -750,12 +786,29 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { private func dequeueRecentTransition() { if let (transition, _) = self.enqueuedRecentTransitions.first { self.enqueuedRecentTransitions.remove(at: 0) + + var focusedPeerId: EnginePeer.Id? + self.recentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.view.accessibilityElementIsFocused() { + focusedPeerId = itemNode.peerId + } + } var itemTransition: ContainedViewLayoutTransition = .immediate if transition.animated { itemTransition = .animated(duration: 0.3, curve: .spring) } - self.recentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.recentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { [weak self] _ in + guard let self, let focusedPeerId else { + return + } + if let target = self.accessibilityFocusTarget(peerId: focusedPeerId) { + if let targetView = target as? UIView, targetView.accessibilityElementIsFocused() { + return + } + UIAccessibility.post(notification: .layoutChanged, argument: target) + } + }) } } diff --git a/submodules/ShareController/Sources/ShareTopicGridItem.swift b/submodules/ShareController/Sources/ShareTopicGridItem.swift index e27f92c389b..c04315d1e94 100644 --- a/submodules/ShareController/Sources/ShareTopicGridItem.swift +++ b/submodules/ShareController/Sources/ShareTopicGridItem.swift @@ -179,6 +179,22 @@ final class ShareTopicGridItemNode: GridItemNode { let textSize = self.textNode.updateLayout(size) let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: 4.0 + 60.0 + 4.0), size: textSize) self.textNode.frame = textFrame + self.updateSelection() + } + + private func updateSelection() { + guard let item = self.currentItem else { + self.accessibilityValue = nil + self.accessibilityTraits.remove(.selected) + return + } + let isSelected = item.controllerInteraction.selectedTopics[item.basePeer.id]?.0 == item.id + self.accessibilityValue = isSelected ? item.strings.VoiceOver_Chat_Selected : nil + if isSelected { + self.accessibilityTraits.insert(.selected) + } else { + self.accessibilityTraits.remove(.selected) + } } override func layout() { diff --git a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift index 0989a2c9db7..ceb82ed9bdb 100644 --- a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift +++ b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift @@ -294,18 +294,38 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { private func dequeueTransition() { if let (transition, _) = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) + + var focusedTopicId: Int64? + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareTopicGridItemNode, itemNode.view.accessibilityElementIsFocused() { + focusedTopicId = itemNode.id + } + } var itemTransition: ContainedViewLayoutTransition = .immediate if transition.animated { itemTransition = .animated(duration: 0.3, curve: .spring) } - self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { [weak self] _ in + guard let self, let focusedTopicId else { + return + } + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareTopicGridItemNode, itemNode.id == focusedTopicId, !itemNode.view.accessibilityElementIsFocused() { + UIAccessibility.post(notification: .layoutChanged, argument: itemNode.view) + } + } + }) } } func setDidBeginDragging(_ f: (() -> Void)?) { self.contentDidBeginDragging = f } + + var accessibilityInitialFocusTarget: Any { + return self.backNode.buttonNode.view + } func setContentOffsetUpdated(_ f: ((CGFloat, ContainedViewLayoutTransition) -> Void)?) { self.contentOffsetUpdated = f @@ -338,6 +358,7 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { } func animateIn(sourceFrame: CGRect, scrollDelta: CGFloat) { + self.accessibilityElementsHidden = false self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.backNode.alpha = 1.0 @@ -364,6 +385,7 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { } func animateOut(targetFrame: CGRect, scrollDelta: CGFloat, completion: @escaping () -> Void = {}) { + self.accessibilityElementsHidden = true self.headerNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.backNode.alpha = 0.0 diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift index 24f2630ba91..2f19bc1c080 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -27,6 +27,30 @@ import LottieComponent import ButtonComponent import ContextUI +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + for subview in view.subviews { + if accessibilityElementIsFocused(in: subview) { + return true + } + } + return false +} + +private func firstAccessibilityElementView(in view: UIView) -> UIView? { + if view.isAccessibilityElement { + return view + } + for subview in view.subviews { + if let result = firstAccessibilityElementView(in: subview) { + return result + } + } + return nil +} + final class GiftsListView: UIView { private let context: AccountContext private let peerId: EnginePeer.Id @@ -424,6 +448,14 @@ final class GiftsListView: UIView { guard let starsProducts = self.starsProducts, let params = self.currentParams else { return 0.0 } + + var focusedItemId: AnyHashable? + for (id, item) in self.starsItems { + if let itemView = item.1.view, accessibilityElementIsFocused(in: itemView) { + focusedItemId = id + break + } + } let optionSpacing: CGFloat = 10.0 let itemsSideInset = params.sideInset + 16.0 @@ -721,6 +753,25 @@ final class GiftsListView: UIView { if itemAlpha < 1.0 { itemView.layer.allowsGroupOpacity = true } + + if let accessibilityView = firstAccessibilityElementView(in: itemView) { + let isSelected = self.selectedItemIds.contains(itemReferenceId) + let isSelectionLimitReached = self.canSelect && !isSelected && self.selectedItemIds.count >= Int(self.remainingSelectionCount) + if isSelected { + accessibilityView.accessibilityTraits.insert(.selected) + accessibilityView.accessibilityValue = params.presentationData.strings.VoiceOver_Chat_Selected + } else { + accessibilityView.accessibilityTraits.remove(.selected) + accessibilityView.accessibilityValue = nil + } + if isSelectionLimitReached { + accessibilityView.accessibilityTraits.insert(.notEnabled) + accessibilityView.accessibilityHint = params.presentationData.strings.RequestPeer_ReachedMaximum(self.remainingSelectionCount) + } else { + accessibilityView.accessibilityTraits.remove(.notEnabled) + accessibilityView.accessibilityHint = nil + } + } if self.isReordering && (product.pinnedToTop || self.isCollection) { if itemView.layer.animation(forKey: "shaking_position") == nil { @@ -762,6 +813,10 @@ final class GiftsListView: UIView { for id in removeIds { self.starsItems.removeValue(forKey: id) } + + if let focusedItemId, let itemView = self.starsItems[focusedItemId]?.1.view, !accessibilityElementIsFocused(in: itemView), let accessibilityView = firstAccessibilityElementView(in: itemView) { + UIAccessibility.post(notification: .layoutChanged, argument: accessibilityView) + } var contentHeight = ceil(CGFloat(starsProducts.count) / CGFloat(defaultItemsInRow)) * (starsOptionSize.height + optionSpacing) - optionSpacing + topInset + 16.0 From 2108986887a466cb430e2ce3ae0583565d815c09 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:09:54 +0500 Subject: [PATCH 14/18] Improve VoiceOver accessibility for gifts and reply media Add accessible gift card and collection tab semantics with stable selection, context menu, pinning, and reorder actions. Complete segmented control labels, selected states, and animated visibility. Prevent replied voice and instant video messages from leaking media traits, selection state, and playback hints into the containing message. --- .../Sources/SegmentedControlNode.swift | 4 + .../Sources/ChatMessageItemView.swift | 28 ++++-- .../Sources/GiftItemComponent.swift | 93 ++++++++++++++++++ .../Sources/CollectionTabItemComponent.swift | 3 + .../Sources/GiftsListView.swift | 86 +++++++++++++++- .../Sources/PeerInfoGiftsPaneNode.swift | 9 +- .../Sources/TabSelectorComponent.swift | 98 ++++++++++++++++++- 7 files changed, 307 insertions(+), 14 deletions(-) diff --git a/submodules/SegmentedControlNode/Sources/SegmentedControlNode.swift b/submodules/SegmentedControlNode/Sources/SegmentedControlNode.swift index fd381b5f552..019dde470cf 100644 --- a/submodules/SegmentedControlNode/Sources/SegmentedControlNode.swift +++ b/submodules/SegmentedControlNode/Sources/SegmentedControlNode.swift @@ -114,6 +114,8 @@ public final class SegmentedControlNode: ASDisplayNode, ASGestureRecognizerDeleg itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) itemNode.titleNode.maximumNumberOfLines = 1 itemNode.titleNode.truncationMode = .byTruncatingTail + itemNode.accessibilityLabel = item.title + itemNode.accessibilityTraits = [.button] itemNode.setTitle(item.title, with: textFont, with: self.theme.textColor, for: .normal) itemNode.setTitle(item.title, with: selectedTextFont, with: self.theme.textColor, for: .selected) itemNode.setTitle(item.title, with: selectedTextFont, with: self.theme.textColor, for: [.selected, .highlighted]) @@ -299,6 +301,7 @@ public final class SegmentedControlNode: ASDisplayNode, ASGestureRecognizerDeleg public func animateSelection(to point: CGPoint, transition: ContainedViewLayoutTransition) -> CGRect { self.isUserInteractionEnabled = false + self.accessibilityElementsHidden = true self.alpha = 0.0 self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) @@ -309,6 +312,7 @@ public final class SegmentedControlNode: ASDisplayNode, ASGestureRecognizerDeleg public func animateSelection(from point: CGPoint, transition: ContainedViewLayoutTransition) -> CGRect { self.isUserInteractionEnabled = true + self.accessibilityElementsHidden = false self.alpha = 1.0 self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 1ef092933d7..c9f6ef06d33 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -123,7 +123,9 @@ public final class ChatMessageAccessibilityData { loop: for media in message.media { if let _ = media as? TelegramMediaImage { - traits.insert(.image) + if !isReply { + traits.insert(.image) + } if isIncoming { if announceIncomingAuthors, let authorName = authorName { label = item.presentationData.strings.VoiceOver_Chat_PhotoFrom(authorName).string @@ -175,10 +177,12 @@ public final class ChatMessageAccessibilityData { continue } isSpecialFile = true - if isSelected == nil { + if !isReply && isSelected == nil { hint = item.presentationData.strings.VoiceOver_Chat_PlayHint } - traits.insert(.startsMediaSession) + if !isReply { + traits.insert(.startsMediaSession) + } if isVoice { let durationString = voiceMessageDurationFormatter.string(from: Double(duration)) ?? "" if isIncoming { @@ -210,10 +214,12 @@ public final class ChatMessageAccessibilityData { } case let .Video(duration, _, flags, _, _, _): isSpecialFile = true - if isSelected == nil { + if !isReply && isSelected == nil { hint = item.presentationData.strings.VoiceOver_Chat_PlayHint } - traits.insert(.startsMediaSession) + if !isReply { + traits.insert(.startsMediaSession) + } let durationString = voiceMessageDurationFormatter.string(from: Double(duration)) ?? "" if flags.contains(.instantRoundVideo) { if isIncoming { @@ -242,7 +248,7 @@ public final class ChatMessageAccessibilityData { } } if !isSpecialFile { - if isSelected == nil { + if !isReply && isSelected == nil { hint = item.presentationData.strings.VoiceOver_Chat_OpenHint } let sizeString = fileSizeFormatter.string(fromByteCount: Int64(file.size ?? 0)) @@ -447,7 +453,7 @@ public final class ChatMessageAccessibilityData { var result = "" - if let isSelected = isSelected { + if !isReply, let isSelected = isSelected { if isSelected { result += item.presentationData.strings.VoiceOver_Chat_Selected result += "\n" @@ -560,8 +566,12 @@ public final class ChatMessageAccessibilityData { replyLabel = item.presentationData.strings.VoiceOver_Chat_ReplyToYourMessage } - let (_, replyMessageValue) = dataForMessage(replyMessage, true) - replyValue = replyMessageValue + let (replyMessageLabel, replyMessageValue) = dataForMessage(replyMessage, true) + if replyMessageValue.isEmpty { + replyValue = replyMessageLabel + } else { + replyValue = "\(replyMessageLabel). \(replyMessageValue)" + } label = "\(replyLabel) . \(label)" } diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index 73e329bf11c..a92139513cf 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -410,6 +410,97 @@ public final class GiftItemComponent: Component { @objc private func buttonPressed() { self.component?.action?() } + + override public func accessibilityActivate() -> Bool { + guard let action = self.component?.action else { + return false + } + action() + return true + } + + @objc private func accessibilityOpenContextMenu(_ action: UIAccessibilityCustomAction) -> Bool { + guard self.isGestureEnabled, let contextGesture = self.contextGesture, let activated = self.activated else { + return false + } + activated(contextGesture, CGPoint(x: self.bounds.midX, y: self.bounds.midY)) + return true + } + + private func updateAccessibility(component: GiftItemComponent) { + let exposesCard = component.mode == .generic || component.mode == .profile || component.mode == .select || component.mode == .thumbnail || component.mode == .grid + self.isAccessibilityElement = exposesCard && !component.isPlaceholder + self.containerButton.isAccessibilityElement = false + + guard self.isAccessibilityElement else { + self.accessibilityLabel = nil + self.accessibilityValue = nil + self.accessibilityHint = nil + self.accessibilityTraits = [] + self.accessibilityCustomActions = nil + return + } + + var label: String + switch component.subject { + case let .uniqueGift(gift, _): + label = "\(gift.title) #\(gift.number)" + default: + label = component.title ?? component.strings.SharedMedia_GiftCount(1) + } + + var values: [String] = [] + if let title = component.title, title != label { + values.append(title) + } + if let subtitle = component.subtitle { + values.append(subtitle) + } + if let ribbon = component.ribbon { + values.append(ribbon.text) + } + switch component.subject { + case let .premium(_, price), let .starGift(_, price): + values.append(price) + case let .uniqueGift(_, price): + if let price { + values.append(price) + } + case .auction, .preview: + break + } + if component.isPinned { + values.append(component.strings.PeerInfo_Gifts_Context_Unpin) + } + if component.isHidden { + values.append(component.strings.PeerInfo_Gifts_Hidden) + } + if component.isSelected { + values.append(component.strings.VoiceOver_Chat_Selected) + } + + self.accessibilityLabel = label + self.accessibilityValue = values.isEmpty ? nil : values.joined(separator: ", ") + self.accessibilityHint = nil + self.accessibilityTraits = [.image] + if component.action != nil { + self.accessibilityTraits.insert(.button) + } + if component.isSelected { + self.accessibilityTraits.insert(.selected) + } + if component.contextAction != nil { + self.accessibilityCustomActions = [ + UIAccessibilityCustomAction( + name: component.strings.VoiceOver_MessageContextOpenMessageMenu, + target: self, + selector: #selector(self.accessibilityOpenContextMenu(_:)) + ) + ] + } else { + self.accessibilityCustomActions = nil + } + } func update(component: GiftItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let isFirstTime = self.component == nil @@ -1624,6 +1715,8 @@ public final class GiftItemComponent: Component { } else { self.containerButton.isUserInteractionEnabled = false } + + self.updateAccessibility(component: component) return size } diff --git a/submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent/Sources/CollectionTabItemComponent.swift b/submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent/Sources/CollectionTabItemComponent.swift index f325d9604b4..81423349dab 100644 --- a/submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent/Sources/CollectionTabItemComponent.swift +++ b/submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent/Sources/CollectionTabItemComponent.swift @@ -58,6 +58,9 @@ public final class CollectionTabItemComponent: Component { func update(component: CollectionTabItemComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component + self.isAccessibilityElement = true + self.accessibilityLabel = component.title + self.accessibilityTraits = [.staticText] let environment = environment[EnvironmentType.self].value diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift index 2f19bc1c080..02e4a628885 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift @@ -51,6 +51,23 @@ private func firstAccessibilityElementView(in view: UIView) -> UIView? { return nil } +private final class GiftAccessibilityAction: UIAccessibilityCustomAction { + enum Kind { + case movePrevious + case moveNext + case togglePinned + } + + let reference: StarGiftReference + let kind: Kind + + init(name: String, reference: StarGiftReference, kind: Kind, target: Any, selector: Selector) { + self.reference = reference + self.kind = kind + super.init(name: name, target: target, selector: selector) + } +} + final class GiftsListView: UIView { private let context: AccountContext private let peerId: EnginePeer.Id @@ -421,6 +438,37 @@ final class GiftsListView: UIView { } } } + + @objc private func performAccessibilityGiftAction(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? GiftAccessibilityAction, let items = self.starsProducts, let index = items.firstIndex(where: { $0.reference == action.reference }) else { + return false + } + switch action.kind { + case .movePrevious: + guard index > 0 else { + return false + } + self.reorderIfPossible(reference: action.reference, toIndex: index - 1) + self.updateScrolling(transition: .spring(duration: 0.3)) + return true + case .moveNext: + guard index + 1 < items.count else { + return false + } + self.reorderIfPossible(reference: action.reference, toIndex: index + 1) + self.updateScrolling(transition: .spring(duration: 0.3)) + return true + case .togglePinned: + let item = items[index] + let pinnedToTop = !item.pinnedToTop + if pinnedToTop && self.pinnedReferences.count >= self.maxPinnedCount { + self.displayUnpinScreen?(item, nil) + return true + } + self.profileGifts.updateStarGiftPinnedToTop(reference: action.reference, pinnedToTop: pinnedToTop) + return true + } + } func loadMore() { self.profileGifts.loadMore() @@ -759,10 +807,8 @@ final class GiftsListView: UIView { let isSelectionLimitReached = self.canSelect && !isSelected && self.selectedItemIds.count >= Int(self.remainingSelectionCount) if isSelected { accessibilityView.accessibilityTraits.insert(.selected) - accessibilityView.accessibilityValue = params.presentationData.strings.VoiceOver_Chat_Selected } else { accessibilityView.accessibilityTraits.remove(.selected) - accessibilityView.accessibilityValue = nil } if isSelectionLimitReached { accessibilityView.accessibilityTraits.insert(.notEnabled) @@ -771,6 +817,42 @@ final class GiftsListView: UIView { accessibilityView.accessibilityTraits.remove(.notEnabled) accessibilityView.accessibilityHint = nil } + + var accessibilityActions: [UIAccessibilityCustomAction] = accessibilityView.accessibilityCustomActions ?? [] + if let reference = product.reference { + if self.isReordering, let itemIndex = starsProducts.firstIndex(where: { $0.reference == reference }) { + if itemIndex > 0 { + accessibilityActions.append(GiftAccessibilityAction( + name: "\(params.presentationData.strings.PeerInfo_Gifts_Context_Reorder) ←", + reference: reference, + kind: .movePrevious, + target: self, + selector: #selector(self.performAccessibilityGiftAction(_:)) + )) + } + if itemIndex + 1 < starsProducts.count { + accessibilityActions.append(GiftAccessibilityAction( + name: "\(params.presentationData.strings.PeerInfo_Gifts_Context_Reorder) →", + reference: reference, + kind: .moveNext, + target: self, + selector: #selector(self.performAccessibilityGiftAction(_:)) + )) + } + } + if !self.canSelect && !self.isCollection && self.peerId == self.context.account.peerId { + if case .unique = product.gift { + accessibilityActions.append(GiftAccessibilityAction( + name: product.pinnedToTop ? params.presentationData.strings.PeerInfo_Gifts_Context_Unpin : params.presentationData.strings.PeerInfo_Gifts_Context_Pin, + reference: reference, + kind: .togglePinned, + target: self, + selector: #selector(self.performAccessibilityGiftAction(_:)) + )) + } + } + } + accessibilityView.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions } if self.isReordering && (product.pinnedToTop || self.isCollection) { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift index d893925f24a..d16455af21a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift @@ -692,6 +692,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr self.updateScrolling(transition: .easeInOut(duration: 0.2)) } : nil, + accessibilityReorderPreviousTitle: "\(params.presentationData.strings.PeerInfo_Gifts_Reorder) ←", + accessibilityReorderNextTitle: "\(params.presentationData.strings.PeerInfo_Gifts_Reorder) →", setSelectedId: { [weak self] id in guard let self, let idValue = id.base as? Int32 else { return @@ -1450,7 +1452,12 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr context: self.context, presentationData: currentParams.presentationData, source: .controller(ContextControllerContentSourceImpl(controller: previewController, sourceView: view)), - items: .single(ContextController.Items(content: .list(items))), gesture: gesture + items: .single(ContextController.Items(content: .list(items), dismissed: { [weak view] in + guard UIAccessibility.isVoiceOverRunning, let view, view.window != nil, !view.accessibilityElementsHidden else { + return + } + UIAccessibility.post(notification: .layoutChanged, argument: view) + })), gesture: gesture ) self.parentController?.presentInGlobalOverlay(contextController) } diff --git a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift index 242c4f2fc2c..4f560429e1b 100644 --- a/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift +++ b/submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift @@ -9,6 +9,22 @@ import TextFormat import AccountContext import TelegramPresentationData +private final class TabSelectorAccessibilityReorderAction: UIAccessibilityCustomAction { + enum Direction { + case previous + case next + } + + let itemId: AnyHashable + let direction: Direction + + init(name: String, itemId: AnyHashable, direction: Direction, target: Any, selector: Selector) { + self.itemId = itemId + self.direction = direction + super.init(name: name, target: target, selector: selector) + } +} + public final class TabSelectorComponent: Component { public enum Style { case glass @@ -136,6 +152,8 @@ public final class TabSelectorComponent: Component { public let items: [Item] public let selectedId: AnyHashable? public let reorderItem: ((AnyHashable, AnyHashable) -> Void)? + public let accessibilityReorderPreviousTitle: String? + public let accessibilityReorderNextTitle: String? public let setSelectedId: (AnyHashable) -> Void public let transitionFraction: CGFloat? @@ -148,6 +166,8 @@ public final class TabSelectorComponent: Component { items: [Item], selectedId: AnyHashable?, reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil, + accessibilityReorderPreviousTitle: String? = nil, + accessibilityReorderNextTitle: String? = nil, setSelectedId: @escaping (AnyHashable) -> Void, transitionFraction: CGFloat? = nil ) { @@ -159,6 +179,8 @@ public final class TabSelectorComponent: Component { self.items = items self.selectedId = selectedId self.reorderItem = reorderItem + self.accessibilityReorderPreviousTitle = accessibilityReorderPreviousTitle + self.accessibilityReorderNextTitle = accessibilityReorderNextTitle self.setSelectedId = setSelectedId self.transitionFraction = transitionFraction } @@ -188,6 +210,12 @@ public final class TabSelectorComponent: Component { if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) { return false } + if lhs.accessibilityReorderPreviousTitle != rhs.accessibilityReorderPreviousTitle { + return false + } + if lhs.accessibilityReorderNextTitle != rhs.accessibilityReorderNextTitle { + return false + } if lhs.transitionFraction != rhs.transitionFraction { return false } @@ -287,6 +315,26 @@ public final class TabSelectorComponent: Component { self.action() } } + + override func accessibilityActivate() -> Bool { + guard self.isUserInteractionEnabled else { + return false + } + self.action() + return true + } + + private func firstAccessibilityLabel(in view: UIView) -> String? { + if let label = view.accessibilityLabel, !label.isEmpty { + return label + } + for subview in view.subviews { + if let label = self.firstAccessibilityLabel(in: subview) { + return label + } + } + return nil + } private func updateIsShaking(animated: Bool) { if self.isReordering { @@ -350,7 +398,7 @@ public final class TabSelectorComponent: Component { } } - func update(theme: PresentationTheme, size: CGSize, item: Item, isReordering: Bool, transition: ComponentTransition) { + func update(theme: PresentationTheme, size: CGSize, item: Item, isSelected: Bool, isReorderMode: Bool, isReordering: Bool, transition: ComponentTransition) { self.theme = theme self.size = size self.isReordering = isReordering @@ -358,6 +406,23 @@ public final class TabSelectorComponent: Component { self.containerNode.isGestureEnabled = item.contextAction != nil && !isReordering self.tapGesture?.isEnabled = !isReordering + + self.isAccessibilityElement = true + self.containerNode.accessibilityElementsHidden = true + switch item.content { + case let .text(title): + self.accessibilityLabel = title + case .component: + self.accessibilityLabel = self.title.view.flatMap { self.firstAccessibilityLabel(in: $0) } + } + self.accessibilityValue = nil + self.accessibilityTraits = [.button] + if isSelected { + self.accessibilityTraits.insert(.selected) + } + if isReorderMode && !item.isReorderable { + self.accessibilityTraits.insert(.notEnabled) + } transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) @@ -378,6 +443,24 @@ public final class TabSelectorComponent: Component { private var visibleItems: [AnyHashable: VisibleItem] = [:] private var didInitiallyScroll = false + + @objc private func performAccessibilityReorder(_ action: UIAccessibilityCustomAction) -> Bool { + guard let action = action as? TabSelectorAccessibilityReorderAction, let component = self.component, let reorderItem = component.reorderItem, let index = component.items.firstIndex(where: { $0.id == action.itemId }) else { + return false + } + let targetIndex: Int + switch action.direction { + case .previous: + targetIndex = index - 1 + case .next: + targetIndex = index + 1 + } + guard component.items.indices.contains(targetIndex), component.items[targetIndex].isReorderable else { + return false + } + reorderItem(action.itemId, component.items[targetIndex].id) + return true + } private var reorderRecognizer: ReorderGestureRecognizer? private weak var reorderingItem: VisibleItem? @@ -723,7 +806,18 @@ public final class TabSelectorComponent: Component { itemTransition.setTransform(view: itemView, transform: CATransform3DIdentity) } - itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition) + itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isSelected: item.id == component.selectedId, isReorderMode: component.reorderItem != nil, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition) + + var accessibilityActions: [UIAccessibilityCustomAction] = [] + if component.reorderItem != nil, item.isReorderable, let itemIndex = component.items.firstIndex(where: { $0.id == item.id }) { + if itemIndex > 0, component.items[itemIndex - 1].isReorderable, let title = component.accessibilityReorderPreviousTitle { + accessibilityActions.append(TabSelectorAccessibilityReorderAction(name: title, itemId: item.id, direction: .previous, target: self, selector: #selector(self.performAccessibilityReorder(_:)))) + } + if itemIndex + 1 < component.items.count, component.items[itemIndex + 1].isReorderable, let title = component.accessibilityReorderNextTitle { + accessibilityActions.append(TabSelectorAccessibilityReorderAction(name: title, itemId: item.id, direction: .next, target: self, selector: #selector(self.performAccessibilityReorder(_:)))) + } + } + itemView.accessibilityCustomActions = accessibilityActions.isEmpty ? nil : accessibilityActions itemTransition.setPosition(view: itemTitleView, position: CGPoint(x: itemTitleFrame.minX, y: itemTitleFrame.minY)) itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size)) From 81e0eb257b4ae96494159bf0b7677dce696e8d66 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:42:36 +0500 Subject: [PATCH 15/18] Improve VoiceOver focus persistence and modal accessibility Preserve VoiceOver focus by stable message, peer, contact, and entry identifiers across chat search, contact, and peer info list transactions. Add consistent modal containment, initial focus, VoiceOver Escape handling, and trigger focus restoration to context, peek, and pinch controllers. Assign stable accessibility identifiers to message renderers and extend focus persistence to global search and chat history search results. --- .../Sources/ChatListSearchListPaneNode.swift | 36 +++++++++++++- .../Sources/ContactListNode.swift | 33 ++++++++++++- .../Sources/ContactsSearchContainerNode.swift | 34 +++++++++++++- .../ChatHistorySearchContainerNode.swift | 32 ++++++++++++- .../Sources/ChatMessageItemView.swift | 5 ++ .../Sources/ContextControllerImpl.swift | 47 +++++++++++++++++-- .../Sources/PeekController.swift | 37 ++++++++++++++- .../Sources/PinchController.swift | 33 ++++++++++++- .../PeerInfoGroupsInCommonPaneNode.swift | 33 ++++++++++++- .../Sources/Panes/PeerInfoMembersPane.swift | 33 ++++++++++++- .../Panes/PeerInfoRecommendedPeersPane.swift | 33 ++++++++++++- .../ChatSearchResultsContollerNode.swift | 41 ++++++++++++++-- 12 files changed, 377 insertions(+), 20 deletions(-) diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 44368c71e0c..2ab2ae8d70c 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -1319,8 +1319,9 @@ public struct ChatListSearchContainerTransition { public let approvedGlobalPostQueryState: ApprovedGlobalPostQueryState? public let globalSearchStateValue: TelegramGlobalPostSearchState? public var animated: Bool + public let stableIds: [AnyHashable] - public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, query: String?, approvedGlobalPostQueryState: ApprovedGlobalPostQueryState?, globalSearchStateValue: TelegramGlobalPostSearchState?, animated: Bool) { + public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, query: String?, approvedGlobalPostQueryState: ApprovedGlobalPostQueryState?, globalSearchStateValue: TelegramGlobalPostSearchState?, animated: Bool, stableIds: [AnyHashable] = []) { self.deletions = deletions self.insertions = insertions self.updates = updates @@ -1331,9 +1332,17 @@ public struct ChatListSearchContainerTransition { self.globalSearchStateValue = globalSearchStateValue self.query = query self.animated = animated + self.stableIds = stableIds } } +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) +} + enum OpenPeerAction { case generic case info @@ -1410,7 +1419,7 @@ public func chatListSearchContainerPreparedTransition( let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, communityId: communityId, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, communityId: communityId, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) } - return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, approvedGlobalPostQueryState: approvedGlobalPostQueryState, globalSearchStateValue: globalSearchStateValue, animated: animated) + return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, approvedGlobalPostQueryState: approvedGlobalPostQueryState, globalSearchStateValue: globalSearchStateValue, animated: animated, stableIds: toEntries.map { AnyHashable($0.stableId) }) } private struct ChatListSearchListPaneNodeState: Equatable { @@ -1686,6 +1695,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { private let searchContextsValue = Atomic<[Int: ChatListSearchMessagesContext]>(value: [:]) var searchCurrentMessages: [EngineMessage]? var currentEntries: [ChatListSearchEntry]? + private var displayedEntryIds: [AnyHashable] = [] private var deletedMessagesDisposable: Disposable? @@ -5539,8 +5549,30 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { options.insert(.PreferSynchronousResourceLoading) } + var focusedEntryId: AnyHashable? + if UIAccessibility.isVoiceOverRunning, let listNode = self.listNode { + for itemNode in listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntryIds.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntryIds[index] + break + } + } + } + self.listNode?.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { + strongSelf.displayedEntryIds = transition.stableIds + if let focusedEntryId, let index = transition.stableIds.firstIndex(of: focusedEntryId), let listNode = strongSelf.listNode { + for itemNode in listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } let searchOptions = strongSelf.searchOptionsValue strongSelf.listNode?.isHidden = strongSelf.tagMask == .photoOrVideo && (strongSelf.searchQueryValue ?? "").isEmpty strongSelf.mediaNode?.isHidden = !(strongSelf.listNode?.isHidden ?? true) diff --git a/submodules/ContactListUI/Sources/ContactListNode.swift b/submodules/ContactListUI/Sources/ContactListNode.swift index 22632f0057c..b758d747b79 100644 --- a/submodules/ContactListUI/Sources/ContactListNode.swift +++ b/submodules/ContactListUI/Sources/ContactListNode.swift @@ -913,7 +913,7 @@ private func preparedContactListNodeTransition(context: AccountContext, presenta scrollToItem = ListViewScrollToItem(index: 0, position: .top(-50.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Up) } - return ContactsListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, indexSections: indexSections, firstTime: firstTime, isEmpty: isEmpty, hasOptions: hasOptions, scrollToItem: scrollToItem, animation: animation) + return ContactsListNodeTransition(deletions: deletions, insertions: insertions, updates: updates, indexSections: indexSections, firstTime: firstTime, isEmpty: isEmpty, hasOptions: hasOptions, scrollToItem: scrollToItem, animation: animation, entries: toEntries) } private struct ContactsListNodeTransition { @@ -926,6 +926,14 @@ private struct ContactsListNodeTransition { let hasOptions: Bool let scrollToItem: ListViewScrollToItem? let animation: ContactListAnimation + let entries: [ContactListNodeEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } public enum ContactListPresentation { @@ -1023,6 +1031,7 @@ public final class ContactListNode: ASDisplayNode { private var indexSections: [String]? private var queuedTransitions: [ContactsListNodeTransition] = [] + private var displayedEntries: [ContactListNodeEntry] = [] private var validLayout: (ContainerViewLayout, UIEdgeInsets, CGFloat)? private var _ready = ValuePromise() @@ -2302,8 +2311,30 @@ public final class ContactListNode: ASDisplayNode { self.indexNode.isUserInteractionEnabled = !transition.indexSections.isEmpty } + var focusedEntryId: ContactListNodeEntryId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: transition.scrollToItem, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { + strongSelf.displayedEntries = transition.entries + if let focusedEntryId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) diff --git a/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift b/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift index d3b7db7a98f..1167f22a365 100644 --- a/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift +++ b/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift @@ -191,13 +191,21 @@ private enum ContactListSearchEntry: Comparable, Identifiable { } } -struct ContactListSearchContainerTransition { +private struct ContactListSearchContainerTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let isSearching: Bool let emptyResults: Bool let query: String + let entries: [ContactListSearchEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private func contactListSearchContainerPreparedRecentTransition(from fromEntries: [ContactListSearchEntry], to toEntries: [ContactListSearchEntry], isSearching: Bool, emptyResults: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, timeFormat: PresentationDateTimeFormat, isPeerEnabled: @escaping (ContactListPeer) -> Bool, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) -> ContactListSearchContainerTransition { @@ -207,7 +215,7 @@ private func contactListSearchContainerPreparedRecentTransition(from fromEntries let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) } - return ContactListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, emptyResults: emptyResults, query: query) + return ContactListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, emptyResults: emptyResults, query: query, entries: toEntries) } public struct ContactsSearchCategories: OptionSet { @@ -256,6 +264,7 @@ public final class ContactsSearchContainerNode: SearchDisplayControllerContentNo private var containerViewLayout: (ContainerViewLayout, CGFloat)? private var enqueuedTransitions: [ContactListSearchContainerTransition] = [] + private var displayedEntries: [ContactListSearchEntry] = [] private let searchInput = ComponentView() @@ -798,10 +807,31 @@ public final class ContactsSearchContainerNode: SearchDisplayControllerContentNo let isSearching = transition.isSearching let emptyResults = transition.emptyResults let query = transition.query + var focusedEntryId: ContactListSearchEntryId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transition.entries + if let focusedEntryId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Contacts_Search_NoResultsQueryDescription(query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor) diff --git a/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift b/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift index c9ca8638b19..0a7d3bbab64 100644 --- a/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift @@ -101,6 +101,14 @@ private struct ChatHistorySearchContainerTransition { let updates: [ListViewUpdateItem] let query: String let displayingResults: Bool + let entries: [ChatHistorySearchEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private func chatHistorySearchContainerPreparedTransition(from fromEntries: [ChatHistorySearchEntry], to toEntries: [ChatHistorySearchEntry], query: String, displayingResults: Bool, context: AccountContext, peerId: EnginePeer.Id, interaction: ChatControllerInteraction) -> ChatHistorySearchContainerTransition { @@ -110,7 +118,7 @@ private func chatHistorySearchContainerPreparedTransition(from fromEntries: [Cha let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, peerId: peerId, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, peerId: peerId, interaction: interaction), directionHint: nil) } - return ChatHistorySearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, query: query, displayingResults: displayingResults) + return ChatHistorySearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, query: query, displayingResults: displayingResults, entries: toEntries) } public final class ChatHistorySearchContainerNode: SearchDisplayControllerContentNode { @@ -125,6 +133,7 @@ public final class ChatHistorySearchContainerNode: SearchDisplayControllerConten private var containerLayout: (ContainerViewLayout, CGFloat)? private var currentEntries: [ChatHistorySearchEntry]? + private var displayedEntries: [ChatHistorySearchEntry] = [] public var currentMessages: [EngineMessage.Id: EngineRawMessage]? private var currentQuery: String? @@ -322,8 +331,29 @@ public final class ChatHistorySearchContainerNode: SearchDisplayControllerConten } let displayingResults = transition.displayingResults + var focusedMessageId: EngineMessage.Id? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view), case let .messageId(messageId) = self.displayedEntries[index].stableId { + focusedMessageId = messageId + break + } + } + } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { + strongSelf.displayedEntries = transition.entries + if let focusedMessageId, let index = transition.entries.firstIndex(where: { $0.stableId == .messageId(focusedMessageId) }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } if displayingResults != !strongSelf.listNode.isHidden || strongSelf.currentQuery != transition.query { strongSelf.currentQuery = transition.query diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index c9f6ef06d33..409c2b194aa 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -745,6 +745,11 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { accessibilityNode.accessibilityValue = accessibilityData.value accessibilityNode.accessibilityHint = accessibilityData.hint accessibilityNode.accessibilityTraits = accessibilityData.traits + if let item = self.item { + accessibilityNode.accessibilityIdentifier = "message.\(item.message.id.peerId.toInt64()).\(item.message.id.namespace).\(item.message.id.id)" + } else { + accessibilityNode.accessibilityIdentifier = nil + } accessibilityNode.view.accessibilityRespondsToUserInteraction = accessibilityData.respondsToUserInteraction if let customActions = accessibilityData.customActions { accessibilityNode.accessibilityCustomActions = customActions.map { action in diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift index 940e6132f1e..d5059ea9b3b 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift @@ -90,6 +90,10 @@ final class ContextControllerNode: ViewControllerTracingNode, ASScrollViewDelega } return sourceContainer.overlayWantsToBeBelowKeyboard } + + var accessibilityInitialFocusTarget: Any { + return firstAccessibilityElement(in: self.actionsContainerNode.view) ?? self.dismissAccessibilityArea.view + } init( controller: ContextControllerImpl, @@ -1868,6 +1872,7 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta private var animatedDidAppear = false private var wasDismissed = false + private weak var previousAccessibilityFocus: AnyObject? private var dismissOnInputClose: (result: ContextMenuActionResult, completion: (() -> Void)?)? private var dismissToReactionOnInputClose: (value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: (() -> Void)?)? @@ -2004,6 +2009,7 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta }) self.controllerNode.dismissedForCancel = self.dismissedForCancel self.displayNodeDidLoad() + self.view.accessibilityViewIsModal = true self._ready.set(combineLatest(queue: .mainQueue(), self.controllerNode.itemsReady.get(), self.controllerNode.contentReady.get()) |> map { values in @@ -2040,6 +2046,15 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta if !self.wasDismissed && !self.animatedDidAppear { self.animatedDidAppear = true self.controllerNode.animateIn() + UIAccessibility.post(notification: .screenChanged, argument: self.controllerNode.accessibilityInitialFocusTarget) + } + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? } } @@ -2105,8 +2120,10 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta self.wasDismissed = true self.controllerNode.animateOut(result: result, completion: { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) - completion?() + self?.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) }) self.dismissed?() } @@ -2125,7 +2142,9 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta } public func dismissNow() { - self.presentingViewController?.dismiss(animated: false, completion: nil) + self.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + }) self.dismissed?() } @@ -2143,12 +2162,30 @@ public final class ContextControllerImpl: ViewController, ContextController, Sta if !self.wasDismissed { self.wasDismissed = true self.controllerNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, onHit: onHit, completion: { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) - completion?() + self?.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) }) self.dismissed?() } } + + override public func accessibilityPerformEscape() -> Bool { + guard !self.wasDismissed else { + return false + } + self.dismissWithoutContent() + return true + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) + } public func animateDismissalIfNeeded() { self.controllerNode.animateDismissalIfNeeded() diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift index 6ce55e630a7..504cd1a509b 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift @@ -49,6 +49,8 @@ public final class PeekControllerImpl: ViewController, PeekController, ContextCo public var disappeared: (() -> Void)? private var animatedIn = false + private var isDismissed = false + private weak var previousAccessibilityFocus: AnyObject? private let _ready = Promise() override public var ready: Promise { @@ -75,6 +77,7 @@ public final class PeekControllerImpl: ViewController, PeekController, ContextCo self?.dismiss() }) self.displayNodeDidLoad() + self.view.accessibilityViewIsModal = true } private func getSourceRect() -> CGRect { @@ -98,6 +101,15 @@ public final class PeekControllerImpl: ViewController, PeekController, ContextCo if self.activateImmediately { self.controllerNode.activateMenu(immediately: true) } + UIAccessibility.post(notification: .screenChanged, argument: firstAccessibilityElement(in: self.controllerNode.view) ?? self.controllerNode.view) + } + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? } } @@ -108,13 +120,36 @@ public final class PeekControllerImpl: ViewController, PeekController, ContextCo } override public func dismiss(completion: (() -> Void)? = nil) { + guard !self.isDismissed else { + return + } + self.isDismissed = true self.visibilityUpdated?(false) self.controllerNode.animateOut(to: self.getSourceRect(), completion: { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) + self?.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) }) } public func dismiss(result: ContextMenuActionResult, completion: (() -> Void)?) { self.dismiss(completion: completion) } + + override public func accessibilityPerformEscape() -> Bool { + guard !self.isDismissed else { + return false + } + self.dismiss() + return true + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) + } } diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift index 886e11d0e5b..1e925e46d19 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift @@ -198,6 +198,7 @@ public final class PinchControllerImpl: ViewController, PinchController, Standal private let getContentAreaInScreenSpace: () -> CGRect private var wasDismissed = false + private weak var previousAccessibilityFocus: AnyObject? private var controllerNode: PinchControllerNode { return self.displayNode as! PinchControllerNode @@ -227,6 +228,7 @@ public final class PinchControllerImpl: ViewController, PinchController, Standal self.displayNode = PinchControllerNode(controller: self, sourceNode: self.sourceNode, disableScreenshots: self.disableScreenshots, getContentAreaInScreenSpace: self.getContentAreaInScreenSpace) self.displayNodeDidLoad() + self.view.accessibilityViewIsModal = true self._ready.set(.single(true)) } @@ -244,18 +246,45 @@ public final class PinchControllerImpl: ViewController, PinchController, Standal super.viewDidAppear(animated) self.controllerNode.animateIn() + UIAccessibility.post(notification: .screenChanged, argument: firstAccessibilityElement(in: self.controllerNode.view) ?? self.controllerNode.view) + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if self.previousAccessibilityFocus == nil { + self.previousAccessibilityFocus = UIAccessibility.focusedElement(using: .notificationVoiceOver) as AnyObject? + } } override public func dismiss(completion: (() -> Void)? = nil) { if !self.wasDismissed { self.wasDismissed = true self.controllerNode.animateOut(completion: { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) - completion?() + self?.presentingViewController?.dismiss(animated: false, completion: { [weak self] in + self?.restoreAccessibilityFocus() + completion?() + }) }) } } + override public func accessibilityPerformEscape() -> Bool { + guard !self.wasDismissed else { + return false + } + self.dismiss() + return true + } + + private func restoreAccessibilityFocus() { + guard let previousAccessibilityFocus = self.previousAccessibilityFocus else { + return + } + self.previousAccessibilityFocus = nil + UIAccessibility.post(notification: .layoutChanged, argument: previousAccessibilityFocus) + } + public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { self.controllerNode.addRelativeContentOffset(offset, transition: transition) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift index 362ea9f2b0e..928011911c3 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift @@ -20,6 +20,14 @@ private struct GroupsInCommonListTransaction { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] + let entries: [GroupsInCommonListEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private struct GroupsInCommonListEntry: Comparable, Identifiable { @@ -57,7 +65,7 @@ private func preparedTransition(from fromEntries: [GroupsInCommonListEntry], to let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, openPeer: openPeer, openPeerContextAction: openPeerContextAction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, openPeer: openPeer, openPeerContextAction: openPeerContextAction), directionHint: nil) } - return GroupsInCommonListTransaction(deletions: deletions, insertions: insertions, updates: updates) + return GroupsInCommonListTransaction(deletions: deletions, insertions: insertions, updates: updates, entries: toEntries) } final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { @@ -74,6 +82,7 @@ final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { private let listNode: ListView private var state: GroupsInCommonState? private var currentEntries: [GroupsInCommonListEntry] = [] + private var displayedEntries: [GroupsInCommonListEntry] = [] private var enqueuedTransactions: [GroupsInCommonListTransaction] = [] private var currentParams: (size: CGSize, isScrollingLockedAtTop: Bool, presentationData: PresentationData)? @@ -249,11 +258,33 @@ final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { var options = ListViewDeleteAndInsertOptions() options.insert(.Synchronous) + + var focusedPeerId: EnginePeer.Id? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedPeerId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transaction.entries + if let focusedPeerId, let index = transaction.entries.firstIndex(where: { $0.stableId == focusedPeerId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf.ready.set(.single(true)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift index 5655bdcfd68..523d9ce12e4 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift @@ -22,6 +22,14 @@ private struct PeerMembersListTransaction { let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let animated: Bool + let entries: [PeerMembersListEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } enum PeerMembersListAction { @@ -274,7 +282,7 @@ private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toE let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enclosingPeer: enclosingPeer, addMemberAction: addMemberAction, action: action, contextAction: contextAction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enclosingPeer: enclosingPeer, addMemberAction: addMemberAction, action: action, contextAction: contextAction), directionHint: nil) } - return PeerMembersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count) + return PeerMembersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count, entries: toEntries) } final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { @@ -289,6 +297,7 @@ final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { private let listMaskView: UIImageView private let listNode: ListView private var currentEntries: [PeerMembersListEntry] = [] + private var displayedEntries: [PeerMembersListEntry] = [] private var enclosingPeer: EnginePeer? private var currentState: PeerInfoMembersState? private var canLoadMore: Bool = false @@ -499,11 +508,33 @@ final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { } else { options.insert(.Synchronous) } + + var focusedEntryId: PeerMembersListEntryStableId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transaction.entries + if let focusedEntryId, let index = transaction.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf.ready.set(.single(true)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift index 95a7038fe2f..1602bcc6fb6 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift @@ -26,6 +26,14 @@ private struct RecommendedPeersListTransaction { let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let animated: Bool + let entries: [RecommendedPeersListEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private enum RecommendedPeersListEntryStableId: Hashable { @@ -97,7 +105,7 @@ private func preparedTransition(from fromEntries: [RecommendedPeersListEntry], t let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, action: action, openPeerContextAction: openPeerContextAction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, action: action, openPeerContextAction: openPeerContextAction), directionHint: nil) } - return RecommendedPeersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count) + return RecommendedPeersListTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: toEntries.count < fromEntries.count, entries: toEntries) } private protocol RecommendedPeers { @@ -121,6 +129,7 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { private let listNode: ListView private var currentEntries: [RecommendedPeersListEntry] = [] + private var displayedEntries: [RecommendedPeersListEntry] = [] private var enqueuedTransactions: [RecommendedPeersListTransaction] = [] private var currentState: (RecommendedPeers?, Bool)? @@ -445,11 +454,33 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { } else { options.insert(.Synchronous) } + + var focusedEntryId: RecommendedPeersListEntryStableId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transaction.entries + if let focusedEntryId, let index = transaction.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf.ready.set(.single(true)) diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index 2d23d0a97ba..52cff8eb5c2 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -131,14 +131,23 @@ public struct ChatListSearchContainerTransition { public let deletions: [ListViewDeleteItem] public let insertions: [ListViewInsertItem] public let updates: [ListViewUpdateItem] + public let stableIds: [AnyHashable] - public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem]) { + public init(deletions: [ListViewDeleteItem], insertions: [ListViewInsertItem], updates: [ListViewUpdateItem], stableIds: [AnyHashable] = []) { self.deletions = deletions self.insertions = insertions self.updates = updates + self.stableIds = stableIds } } +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) +} + private func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], context: AccountContext, interaction: ChatListNodeInteraction, location: ChatListControllerLocation) -> ChatListSearchContainerTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) @@ -146,7 +155,7 @@ private func chatListSearchContainerPreparedTransition(from fromEntries: [ChatLi let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, interaction: interaction, location: location), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, interaction: interaction, location: location), directionHint: nil) } - return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates) + return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, stableIds: toEntries.map { AnyHashable($0.stableId) }) } class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDelegate { @@ -165,6 +174,7 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe private let listNode: ListView private var enqueuedTransitions: [(ChatListSearchContainerTransition, Bool)] = [] + private var displayedEntryIds: [AnyHashable] = [] private var validLayout: (ContainerViewLayout, CGFloat)? var resultsUpdated: ((SearchMessagesResult, SearchMessagesState) -> Void)? @@ -417,8 +427,33 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe var options = ListViewDeleteAndInsertOptions() options.insert(.PreferSynchronousDrawing) options.insert(.PreferSynchronousResourceLoading) + + var focusedEntryId: AnyHashable? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntryIds.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntryIds[index] + break + } + } + } - self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + guard let self else { + return + } + self.displayedEntryIds = transition.stableIds + if let focusedEntryId, let index = transition.stableIds.firstIndex(of: focusedEntryId) { + for itemNode in self.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } }) } } From e37e5ba2b27ccfeb7479410bcd16afac295e11b8 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:48:40 +0500 Subject: [PATCH 16/18] Complete VoiceOver focus persistence and regression gates Preserve VoiceOver focus across remaining search and selection transactions using stable identifiers. Add accessible sticker search semantics, source contract tests, XCTest accessibility audits, performance budgets, GitHub Actions integration, release documentation, templates, and accessibility changelog. --- .github/ISSUE_TEMPLATE/voiceover_tracking.md | 41 +++++ .github/PULL_REQUEST_TEMPLATE.md | 21 +++ .github/workflows/voiceover-gate.yml | 52 ++++++ ACCESSIBILITY_CHANGELOG.md | 16 ++ .../Tests/Sources/AccessibilityUITests.swift | 90 ++++++++++ .../test_voiceover_contracts.py | 154 ++++++++++++++++++ docs/VOICEOVER_RELEASE_GATE.md | 58 +++++++ .../InviteContactsControllerNode.swift | 32 +++- .../StickerPaneSearchStickerItem.swift | 17 ++ .../Sources/AttachmentFileSearchItem.swift | 34 +++- .../StickerPaneSearchContentNode.swift | 30 +++- .../GroupStickerSearchContainerNode.swift | 34 +++- 12 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/voiceover_tracking.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/voiceover-gate.yml create mode 100644 ACCESSIBILITY_CHANGELOG.md create mode 100644 Telegram/Tests/Sources/AccessibilityUITests.swift create mode 100644 Tests/VoiceOverContracts/test_voiceover_contracts.py create mode 100644 docs/VOICEOVER_RELEASE_GATE.md diff --git a/.github/ISSUE_TEMPLATE/voiceover_tracking.md b/.github/ISSUE_TEMPLATE/voiceover_tracking.md new file mode 100644 index 00000000000..1d12860c9b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/voiceover_tracking.md @@ -0,0 +1,41 @@ +--- +name: VoiceOver release tracking +about: Track VoiceOver verification, regressions, and release evidence +title: "VoiceOver release gate: " +labels: "a11y-voiceover,a11y-regression" +assignees: "" +--- + +## Scope + +- Commit or tag: +- iOS versions: +- Devices and simulators: +- Locales: + +## Automated gates + +- [ ] VoiceOver source-contract tests pass. +- [ ] iOS 17+ XCTest accessibility-tree audit passes in required app states. +- [ ] Result logs and `.xcresult` are attached. + +## Manual VoiceOver gate + +- [ ] Chat history traversal and scrolling +- [ ] Jump to latest and input focus +- [ ] Message information, states, and custom actions +- [ ] Instant video and reply voice messages +- [ ] Selection flows and limit errors +- [ ] Share Extension +- [ ] Peer Info, Gifts, contacts, and searches +- [ ] Modal containment, Escape, and trigger-focus restoration +- [ ] Dynamic Type, Reduce Motion, and Voice Control + +## Regressions + +Link every regression to a dedicated issue and atomic fix commit. Apply the appropriate `a11y-focus`, `a11y-navigation`, `a11y-semantics`, or `a11y-regression` label. + +## Release decision + +- [ ] No release-blocking accessibility regressions remain. +- Decision and rationale: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..77fc7fa8ccd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Summary + +Describe the user-visible change and link the relevant issue. + +## Verification + +- [ ] The affected target builds successfully. +- [ ] The change was tested on a supported iOS version. +- [ ] `python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v` passes. + +## Accessibility + +- [ ] Labels describe content without repeating the control role. +- [ ] Values expose selection, delivery, playback, loading, and disabled states. +- [ ] Hints describe the result of activation. +- [ ] VoiceOver focus survives updates by a stable domain identifier. +- [ ] Modal UI contains traversal, supports Escape, and restores trigger focus. +- [ ] Dynamic Type, Reduce Motion, and Voice Control were checked where applicable. +- [ ] Decorative or hidden views are excluded from the accessibility tree. +- [ ] The iOS 17+ XCTest accessibility-tree audit passes. +- [ ] Manual VoiceOver verification evidence is attached, or the reason it is not applicable is documented. diff --git a/.github/workflows/voiceover-gate.yml b/.github/workflows/voiceover-gate.yml new file mode 100644 index 00000000000..66dc026a3be --- /dev/null +++ b/.github/workflows/voiceover-gate.yml @@ -0,0 +1,52 @@ +name: VoiceOver accessibility gate + +on: + pull_request: + paths: + - "submodules/**/*.swift" + - "Telegram/Tests/Sources/AccessibilityUITests.swift" + - "Tests/VoiceOverContracts/**" + - ".github/workflows/voiceover-gate.yml" + workflow_dispatch: + +jobs: + source-contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run VoiceOver source contracts + run: python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v + + simulator-tree-audit: + if: github.event_name == 'workflow_dispatch' + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + - name: Select repository Xcode version + run: | + XCODE_VERSION=$(python3 -c 'import json; print(json.load(open("versions.json"))["xcode"])') + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + - name: Run iOS accessibility tree audit + run: | + python3 build-system/Make/Make.py \ + --bazelUserRoot=/private/var/tmp/_bazel_voiceover \ + test \ + --configurationPath=build-system/appstore-configuration.json \ + --disableProvisioningProfiles \ + --target=Telegram:iOSAppUITestSuite + - name: Collect XCTest result bundles + if: always() + run: | + mkdir -p voiceover-test-results + find bazel-testlogs -name "*.xcresult" -exec cp -R {} voiceover-test-results/ \; || true + - name: Upload VoiceOver performance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: voiceover-xcresult + path: voiceover-test-results + if-no-files-found: warn + retention-days: 30 diff --git a/ACCESSIBILITY_CHANGELOG.md b/ACCESSIBILITY_CHANGELOG.md new file mode 100644 index 00000000000..2054a5bbfb6 --- /dev/null +++ b/ACCESSIBILITY_CHANGELOG.md @@ -0,0 +1,16 @@ +# Accessibility changelog + +## Unreleased — VoiceOver-fixes + +- Added navigable chat history scrolling, localized scroll feedback, and an accessible jump-to-latest control. +- Preserved message focus by stable `MessageId` after history transactions. +- Improved input-field hit testing, frame updates, and focus behavior. +- Unified message renderer labels, values, hints, traits, delivery/playback states, reply semantics, and custom actions. +- Added stable accessibility identifiers for message renderers. +- Improved selection states, disabled-limit explanations, and focus persistence across selection updates. +- Improved Share Extension mode semantics, recipients, search, topics, initial focus, Escape, error handling, and modal containment. +- Improved Peer Info, Gifts, contacts, global search, attachment search, and sticker search semantics and stable focus restoration. +- Added accessible gift context actions and non-drag reorder alternatives. +- Added modal containment, initial focus, Escape, and trigger-focus restoration to shared alerts, action sheets, context, peek, and pinch controllers. +- Added source-contract tests, an iOS accessibility-tree audit, a pull-request checklist, and a documented release gate. +- Added an initial accessibility-tree size budget, XCTest traversal time/memory metrics, and retained `.xcresult` performance evidence. diff --git a/Telegram/Tests/Sources/AccessibilityUITests.swift b/Telegram/Tests/Sources/AccessibilityUITests.swift new file mode 100644 index 00000000000..e63b0e08f32 --- /dev/null +++ b/Telegram/Tests/Sources/AccessibilityUITests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest + +final class AccessibilityUITests: XCTestCase { + private static let initialTreeElementBudget = 500 + private static let traversalSampleCount = 10 + private static let averageTraversalBudget: TimeInterval = 2.0 + private static let maximumTraversalBudget: TimeInterval = 5.0 + + private var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments += ["--ui-test", "--voiceover-release-gate"] + } + + override func tearDownWithError() throws { + app = nil + } + + @available(iOS 17.0, *) + func testInitialAccessibilityTree() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + try app.performAccessibilityAudit() + } + + func testStableMessageIdentifiersAreUnique() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let messages = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "message.") + ) + var identifiers = Set() + for index in 0 ..< messages.count { + let identifier = messages.element(boundBy: index).identifier + XCTAssertFalse(identifier.isEmpty) + XCTAssertTrue(identifiers.insert(identifier).inserted, "Duplicate message accessibility identifier: \(identifier)") + } + } + + func testInitialAccessibilityTreeStaysWithinBudget() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let elementCount = app.descendants(matching: .any).count + let attachment = XCTAttachment(string: "Initial accessibility tree elements: \(elementCount)") + attachment.name = "Accessibility tree size" + attachment.lifetime = .keepAlways + add(attachment) + + XCTAssertLessThanOrEqual( + elementCount, + Self.initialTreeElementBudget, + "Initial accessibility tree exceeded its budget; inspect hidden or decorative duplicate elements" + ) + } + + func testAccessibilityTreeTraversalPerformance() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + _ = app.descendants(matching: .any).count + + var samples: [TimeInterval] = [] + for _ in 0 ..< Self.traversalSampleCount { + let startTime = CFAbsoluteTimeGetCurrent() + _ = app.descendants(matching: .any).count + samples.append(CFAbsoluteTimeGetCurrent() - startTime) + } + + let average = samples.reduce(0.0, +) / Double(samples.count) + let maximum = samples.max() ?? 0.0 + let attachment = XCTAttachment( + string: "Traversal samples: \(samples)\nAverage: \(average)\nMaximum: \(maximum)" + ) + attachment.name = "Accessibility traversal performance" + attachment.lifetime = .keepAlways + add(attachment) + + XCTAssertLessThanOrEqual(average, Self.averageTraversalBudget) + XCTAssertLessThanOrEqual(maximum, Self.maximumTraversalBudget) + + measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) { + _ = app.descendants(matching: .any).count + } + } +} diff --git a/Tests/VoiceOverContracts/test_voiceover_contracts.py b/Tests/VoiceOverContracts/test_voiceover_contracts.py new file mode 100644 index 00000000000..a05319d4d8b --- /dev/null +++ b/Tests/VoiceOverContracts/test_voiceover_contracts.py @@ -0,0 +1,154 @@ +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def source(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +class MessageRendererContractTests(unittest.TestCase): + def test_all_top_level_renderers_use_shared_accessibility_data(self) -> None: + renderers = ( + "submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift", + "submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift", + "submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift", + "submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoItemNode/Sources/ChatMessageInstantVideoItemNode.swift", + ) + for renderer in renderers: + with self.subTest(renderer=renderer): + contents = source(renderer) + self.assertIn("ChatMessageAccessibilityData(item:", contents) + self.assertIn("updateAccessibilityData", contents) + + def test_shared_contract_assigns_required_properties(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift" + ) + for assignment in ( + "accessibilityNode.accessibilityLabel = accessibilityData.label", + "accessibilityNode.accessibilityValue = accessibilityData.value", + "accessibilityNode.accessibilityHint = accessibilityData.hint", + "accessibilityNode.accessibilityTraits = accessibilityData.traits", + "accessibilityNode.accessibilityIdentifier = \"message.", + "accessibilityNode.accessibilityCustomActions", + ): + with self.subTest(assignment=assignment): + self.assertIn(assignment, contents) + + def test_shared_contract_exposes_message_actions(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift" + ) + for action in (".reply", ".react", ".options", ".copy", ".forward", ".delete"): + with self.subTest(action=action): + self.assertIn(f"case {action}:", contents) + + +class FocusPersistenceContractTests(unittest.TestCase): + def test_transaction_lists_restore_only_existing_stable_ids(self) -> None: + owners = ( + "submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift", + "submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift", + "submodules/ContactListUI/Sources/ContactListNode.swift", + "submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift", + "submodules/ContactListUI/Sources/InviteContactsControllerNode.swift", + "submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift", + "submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift", + "submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift", + "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift", + "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift", + "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift", + "submodules/ShareController/Sources/SharePeersContainerNode.swift", + "submodules/ShareController/Sources/ShareSearchContainerNode.swift", + "submodules/ShareController/Sources/ShareTopicsContainerNode.swift", + ) + for owner in owners: + with self.subTest(owner=owner): + contents = source(owner) + self.assertIn("accessibilityElementIsFocused", contents) + self.assertIn("UIAccessibility.post(notification: .layoutChanged", contents) + + history = source("submodules/TelegramUI/Sources/ChatHistoryListNode.swift") + self.assertIn("accessibilityContainsFocus()", history) + self.assertIn("restoreAccessibilityFocus()", history) + self.assertIn("accessibilityFocusedMessageId", history) + + sticker_item = source( + "submodules/FeaturedStickersScreen/Sources/StickerPaneSearchStickerItem.swift" + ) + self.assertIn('accessibilityIdentifier = "sticker.', sticker_item) + self.assertIn("override func accessibilityActivate() -> Bool", sticker_item) + + +class ModalContractTests(unittest.TestCase): + def test_shared_modal_controllers_enforce_complete_contract(self) -> None: + controllers = ( + "submodules/Display/Source/AlertController.swift", + "submodules/Display/Source/ActionSheetController.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift", + ) + for controller in controllers: + with self.subTest(controller=controller): + contents = source(controller) + self.assertIn("accessibilityPerformEscape", contents) + self.assertIn("restoreAccessibilityFocus", contents) + + share_controller = source("submodules/ShareController/Sources/ShareController.swift") + self.assertIn("accessibilityPerformEscape", share_controller) + share_node = source("submodules/ShareController/Sources/ShareControllerNode.swift") + self.assertIn("activateInitialAccessibilityFocus", share_node) + self.assertIn("performAccessibilityEscape", share_node) + + def test_modal_nodes_contain_voiceover_traversal(self) -> None: + node_sources = ( + "submodules/Display/Source/AlertControllerNode.swift", + "submodules/Display/Source/ActionSheetControllerNode.swift", + "submodules/ShareController/Sources/ShareControllerNode.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerImpl.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/PeekController.swift", + "submodules/TelegramUI/Components/ContextControllerImpl/Sources/PinchController.swift", + ) + for node_source in node_sources: + with self.subTest(node_source=node_source): + self.assertIn("accessibilityViewIsModal = true", source(node_source)) + + +class ReleaseGateContractTests(unittest.TestCase): + def test_ui_suite_keeps_tree_audit_and_performance_budget(self) -> None: + contents = source("Telegram/Tests/Sources/AccessibilityUITests.swift") + for contract in ( + "performAccessibilityAudit()", + "initialTreeElementBudget", + "averageTraversalBudget", + "maximumTraversalBudget", + "XCTClockMetric()", + "XCTMemoryMetric()", + "XCTAttachment", + ): + with self.subTest(contract=contract): + self.assertIn(contract, contents) + + def test_workflow_runs_both_automated_gate_layers(self) -> None: + contents = source(".github/workflows/voiceover-gate.yml") + self.assertIn("python3 -m unittest discover", contents) + self.assertIn("--target=Telegram:iOSAppUITestSuite", contents) + self.assertIn("actions/upload-artifact@v4", contents) + + def test_release_process_artifacts_are_present(self) -> None: + for artifact in ( + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/ISSUE_TEMPLATE/voiceover_tracking.md", + "ACCESSIBILITY_CHANGELOG.md", + "docs/VOICEOVER_RELEASE_GATE.md", + ): + with self.subTest(artifact=artifact): + self.assertTrue((ROOT / artifact).is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/VOICEOVER_RELEASE_GATE.md b/docs/VOICEOVER_RELEASE_GATE.md new file mode 100644 index 00000000000..2f2b32692d8 --- /dev/null +++ b/docs/VOICEOVER_RELEASE_GATE.md @@ -0,0 +1,58 @@ +# VoiceOver release gate + +Accessibility changes are release-ready only after all three layers below pass. + +## 1. Source contracts + +Run from the repository root: + +```sh +python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v +``` + +These tests prevent known renderer, stable-focus, and modal contracts from being removed. They do not prove runtime accessibility. + +## 2. Simulator tree audit + +Generate the Xcode project using the repository build instructions and run `AccessibilityUITests` from `iOSAppUITestSuite` on an iOS 17 or newer simulator. The suite runs the XCTest accessibility audit and checks stable message identifiers for duplicates. + +The repository command used by the manually dispatched GitHub gate is: + +```sh +python3 build-system/Make/Make.py \ + --bazelUserRoot=/private/var/tmp/_bazel_voiceover \ + test \ + --configurationPath=build-system/appstore-configuration.json \ + --disableProvisioningProfiles \ + --target=Telegram:iOSAppUITestSuite +``` + +Run the suite in at least these states: + +- signed out, welcome screen; +- signed in, chat list populated; +- chat containing text, media, voice, instant video, reply, service, gift, and paid-media messages; +- search results visible; +- a context menu or modal sheet open. + +The initial accessibility tree has a regression budget of 500 exposed elements. After one warm-up traversal, the suite takes ten samples and enforces a conservative average budget of 2 seconds and a maximum budget of 5 seconds. It also records XCTest wall-clock time and memory through `XCTClockMetric` and `XCTMemoryMetric`. Tighten the conservative budgets after accepting a stable device-specific baseline. Treat a statistically significant regression against the latest accepted `.xcresult` as release-blocking even when the absolute budgets still pass. + +## 3. Manual VoiceOver matrix + +Record the device, iOS version, app commit, locale, and result for each scenario: + +- history traversal and three-finger scrolling; +- jump to latest and input focus; +- reply, react, options, copy, forward, and delete actions; +- delivery, read, playback, selection, loading, error, and disabled states; +- selection transactions and limit errors; +- Share Extension peers, search, topics, error, Escape, and return focus; +- Peer Info, Gifts, members, contacts, and search transaction focus; +- context, peek, pinch, alert, and action-sheet containment and Escape; +- Dynamic Type accessibility sizes, Reduce Motion, and Voice Control names. + +A release is blocked when a P0 chat flow fails, focus moves to an unrelated element, a modal leaks background traversal, or an interactive control has no meaningful name or action. + +## Evidence + +Attach the source-contract log, XCTest result bundle, tree-size attachment, performance comparison, VoiceOver transcript or recording, and discovered regressions to the release tracking issue. The manually dispatched GitHub workflow retains its `.xcresult` artifact for 30 days. Regressions must link to an atomic fix commit or a documented release-blocking decision. diff --git a/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift b/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift index f24850286b2..08c22da8dbc 100644 --- a/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift +++ b/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift @@ -207,7 +207,7 @@ private func preparedInviteContactsTransition(context: AccountContext, presentat let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - return InviteContactsTransition(deletions: deletions, insertions: insertions, updates: updates, sortedContacts: sortedContacts, isLoading: isLoading, firstTime: firstTime, crossfade: crossfade) + return InviteContactsTransition(deletions: deletions, insertions: insertions, updates: updates, sortedContacts: sortedContacts, isLoading: isLoading, firstTime: firstTime, crossfade: crossfade, entries: toEntries) } private struct InviteContactsTransition { @@ -218,6 +218,14 @@ private struct InviteContactsTransition { let isLoading: Bool let firstTime: Bool let crossfade: Bool + let entries: [InviteContactsEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } final class InviteContactsControllerNode: ASDisplayNode { @@ -258,6 +266,7 @@ final class InviteContactsControllerNode: ASDisplayNode { private let selectionStatePromise = Promise(InviteContactsGroupSelectionState()) private var queuedTransitions: [InviteContactsTransition] = [] + private var displayedEntries: [InviteContactsEntry] = [] private var presentationData: PresentationData private var presentationDataDisposable: Disposable? @@ -559,8 +568,29 @@ final class InviteContactsControllerNode: ASDisplayNode { } else if transition.crossfade { options.insert(.AnimateCrossfade) } + var focusedEntryId: InviteContactsEntryId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { + strongSelf.displayedEntries = transition.entries + if let focusedEntryId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } strongSelf.readyValue = true if transition.isLoading, strongSelf.activityIndicator == nil { diff --git a/submodules/FeaturedStickersScreen/Sources/StickerPaneSearchStickerItem.swift b/submodules/FeaturedStickersScreen/Sources/StickerPaneSearchStickerItem.swift index 2e12bc23310..49f7b16b74f 100644 --- a/submodules/FeaturedStickersScreen/Sources/StickerPaneSearchStickerItem.swift +++ b/submodules/FeaturedStickersScreen/Sources/StickerPaneSearchStickerItem.swift @@ -189,6 +189,15 @@ public final class StickerPaneSearchStickerItemNode: GridItemNode { self.setNeedsLayout() self.updateVisibility() } + let strings = context.sharedContext.currentPresentationData.with { $0.strings } + self.isAccessibilityElement = true + if let code, !code.isEmpty { + self.accessibilityLabel = "\(strings.VoiceOver_Chat_Sticker). \(code)" + } else { + self.accessibilityLabel = strings.VoiceOver_Chat_Sticker + } + self.accessibilityTraits = [.button, .image] + self.accessibilityIdentifier = "sticker.\(stickerItem.file.fileId.id).\(code ?? "")" } public override func layout() { @@ -214,6 +223,14 @@ public final class StickerPaneSearchStickerItemNode: GridItemNode { } self.selected?(self, itemLayer, self.bounds) } + + public override func accessibilityActivate() -> Bool { + guard let itemLayer = self.itemLayer else { + return false + } + self.selected?(self, itemLayer, self.bounds) + return self.selected != nil + } public func transitionNode() -> ASDisplayNode? { return self diff --git a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift index f45f6eac366..a8b654118f4 100644 --- a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift +++ b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift @@ -342,7 +342,7 @@ private enum AttachmentFileSearchEntry: Comparable, Identifiable { } } -struct AttachmentFileSearchContainerTransition { +private struct AttachmentFileSearchContainerTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] @@ -350,6 +350,14 @@ struct AttachmentFileSearchContainerTransition { let isEmpty: Bool let query: String let crossfade: Bool + let entries: [AttachmentFileSearchEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private func attachmentFileSearchContainerPreparedRecentTransition( @@ -372,7 +380,7 @@ private func attachmentFileSearchContainerPreparedRecentTransition( let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, interaction: interaction, mode: mode), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, interaction: interaction, mode: mode), directionHint: nil) } - return AttachmentFileSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query, crossfade: crossfade) + return AttachmentFileSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query, crossfade: crossfade, entries: toEntries) } @@ -390,6 +398,7 @@ public final class AttachmentFileSearchContainerNode: SearchDisplayControllerCon private let emptyResultsTextNode: ImmediateTextNode private var enqueuedTransitions: [(AttachmentFileSearchContainerTransition, Bool)] = [] + private var displayedEntries: [AttachmentFileSearchEntry] = [] private var validLayout: (ContainerViewLayout, CGFloat)? private let searchQuery = Promise() @@ -800,10 +809,31 @@ public final class AttachmentFileSearchContainerNode: SearchDisplayControllerCon } let isSearching = transition.isSearching + var focusedEntryId: AttachmentFileSearchEntryId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedEntryId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transition.entries + if let focusedEntryId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedEntryId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } let containerTransition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) containerTransition.updateAlpha(node: strongSelf.backgroundNode, alpha: isSearching ? 1.0 : 0.0) diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift index aad31d517da..00a5ce29209 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift @@ -255,6 +255,7 @@ private struct StickerPaneSearchGridTransition { let scrollToItem: GridNodeScrollToItem? let animated: Bool let crossfade: Bool + let accessibilityIdentifiers: [String] } private struct StickerPaneSearchStickerState { @@ -277,7 +278,13 @@ private func preparedChatMediaInputGridEntryTransition(context: AccountContext, let firstIndexInSectionOffset = 0 - return StickerPaneSearchGridTransition(deletions: deletions, insertions: insertions, updates: updates, updateFirstIndexInSectionOffset: firstIndexInSectionOffset, stationaryItems: stationaryItems, scrollToItem: scrollToItem, animated: animated, crossfade: crossfade) + let accessibilityIdentifiers = toEntries.map { entry -> String in + switch entry { + case let .sticker(_, code, stickerItem, _): + return "sticker.\(stickerItem.file.fileId.id).\(code ?? "")" + } + } + return StickerPaneSearchGridTransition(deletions: deletions, insertions: insertions, updates: updates, updateFirstIndexInSectionOffset: firstIndexInSectionOffset, stationaryItems: stationaryItems, scrollToItem: scrollToItem, animated: animated, crossfade: crossfade, accessibilityIdentifiers: accessibilityIdentifiers) } final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { @@ -1064,7 +1071,26 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } let itemTransition: ContainedViewLayoutTransition = .immediate - self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { _ in }) + var focusedAccessibilityIdentifier: String? + if UIAccessibility.isVoiceOverRunning { + self.gridNode.forEachItemNode { itemNode in + if itemNode.view.accessibilityElementIsFocused() { + focusedAccessibilityIdentifier = itemNode.accessibilityIdentifier + } + } + } + self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { [weak self] _ in + guard let self else { + return + } + if let focusedAccessibilityIdentifier, transition.accessibilityIdentifiers.contains(focusedAccessibilityIdentifier) { + self.gridNode.forEachItemNode { itemNode in + if itemNode.accessibilityIdentifier == focusedAccessibilityIdentifier, !itemNode.view.accessibilityElementIsFocused() { + UIAccessibility.post(notification: .layoutChanged, argument: itemNode.view) + } + } + } + }) } } diff --git a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift index 381166f2d72..64ac9fabde0 100644 --- a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift @@ -57,13 +57,21 @@ private final class GroupStickerSearchEntry: Comparable, Identifiable { } } -struct GroupStickerSearchContainerTransition { +private struct GroupStickerSearchContainerTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let isSearching: Bool let isEmpty: Bool let query: String + let entries: [GroupStickerSearchEntry] +} + +private func accessibilityElementIsFocused(in view: UIView) -> Bool { + if view.isAccessibilityElement && view.accessibilityElementIsFocused() { + return true + } + return view.subviews.contains(where: { accessibilityElementIsFocused(in: $0) }) } private func groupStickerSearchContainerPreparedRecentTransition(from fromEntries: [GroupStickerSearchEntry], to toEntries: [GroupStickerSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, interaction: GroupStickerSearchContainerInteraction) -> GroupStickerSearchContainerTransition { @@ -73,7 +81,7 @@ private func groupStickerSearchContainerPreparedRecentTransition(from fromEntrie let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - return GroupStickerSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query) + return GroupStickerSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query, entries: toEntries) } public final class GroupStickerSearchContainerNode: SearchDisplayControllerContentNode { @@ -86,6 +94,7 @@ public final class GroupStickerSearchContainerNode: SearchDisplayControllerConte private let emptyResultsTextNode: ImmediateTextNode private var enqueuedTransitions: [(GroupStickerSearchContainerTransition, Bool)] = [] + private var displayedEntries: [GroupStickerSearchEntry] = [] private var validLayout: (ContainerViewLayout, CGFloat)? private let searchQuery = Promise() @@ -251,10 +260,31 @@ public final class GroupStickerSearchContainerNode: SearchDisplayControllerConte } let isSearching = transition.isSearching + var focusedPackId: EngineItemCollectionId? + if UIAccessibility.isVoiceOverRunning { + for itemNode in self.listNode.visibleItemNodes() { + guard let index = itemNode.index, self.displayedEntries.indices.contains(index) else { + continue + } + if accessibilityElementIsFocused(in: itemNode.view) { + focusedPackId = self.displayedEntries[index].stableId + break + } + } + } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } + strongSelf.displayedEntries = transition.entries + if let focusedPackId, let index = transition.entries.firstIndex(where: { $0.stableId == focusedPackId }) { + for itemNode in strongSelf.listNode.visibleItemNodes() { + if itemNode.index == index, !accessibilityElementIsFocused(in: itemNode.view) { + UIAccessibility.post(notification: .layoutChanged, argument: firstAccessibilityElement(in: itemNode.view) ?? itemNode.view) + break + } + } + } strongSelf.listNode.isHidden = !isSearching From 0f958f63b652e0a877b6b3b445188a9079d50b87 Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:03:11 +0500 Subject: [PATCH 17/18] Expand VoiceOver regression coverage Add a stable chat input identifier and opt-in populated-chat UI tests for message semantics, frames, hit testing, and keyboard focus. Expand source contracts across media states, navigation, Share, Gifts, modal accessibility, Dynamic Type, Reduce Motion, and Reduce Transparency. Run the accessibility gate automatically for VoiceOver-fixes pushes and update release documentation. --- .github/workflows/voiceover-gate.yml | 8 + ACCESSIBILITY_CHANGELOG.md | 2 + .../Tests/Sources/AccessibilityUITests.swift | 42 ++- .../test_voiceover_contracts.py | 239 ++++++++++++++++++ docs/VOICEOVER_RELEASE_GATE.md | 4 + .../Sources/ChatTextInputPanelNode.swift | 1 + 6 files changed, 295 insertions(+), 1 deletion(-) diff --git a/.github/workflows/voiceover-gate.yml b/.github/workflows/voiceover-gate.yml index 66dc026a3be..26c5f59d51f 100644 --- a/.github/workflows/voiceover-gate.yml +++ b/.github/workflows/voiceover-gate.yml @@ -1,6 +1,14 @@ name: VoiceOver accessibility gate on: + push: + branches: + - "VoiceOver-fixes" + paths: + - "submodules/**/*.swift" + - "Telegram/Tests/Sources/AccessibilityUITests.swift" + - "Tests/VoiceOverContracts/**" + - ".github/workflows/voiceover-gate.yml" pull_request: paths: - "submodules/**/*.swift" diff --git a/ACCESSIBILITY_CHANGELOG.md b/ACCESSIBILITY_CHANGELOG.md index 2054a5bbfb6..2c03575dfd2 100644 --- a/ACCESSIBILITY_CHANGELOG.md +++ b/ACCESSIBILITY_CHANGELOG.md @@ -14,3 +14,5 @@ - Added modal containment, initial focus, Escape, and trigger-focus restoration to shared alerts, action sheets, context, peek, and pinch controllers. - Added source-contract tests, an iOS accessibility-tree audit, a pull-request checklist, and a documented release gate. - Added an initial accessibility-tree size budget, XCTest traversal time/memory metrics, and retained `.xcresult` performance evidence. +- Added opt-in populated-chat UI contracts for message names, stable identifiers, frames, and the input field hit target using `VOICEOVER_USE_EXISTING_DATA=1`. +- Expanded source contracts to cover media/reply/delivery/play states, history scrolling, selection limits, Share modes, Gifts actions, Dynamic Type, Reduce Motion, and Reduce Transparency. diff --git a/Telegram/Tests/Sources/AccessibilityUITests.swift b/Telegram/Tests/Sources/AccessibilityUITests.swift index e63b0e08f32..b76cc091afc 100644 --- a/Telegram/Tests/Sources/AccessibilityUITests.swift +++ b/Telegram/Tests/Sources/AccessibilityUITests.swift @@ -12,7 +12,10 @@ final class AccessibilityUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false app = XCUIApplication() - app.launchArguments += ["--ui-test", "--voiceover-release-gate"] + if ProcessInfo.processInfo.environment["VOICEOVER_USE_EXISTING_DATA"] != "1" { + app.launchArguments.append("--ui-test") + } + app.launchArguments.append("--voiceover-release-gate") } override func tearDownWithError() throws { @@ -87,4 +90,41 @@ final class AccessibilityUITests: XCTestCase { _ = app.descendants(matching: .any).count } } + + func testPopulatedChatMessageContractWhenFixtureIsAvailable() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let messages = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "message.") + ) + guard messages.count != 0 else { + throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a populated chat before launching the test") + } + + var identifiers = Set() + for index in 0 ..< messages.count { + let message = messages.element(boundBy: index) + XCTAssertFalse(message.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + XCTAssertFalse(message.identifier.isEmpty) + XCTAssertTrue(identifiers.insert(message.identifier).inserted) + XCTAssertGreaterThan(message.frame.width, 0.0) + XCTAssertGreaterThan(message.frame.height, 0.0) + } + } + + func testChatInputHitTargetWhenFixtureIsAvailable() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let input = app.descendants(matching: .any)["chat.input"] + guard input.waitForExistence(timeout: 2.0) else { + throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a writable chat before launching the test") + } + XCTAssertGreaterThanOrEqual(input.frame.width, 44.0) + XCTAssertGreaterThanOrEqual(input.frame.height, 44.0) + XCTAssertTrue(input.isHittable) + input.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 2.0)) + } } diff --git a/Tests/VoiceOverContracts/test_voiceover_contracts.py b/Tests/VoiceOverContracts/test_voiceover_contracts.py index a05319d4d8b..28aef431a35 100644 --- a/Tests/VoiceOverContracts/test_voiceover_contracts.py +++ b/Tests/VoiceOverContracts/test_voiceover_contracts.py @@ -46,6 +46,85 @@ def test_shared_contract_exposes_message_actions(self) -> None: with self.subTest(action=action): self.assertIn(f"case {action}:", contents) + def test_shared_contract_covers_media_reply_and_message_states(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift" + ) + media_contracts = ( + "TelegramMediaImage", + "file.isInstantVideo", + ".Sticker(", + ".Audio(", + ".Video(", + "TelegramMediaWebpage", + "TelegramMediaContact", + "TelegramMediaPoll", + ) + state_contracts = ( + "VoiceOver_Chat_Selected", + "traits.insert(.selected)", + "VoiceOver_Chat_Sending", + "VoiceOver_Chat_Failed", + "Conversation_ChecksTooltip_Read", + "Conversation_ChecksTooltip_Delivered", + "VoiceOver_Chat_NotPlayedByRecipient", + "VoiceOver_Chat_PlayedByRecipient", + "ReplyMessageAttribute", + "VoiceOver_Chat_ReplyingToMessage", + ".navigateToReply(replyMessageId)", + ) + for contract in media_contracts + state_contracts: + with self.subTest(contract=contract): + self.assertIn(contract, contents) + + fallback_contents = source( + "submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift" + ) + for media_type in ( + "TelegramMediaPaidContent", + "TelegramMediaMap", + "TelegramMediaGame", + "TelegramMediaInvoice", + "TelegramMediaAction", + "TelegramMediaStory", + "TelegramMediaGiveaway", + "TelegramMediaGiveawayResults", + ): + with self.subTest(media_type=media_type): + self.assertIn(media_type, fallback_contents) + + +class ChatNavigationContractTests(unittest.TestCase): + def test_history_supports_voiceover_scroll_and_stable_message_focus(self) -> None: + list_view = source("submodules/Display/Source/ListView.swift") + self.assertIn( + "override open func accessibilityScroll(_ direction: UIAccessibilityScrollDirection) -> Bool", + list_view, + ) + self.assertIn("self.rotated ? .up : .down", list_view) + self.assertIn("UIAccessibility.Notification.pageScrolled", list_view) + + history = source("submodules/TelegramUI/Sources/ChatHistoryListNode.swift") + for contract in ( + "accessibilityFocusedMessageId", + "accessibilityContainsFocus()", + "restoreAccessibilityFocus()", + ): + self.assertIn(contract, history) + + def test_input_exposes_stable_hit_target_and_screen_frame(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift" + ) + for contract in ( + 'accessibilityIdentifier = "chat.input"', + "inputHitTestSlop", + "UIAccessibility.convertToScreenCoordinates", + "accessibilityRespondsToUserInteraction = true", + "UIAccessibility.post(notification: .layoutChanged, argument: new.inputView)", + ): + self.assertIn(contract, contents) + class FocusPersistenceContractTests(unittest.TestCase): def test_transaction_lists_restore_only_existing_stable_ids(self) -> None: @@ -58,6 +137,7 @@ def test_transaction_lists_restore_only_existing_stable_ids(self) -> None: "submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift", "submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift", "submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift", + "submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift", "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift", "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift", "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift", @@ -117,6 +197,161 @@ def test_modal_nodes_contain_voiceover_traversal(self) -> None: with self.subTest(node_source=node_source): self.assertIn("accessibilityViewIsModal = true", source(node_source)) + def test_modal_backgrounds_are_hidden_and_reduce_motion_is_respected(self) -> None: + alert = source("submodules/Display/Source/AlertControllerNode.swift") + action_sheet = source("submodules/Display/Source/ActionSheetControllerNode.swift") + self.assertIn("dimContainerView.accessibilityElementsHidden = true", alert) + for background in ( + "dismissTapView", + "leftDimView", + "rightDimView", + "topDimView", + "bottomDimView", + ): + self.assertIn(f"{background}.accessibilityElementsHidden = true", action_sheet) + self.assertIn("UIAccessibility.isReduceMotionEnabled", alert) + self.assertIn("UIAccessibility.isReduceMotionEnabled", action_sheet) + + archive = source( + "submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift" + ) + self.assertIn("self.accessibilityViewIsModal = true", archive) + self.assertIn("UIAccessibility.post(notification: .screenChanged", archive) + self.assertIn("override public func accessibilityPerformEscape() -> Bool", archive) + + +class SelectionAndShareContractTests(unittest.TestCase): + def test_share_peer_and_topic_selection_expose_full_state(self) -> None: + peer = source("submodules/ShareController/Sources/ShareControllerPeerGridItem.swift") + topic = source("submodules/ShareController/Sources/ShareTopicGridItem.swift") + for contract in ( + "accessibilityLabel", + "accessibilityValue", + "accessibilityHint", + "accessibilityTraits.insert(.selected)", + "accessibilityTraits.insert(.notEnabled)", + "override func accessibilityActivate() -> Bool", + ): + self.assertIn(contract, peer) + for contract in ( + "accessibilityLabel", + "accessibilityValue", + "accessibilityTraits.insert(.selected)", + "override func accessibilityActivate() -> Bool", + ): + self.assertIn(contract, topic) + + def test_share_modes_focus_error_and_escape_contracts_are_retained(self) -> None: + segmented = source("submodules/SegmentedControlNode/Sources/SegmentedControlNode.swift") + self.assertIn("itemNode.accessibilityLabel = item.title", segmented) + self.assertIn("itemNode.accessibilityTraits.insert(.selected)", segmented) + + node = source("submodules/ShareController/Sources/ShareControllerNode.swift") + for contract in ( + "activateInitialAccessibilityFocus", + "accessibilityFocusTarget(peerId:", + "accessibilityInitialFocusTarget", + "UIAccessibility.post(notification: .screenChanged", + "UIAccessibility.post(notification: .layoutChanged, argument: self.actionButtonNode.view)", + ): + self.assertIn(contract, node) + controller = source("submodules/ShareController/Sources/ShareController.swift") + self.assertIn("override public func accessibilityPerformEscape() -> Bool", controller) + + +class GiftsContractTests(unittest.TestCase): + def test_gift_card_exposes_semantics_activation_and_context_menu(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift" + ) + for contract in ( + "override public func accessibilityActivate() -> Bool", + "self.isAccessibilityElement = exposesCard && !component.isPlaceholder", + "self.containerButton.isAccessibilityElement = false", + "self.accessibilityLabel = label", + "self.accessibilityValue = values.isEmpty ? nil", + "self.accessibilityTraits = [.image]", + "self.accessibilityTraits.insert(.button)", + "self.accessibilityTraits.insert(.selected)", + "accessibilityOpenContextMenu", + ): + with self.subTest(contract=contract): + self.assertIn(contract, contents) + + def test_gifts_keep_selection_limit_actions_and_stable_focus(self) -> None: + contents = source( + "submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/GiftsListView.swift" + ) + for contract in ( + "accessibilityTraits.insert(.selected)", + "accessibilityTraits.insert(.notEnabled)", + "RequestPeer_ReachedMaximum", + "kind: .movePrevious", + "kind: .moveNext", + "kind: .togglePinned", + "accessibilityCustomActions", + "focusedItemId", + "UIAccessibility.post(notification: .layoutChanged, argument: accessibilityView)", + "itemView.accessibilityElementsHidden = true", + ): + with self.subTest(contract=contract): + self.assertIn(contract, contents) + + def test_collection_tabs_expose_selection_and_reorder_actions_without_duplicates(self) -> None: + selector = source( + "submodules/TelegramUI/Components/TabSelectorComponent/Sources/TabSelectorComponent.swift" + ) + for contract in ( + "self.isAccessibilityElement = true", + "self.containerNode.accessibilityElementsHidden = true", + "self.accessibilityLabel = title", + "self.accessibilityTraits.insert(.selected)", + "self.accessibilityTraits.insert(.notEnabled)", + "override func accessibilityActivate() -> Bool", + "accessibilityReorderPreviousTitle", + "accessibilityReorderNextTitle", + "itemView.accessibilityCustomActions", + ): + with self.subTest(contract=contract): + self.assertIn(contract, selector) + + collection_tab = source( + "submodules/TelegramUI/Components/PeerInfo/CollectionTabItemComponent/Sources/CollectionTabItemComponent.swift" + ) + self.assertIn("self.accessibilityLabel = component.title", collection_tab) + + +class AccessibilityPreferencesContractTests(unittest.TestCase): + def test_common_modals_support_dynamic_type_and_reduce_transparency(self) -> None: + archive = source( + "submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift" + ) + archive_content = source( + "submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoContentComponent.swift" + ) + alert_controller = source("submodules/Display/Source/AlertController.swift") + action_sheet_controller = source("submodules/Display/Source/ActionSheetController.swift") + alert_node = source("submodules/Display/Source/AlertControllerNode.swift") + action_sheet_group = source("submodules/Display/Source/ActionSheetItemGroupNode.swift") + + self.assertIn("UIFontMetrics(forTextStyle: .headline)", archive) + self.assertIn("UIFontMetrics(forTextStyle: .headline)", archive_content) + self.assertIn("UIFontMetrics(forTextStyle: .body)", archive_content) + self.assertIn("UIContentSizeCategory.didChangeNotification", alert_controller) + self.assertIn("UIContentSizeCategory.didChangeNotification", action_sheet_controller) + self.assertIn("UIAccessibility.isReduceTransparencyEnabled", alert_node) + self.assertIn("UIAccessibility.isReduceTransparencyEnabled", action_sheet_group) + + def test_url_auth_alert_reflows_at_accessibility_sizes(self) -> None: + contents = source("submodules/TelegramUI/Sources/ChatMessageActionUrlAuthController.swift") + for contract in ( + "UIFontMetrics(forTextStyle: .footnote)", + "UIFontMetrics(forTextStyle: .headline)", + "override func contentSizeCategoryUpdated()", + "preferredContentSizeCategory.isAccessibilityCategory", + ): + self.assertIn(contract, contents) + class ReleaseGateContractTests(unittest.TestCase): def test_ui_suite_keeps_tree_audit_and_performance_budget(self) -> None: @@ -129,12 +364,16 @@ def test_ui_suite_keeps_tree_audit_and_performance_budget(self) -> None: "XCTClockMetric()", "XCTMemoryMetric()", "XCTAttachment", + "testPopulatedChatMessageContractWhenFixtureIsAvailable", + "testChatInputHitTargetWhenFixtureIsAvailable", + "VOICEOVER_USE_EXISTING_DATA", ): with self.subTest(contract=contract): self.assertIn(contract, contents) def test_workflow_runs_both_automated_gate_layers(self) -> None: contents = source(".github/workflows/voiceover-gate.yml") + self.assertIn('branches:\n - "VoiceOver-fixes"', contents) self.assertIn("python3 -m unittest discover", contents) self.assertIn("--target=Telegram:iOSAppUITestSuite", contents) self.assertIn("actions/upload-artifact@v4", contents) diff --git a/docs/VOICEOVER_RELEASE_GATE.md b/docs/VOICEOVER_RELEASE_GATE.md index 2f2b32692d8..21068b9600d 100644 --- a/docs/VOICEOVER_RELEASE_GATE.md +++ b/docs/VOICEOVER_RELEASE_GATE.md @@ -12,10 +12,14 @@ python3 -m unittest discover -s Tests/VoiceOverContracts -p "test_*.py" -v These tests prevent known renderer, stable-focus, and modal contracts from being removed. They do not prove runtime accessibility. +The source-contract job runs automatically for pushes to `VoiceOver-fixes` and for pull requests that touch accessibility implementation or gate files. + ## 2. Simulator tree audit Generate the Xcode project using the repository build instructions and run `AccessibilityUITests` from `iOSAppUITestSuite` on an iOS 17 or newer simulator. The suite runs the XCTest accessibility audit and checks stable message identifiers for duplicates. +The default suite launches with `--ui-test` and therefore uses a clean signed-out data directory. To exercise the populated-chat assertions, prepare a dedicated simulator account, leave the required chat open, and run the suite with `VOICEOVER_USE_EXISTING_DATA=1` in the test scheme environment. In this mode the tests retain simulator data and additionally verify message names, unique stable identifiers, non-empty frames, and the input field's expanded hit target and keyboard focus. These tests report `XCTSkip`, rather than a false pass, when the required fixture is absent. + The repository command used by the manually dispatched GitHub gate is: ```sh diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index da7c676ff45..5412c51e782 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -3218,6 +3218,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg richTextInputNode.updateLayout(size: textFieldFrame.size) let accessibilityInputView = accessibilityTextInputView(in: richTextInputNode.inputView) let accessibilityBounds = richTextInputNode.inputView.bounds.inset(by: richTextInputNode.inputHitTestSlop) + accessibilityInputView.accessibilityIdentifier = "chat.input" accessibilityInputView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(accessibilityBounds, in: richTextInputNode.inputView) accessibilityInputView.accessibilityRespondsToUserInteraction = true self.updateInputField(textInputFrame: textFieldFrame, transition: ComponentTransition(transition)) From 693b4f91fd07b7c8da0e217002f119e85dd3b00c Mon Sep 17 00:00:00 2001 From: Danil <81031453+Kostenkov-2021@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:34:35 +0500 Subject: [PATCH 18/18] Improve rich text scaling and Voice Control performance coverage Scale rich-message typography with the configured chat text size and invalidate cached layouts when it changes. Add stable Voice Control targets for Settings rows, opt-in accessibility performance tests for Settings traversal and chat typing, and update the accessibility contracts and release documentation. --- ACCESSIBILITY_CHANGELOG.md | 3 + .../Tests/Sources/AccessibilityUITests.swift | 72 +++++++++++++++++++ .../test_voiceover_contracts.py | 46 ++++++++++++ docs/VOICEOVER_RELEASE_GATE.md | 4 ++ ...ChatMessageRichDataBubbleContentNode.swift | 26 ++++--- .../ListItems/PeerInfoScreenActionItem.swift | 2 + .../PeerInfoScreenDisclosureItem.swift | 2 + 7 files changed, 146 insertions(+), 9 deletions(-) diff --git a/ACCESSIBILITY_CHANGELOG.md b/ACCESSIBILITY_CHANGELOG.md index 2c03575dfd2..9f0a2eb63f2 100644 --- a/ACCESSIBILITY_CHANGELOG.md +++ b/ACCESSIBILITY_CHANGELOG.md @@ -16,3 +16,6 @@ - Added an initial accessibility-tree size budget, XCTest traversal time/memory metrics, and retained `.xcresult` performance evidence. - Added opt-in populated-chat UI contracts for message names, stable identifiers, frames, and the input field hit target using `VOICEOVER_USE_EXISTING_DATA=1`. - Expanded source contracts to cover media/reply/delivery/play states, history scrolling, selection limits, Share modes, Gifts actions, Dynamic Type, Reduce Motion, and Reduce Transparency. +- Scaled Rich Message Instant Page typography from the configured chat text size and included that size in the layout cache key. +- Added stable Voice Control targets for interactive Settings disclosure/action rows. +- Added opt-in Settings tree/memory and chat typing accessibility performance scenarios with retained measurements and blocking budgets. diff --git a/Telegram/Tests/Sources/AccessibilityUITests.swift b/Telegram/Tests/Sources/AccessibilityUITests.swift index b76cc091afc..d89d8a9b01b 100644 --- a/Telegram/Tests/Sources/AccessibilityUITests.swift +++ b/Telegram/Tests/Sources/AccessibilityUITests.swift @@ -6,6 +6,9 @@ final class AccessibilityUITests: XCTestCase { private static let traversalSampleCount = 10 private static let averageTraversalBudget: TimeInterval = 2.0 private static let maximumTraversalBudget: TimeInterval = 5.0 + private static let settingsTreeElementBudget = 250 + private static let typingUpdateAverageBudget: TimeInterval = 3.0 + private static let typingUpdateMaximumBudget: TimeInterval = 6.0 private var app: XCUIApplication! @@ -127,4 +130,73 @@ final class AccessibilityUITests: XCTestCase { input.tap() XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 2.0)) } + + func testSettingsVoiceControlContractAndPerformanceWhenFixtureIsAvailable() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let settingsItems = app.descendants(matching: .any).matching( + NSPredicate( + format: "identifier BEGINSWITH %@ OR identifier BEGINSWITH %@", + "peerInfo.disclosure.", + "peerInfo.action." + ) + ) + guard settingsItems.count >= 5 else { + throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and leave the main Settings tab visible") + } + + var identifiers = Set() + for index in 0 ..< settingsItems.count { + let item = settingsItems.element(boundBy: index) + XCTAssertFalse(item.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + XCTAssertTrue(identifiers.insert(item.identifier).inserted) + XCTAssertGreaterThan(item.frame.width, 0.0) + XCTAssertGreaterThan(item.frame.height, 0.0) + } + + let elementCount = app.descendants(matching: .any).count + XCTAssertLessThanOrEqual(elementCount, Self.settingsTreeElementBudget) + let attachment = XCTAttachment(string: "Settings accessibility tree elements: \(elementCount)") + attachment.name = "Settings accessibility performance" + attachment.lifetime = .keepAlways + add(attachment) + + measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) { + _ = app.descendants(matching: .any).count + } + } + + func testChatTypingAccessibilityPerformanceWhenFixtureIsAvailable() throws { + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10.0)) + + let input = app.descendants(matching: .any)["chat.input"] + guard input.waitForExistence(timeout: 2.0) else { + throw XCTSkip("Run with VOICEOVER_USE_EXISTING_DATA=1 and open a writable chat before launching the test") + } + input.tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 2.0)) + + var samples: [TimeInterval] = [] + for _ in 0 ..< Self.traversalSampleCount { + let startTime = CFAbsoluteTimeGetCurrent() + input.typeText("a") + _ = app.descendants(matching: .any).count + input.typeText(XCUIKeyboardKey.delete.rawValue) + samples.append(CFAbsoluteTimeGetCurrent() - startTime) + } + + let average = samples.reduce(0.0, +) / Double(samples.count) + let maximum = samples.max() ?? 0.0 + let attachment = XCTAttachment( + string: "Typing accessibility samples: \(samples)\nAverage: \(average)\nMaximum: \(maximum)" + ) + attachment.name = "Voice Control typing performance" + attachment.lifetime = .keepAlways + add(attachment) + + XCTAssertLessThanOrEqual(average, Self.typingUpdateAverageBudget) + XCTAssertLessThanOrEqual(maximum, Self.typingUpdateMaximumBudget) + } } diff --git a/Tests/VoiceOverContracts/test_voiceover_contracts.py b/Tests/VoiceOverContracts/test_voiceover_contracts.py index 28aef431a35..d0038c974ed 100644 --- a/Tests/VoiceOverContracts/test_voiceover_contracts.py +++ b/Tests/VoiceOverContracts/test_voiceover_contracts.py @@ -322,6 +322,48 @@ def test_collection_tabs_expose_selection_and_reorder_actions_without_duplicates class AccessibilityPreferencesContractTests(unittest.TestCase): + def test_rich_messages_scale_with_chat_text_size_and_invalidate_layout_cache(self) -> None: + contents = source( + "submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift" + ) + for contract in ( + "baseFontSize: CGFloat", + "item.presentationData.fontSize.baseDisplaySize", + "let fontScale = baseFontSize / 17.0", + "scaledFontSize(17.0)", + "current.baseFontSize == baseFontSize", + ): + with self.subTest(contract=contract): + self.assertIn(contract, contents) + for fixed_size in ( + "size: 19.0", + "size: 18.0", + "size: 17.0", + "size: 15.0", + "size: 14.0", + "size: 13.0", + ): + with self.subTest(fixed_size=fixed_size): + self.assertNotIn(fixed_size, contents) + + def test_settings_rows_have_voice_control_names_and_stable_targets(self) -> None: + owners = ( + ( + "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift", + '"peerInfo.disclosure.', + ), + ( + "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift", + '"peerInfo.action.', + ), + ) + for owner, identifier in owners: + with self.subTest(owner=owner): + contents = source(owner) + self.assertIn("activateArea.accessibilityLabel = item.text", contents) + self.assertIn("activateArea.accessibilityRespondsToUserInteraction = item.action != nil", contents) + self.assertIn(identifier, contents) + def test_common_modals_support_dynamic_type_and_reduce_transparency(self) -> None: archive = source( "submodules/TelegramUI/Components/Settings/ArchiveInfoScreen/Sources/ArchiveInfoScreen.swift" @@ -367,6 +409,10 @@ def test_ui_suite_keeps_tree_audit_and_performance_budget(self) -> None: "testPopulatedChatMessageContractWhenFixtureIsAvailable", "testChatInputHitTargetWhenFixtureIsAvailable", "VOICEOVER_USE_EXISTING_DATA", + "testSettingsVoiceControlContractAndPerformanceWhenFixtureIsAvailable", + "testChatTypingAccessibilityPerformanceWhenFixtureIsAvailable", + "settingsTreeElementBudget", + "typingUpdateAverageBudget", ): with self.subTest(contract=contract): self.assertIn(contract, contents) diff --git a/docs/VOICEOVER_RELEASE_GATE.md b/docs/VOICEOVER_RELEASE_GATE.md index 21068b9600d..5970a4c3662 100644 --- a/docs/VOICEOVER_RELEASE_GATE.md +++ b/docs/VOICEOVER_RELEASE_GATE.md @@ -20,6 +20,8 @@ Generate the Xcode project using the repository build instructions and run `Acce The default suite launches with `--ui-test` and therefore uses a clean signed-out data directory. To exercise the populated-chat assertions, prepare a dedicated simulator account, leave the required chat open, and run the suite with `VOICEOVER_USE_EXISTING_DATA=1` in the test scheme environment. In this mode the tests retain simulator data and additionally verify message names, unique stable identifiers, non-empty frames, and the input field's expanded hit target and keyboard focus. These tests report `XCTSkip`, rather than a false pass, when the required fixture is absent. +Use the same opt-in mode with the main Settings tab visible to verify meaningful Voice Control names, unique row targets, a 250-element Settings tree budget, and clock/memory traversal metrics. Run it again in a writable chat for the typing scenario: ten type/query/delete samples enforce conservative average and maximum budgets of 3 and 6 seconds and retain the raw samples as an XCTest attachment. Because UI automation time is included, accept tighter device-specific budgets only after collecting a stable `.xcresult` baseline. + The repository command used by the manually dispatched GitHub gate is: ```sh @@ -54,6 +56,8 @@ Record the device, iOS version, app commit, locale, and result for each scenario - Peer Info, Gifts, members, contacts, and search transaction focus; - context, peek, pinch, alert, and action-sheet containment and Escape; - Dynamic Type accessibility sizes, Reduce Motion, and Voice Control names. +- Rich Messages at the minimum, default, maximum Telegram Text Size, and system accessibility text sizes; +- Settings Voice Control Show Names/Show Numbers and typing performance with Voice Control enabled. A release is blocked when a P0 chat flow fails, focus moves to an unrelated element, a modal leaks background traversal, or an interactive control has no meaningful name or action. diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index b16547937ef..15fc6e1eeac 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -50,6 +50,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // this, the cached layout would shadow newly-arrived content during streaming. private var currentPageLayout: (boundingWidth: CGFloat, presentationThemeIdentity: ObjectIdentifier, + baseFontSize: CGFloat, expandedDetails: [Int: Bool], messageStableVersion: UInt32, pendingEditKey: ObjectIdentifier?, @@ -442,16 +443,21 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode let _ = codeBlockTitleColor let _ = codeBlockAccentColor + let baseFontSize = item.presentationData.fontSize.baseDisplaySize + let fontScale = baseFontSize / 17.0 + let scaledFontSize: (CGFloat) -> CGFloat = { size in + return floor(size * fontScale) + } let textCategories = InstantPageTextCategories( - kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), - header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), - subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), - paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), - caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), - credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), - table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), - article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), - codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: scaledFontSize(15.0), lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: scaledFontSize(19.0), lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: scaledFontSize(18.0), lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: scaledFontSize(17.0), lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: scaledFontSize(15.0), lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: scaledFontSize(13.0), lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: scaledFontSize(15.0), lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: scaledFontSize(18.0), lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: scaledFontSize(14.0), lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), ) let pageTheme = InstantPageTheme( type: isDark ? .dark : .light, @@ -530,6 +536,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode if let current = currentPageLayout, current.boundingWidth == suggestedBoundingWidth, current.presentationThemeIdentity == presentationThemeIdentity, + current.baseFontSize == baseFontSize, current.expandedDetails == currentExpandedDetails, current.showMoreExpanded == showMoreExpanded, current.messageStableVersion == currentMessageStableVersion, @@ -944,6 +951,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode self.currentPageLayout = ( suggestedBoundingWidth, ObjectIdentifier(item.presentationData.theme.theme), + item.presentationData.fontSize.baseDisplaySize, self.currentExpandedDetails, item.message.stableVersion, (item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) }), diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift index 872e6ae9e6a..cf9353177ed 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenActionItem.swift @@ -100,6 +100,8 @@ private final class PeerInfoScreenActionItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityIdentifier = "peerInfo.action.\(String(describing: item.id)).\(item.text)" + self.activateArea.accessibilityRespondsToUserInteraction = item.action != nil if let action = item.action { self.activateArea.accessibilityTraits = [.button] self.activateArea.activate = { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift index 9f0f3ca89c4..8fad8fdcbc7 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenDisclosureItem.swift @@ -152,6 +152,8 @@ private final class PeerInfoScreenDisclosureItemNode: PeerInfoScreenItemNode { self.item = item self.selectionNode.pressed = item.action + self.activateArea.accessibilityIdentifier = "peerInfo.disclosure.\(String(describing: item.id)).\(item.text)" + self.activateArea.accessibilityRespondsToUserInteraction = item.action != nil if let action = item.action { self.activateArea.accessibilityTraits = [.button] self.activateArea.activate = {