From 12e79ed954d9a16cd5caf489358534d2aabaec46 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Tue, 14 Jul 2026 17:57:35 +0330 Subject: [PATCH] Keep the message list visible and focused while a chat loads Opening a chat whose history was not loaded yet hid the scroll area and parked keyboard focus on the unnamed HistoryWidget container until the first messages arrived, so screen readers announced it as a bare "grouping" for the whole load. Keep the scroll visible during the first load for everyone, like the newer sections do: the empty message list takes focus right away and the container-parking branch goes away entirely. With a screen reader the list announces the focused message once the first slice of messages arrives, the same as when opening an already loaded chat. --- .../history/history_inner_widget.cpp | 131 ++++++++++-------- .../history/history_inner_widget.h | 2 + .../SourceFiles/history/history_widget.cpp | 11 +- 3 files changed, 79 insertions(+), 65 deletions(-) diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 7ed79e9058..f444d21e58 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -840,6 +840,13 @@ void HistoryInner::messagesReceived( _migrated->addNewerSlice(QVector()); } } + if (_announceFirstMessages && hasFocus()) { + InvokeQueued(this, [=] { + if (_announceFirstMessages && hasFocus()) { + announceAccessibilityFocusedChild(); + } + }); + } } void HistoryInner::messagesReceivedDown( @@ -6619,67 +6626,79 @@ auto HistoryInner::computeActiveColumns(int row) const return _activeColumns; } +void HistoryInner::announceAccessibilityFocusedChild() { + const auto count = accessibilityChildCount(); + if (count <= 0) { + // One-shot for chats focused before their first page arrived: + // while the empty list holds focus, remember that the first + // received slice should announce the focused message. Fired + // (queued) from messagesReceived and disarmed by any real + // announcement below or in a later focus-in. + if (Ui::ScreenReaderModeActive()) { + _announceFirstMessages = true; + } + return; + } + _announceFirstMessages = false; + if (_accessibilityFocusedItem) { + const auto elements = accessibleElements(); + const auto barIndex = accessibilityUnreadBarIndex(); + auto found = -1; + for (auto i = 0, n = int(elements.size()); i < n; ++i) { + if (elements[i]->data().get() + == _accessibilityFocusedItem) { + found = (barIndex >= 0 && i >= barIndex) + ? (i + 1) + : i; + break; + } + } + if (found >= 0 && found < count) { + _accessibilityFocusedIndex = found; + announceAccessibilityFocus(found); + return; + } + // The cached focused item is no longer in the list (it + // was removed or fell out of the loaded slice since we + // last had focus). Invalidate the index together with the + // item: announcing whatever row occupies the old index + // would leave later actions bound to a row the user never + // heard about once the list shifts again. The auto-select + // branch below establishes a fresh focus instead. + _accessibilityFocusedItem = nullptr; + _accessibilityFocusedIndex = -1; + } else if (_accessibilityFocusedIndex >= 0) { + // A nonnegative index with no cached item means the unread + // bar was focused. Follow the bar to wherever it sits now, + // or fall through to pick a fresh focus target when it is + // gone: the row that occupies the old index was never + // announced to the user. + _accessibilityFocusedIndex = accessibilityUnreadBarIndex(); + } + if (_accessibilityFocusedIndex >= 0 + && _accessibilityFocusedIndex < count) { + announceAccessibilityFocus(_accessibilityFocusedIndex); + return; + } + const auto barIndex = accessibilityUnreadBarIndex(); + const auto index = (barIndex >= 0 && barIndex + 1 < count) + ? (barIndex + 1) + : (count - 1); + const auto elements = accessibleElements(); + const auto item = accessibilityItemAtIndex( + index, + elements, + barIndex); + setAccessibilityFocusedItem(index, item); +} + void HistoryInner::focusInEvent(QFocusEvent *e) { RpWidget::focusInEvent(e); InvokeQueued(this, [=] { - if (!hasFocus()) { - return; + if (hasFocus()) { + announceAccessibilityFocusedChild(); } - const auto count = accessibilityChildCount(); - if (count <= 0) { - return; - } - if (_accessibilityFocusedItem) { - const auto elements = accessibleElements(); - const auto barIndex = accessibilityUnreadBarIndex(); - auto found = -1; - for (auto i = 0, n = int(elements.size()); i < n; ++i) { - if (elements[i]->data().get() - == _accessibilityFocusedItem) { - found = (barIndex >= 0 && i >= barIndex) - ? (i + 1) - : i; - break; - } - } - if (found >= 0 && found < count) { - _accessibilityFocusedIndex = found; - announceAccessibilityFocus(found); - return; - } - // The cached focused item is no longer in the list (it - // was removed or fell out of the loaded slice since we - // last had focus). Invalidate the index together with the - // item: announcing whatever row occupies the old index - // would leave later actions bound to a row the user never - // heard about once the list shifts again. The auto-select - // branch below establishes a fresh focus instead. - _accessibilityFocusedItem = nullptr; - _accessibilityFocusedIndex = -1; - } else if (_accessibilityFocusedIndex >= 0) { - // A nonnegative index with no cached item means the unread - // bar was focused. Follow the bar to wherever it sits now, - // or fall through to pick a fresh focus target when it is - // gone: the row that occupies the old index was never - // announced to the user. - _accessibilityFocusedIndex = accessibilityUnreadBarIndex(); - } - if (_accessibilityFocusedIndex >= 0 - && _accessibilityFocusedIndex < count) { - announceAccessibilityFocus(_accessibilityFocusedIndex); - return; - } - const auto barIndex = accessibilityUnreadBarIndex(); - const auto index = (barIndex >= 0 && barIndex + 1 < count) - ? (barIndex + 1) - : (count - 1); - const auto elements = accessibleElements(); - const auto item = accessibilityItemAtIndex( - index, - elements, - barIndex); - setAccessibilityFocusedItem(index, item); }); } diff --git a/Telegram/SourceFiles/history/history_inner_widget.h b/Telegram/SourceFiles/history/history_inner_widget.h index 4befb952e9..b500735ade 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.h +++ b/Telegram/SourceFiles/history/history_inner_widget.h @@ -323,6 +323,7 @@ private: void playPauseFocusedMedia(); void setAccessibilityFocusedItem(int index, HistoryItem *item); void announceAccessibilityFocus(int index); + void announceAccessibilityFocusedChild(); void applyAccessibilityFocus(int index, bool announceAlways); [[nodiscard]] auto computeActiveColumns(int row) const -> const std::vector &; @@ -571,6 +572,7 @@ private: int _accessibilityFocusedIndex = -1; HistoryItem *_accessibilityFocusedItem = nullptr; HistoryItem *_accessibilitySelectionAnchor = nullptr; + bool _announceFirstMessages = false; mutable base::flat_map< not_null, quintptr> _accessibilityIdentities; diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index 63ac548e05..10fda20956 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -3175,7 +3175,6 @@ void HistoryWidget::showHistory( } } - _scroll->hide(); _list = _scroll->setOwnedWidget( object_ptr(this, _scroll, controller(), _history)); _pullToNext->attachToContent(_list); @@ -3931,13 +3930,7 @@ void HistoryWidget::updateControlsVisibility() { return; } - if (_firstLoadRequest && !_scroll->isHidden()) { - if (Ui::InFocusChain(_scroll.data())) { - // Don't loose focus back to chats list. - setFocus(); - } - _scroll->hide(); - } else if (!_firstLoadRequest && _scroll->isHidden()) { + if (_scroll->isHidden()) { _scroll->show(); } _topBars->show(); @@ -5010,7 +5003,7 @@ bool HistoryWidget::isItemCompletelyHidden(HistoryItem *item) const { } void HistoryWidget::visibleAreaUpdated() { - if (_list && !_scroll->isHidden()) { + if (_list && !_firstLoadRequest && !_scroll->isHidden()) { const auto scrollTop = _scroll->scrollTop(); const auto scrollBottom = scrollTop + _scroll->height(); _list->visibleAreaUpdated(scrollTop, scrollBottom);