From 3031bde52719c164a7890a1a5f6fb70032eb07ee Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Mon, 3 Aug 2026 03:50:06 +0330 Subject: [PATCH 1/6] Add accessible names and locked folder handling to chat filters tabs --- .../ui/widgets/chat_filters_tabs_slider.cpp | 39 +++++++++++++++++++ .../ui/widgets/chat_filters_tabs_slider.h | 4 ++ .../ui/widgets/chat_filters_tabs_strip.cpp | 1 + 3 files changed, 44 insertions(+) diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp index b0d70d77f762da..5249f5a78d38b6 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp @@ -7,6 +7,7 @@ For license and copyright information please follow this link: */ #include "ui/widgets/chat_filters_tabs_slider.h" +#include "lang/lang_keys.h" #include "ui/effects/ripple_animation.h" #include "ui/widgets/side_bar_button.h" #include "styles/style_dialogs.h" @@ -169,15 +170,18 @@ void ChatsFiltersTabs::setUnreadCount(int index, int unreadCount, bool mute) { .muted = mute, }); update(); + accessibilityChildNameChanged(index); } } else if (!unreadCount) { _unreadCounts.erase(it); update(); + accessibilityChildNameChanged(index); } else if (it->second.count != unreadCount || it->second.muted != mute) { it->second.count = unreadCount; it->second.muted = mute; it->second.cache = cacheUnreadCount(unreadCount, mute); update(); + accessibilityChildNameChanged(index); } updateSectionsContentWidths(); } @@ -438,6 +442,41 @@ void ChatsFiltersTabs::contextMenuEvent(QContextMenuEvent *e) { _contextMenuRequested.fire_copy(index); } +QString ChatsFiltersTabs::accessibilityChildName(int index) const { + const auto title = Ui::SettingsSlider::accessibilityChildName(index); + if (_lockedFrom > 0 && index >= _lockedFrom) { + // Surface a locked folder's premium-gated status, which the visual + // lock glyph alone can't convey to a screen reader. + return tr::lng_sr_folder_locked(tr::now, lt_text, title); + } + const auto it = _unreadCounts.find(index); + return (it != _unreadCounts.end()) + ? tr::lng_filter_unread_chats( + tr::now, + lt_count, + it->second.count, + lt_text, + title) + : title; +} + +QString ChatsFiltersTabs::accessibilityChildDescription(int index) const { + return (_lockedFrom > 0 && index >= _lockedFrom) + ? tr::lng_sr_folder_locked_about(tr::now) + : QString(); +} + +void ChatsFiltersTabs::activateSectionByAccessibility(int index) { + // Keyboard or screen reader activation of a locked (premium) folder + // behaves like a click on it: show the premium promo instead of + // switching to a folder the user can't use. + if (_lockedFrom > 0 && index >= _lockedFrom) { + _lockedClicked.fire({}); + } else { + Ui::SettingsSlider::activateSectionByAccessibility(index); + } +} + rpl::producer ChatsFiltersTabs::contextMenuRequested() const { return _contextMenuRequested.events(); } diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h index fc55a47336c547..d0f3a3a9b6a09a 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h @@ -58,6 +58,9 @@ class ChatsFiltersTabs final : public Ui::SettingsSlider { void stopAnimation(); + QString accessibilityChildName(int index) const override; + QString accessibilityChildDescription(int index) const override; + protected: struct ShiftedSection { not_null section; @@ -71,6 +74,7 @@ class ChatsFiltersTabs final : public Ui::SettingsSlider { void mouseMoveEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; void contextMenuEvent(QContextMenuEvent *e) override; + void activateSectionByAccessibility(int index) override; std::vector _sections; diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp index 9505861b5db6fc..38d2d02a64bd0b 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp @@ -230,6 +230,7 @@ not_null AddChatFiltersTabsStrip( trackActiveFilterAndUnreadAndReorder ? st::dialogsSearchTabs : st::chatsFiltersTabs)); + slider->setAccessibleName(tr::lng_filters_title(tr::now)); const auto state = wrap->lifetime().make_state(); const auto reassignUnreadValue = [=] { state->reorderLifetime.destroy(); From b4849d128a76b42c6c9b1a213e3a60e80f60b5c9 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Mon, 3 Aug 2026 05:20:03 +0330 Subject: [PATCH 2/6] Scroll folder tabs into view while browsing with a screen reader --- .../SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp index 38d2d02a64bd0b..d2e811cf9c200f 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp @@ -365,6 +365,14 @@ not_null AddChatFiltersTabsStrip( } }; + slider->accessibilitySectionBrowsed( + ) | rpl::on_next([=](int index) { + // Browsing with a screen reader moves between sections without + // activating them, so nothing else scrolls the strip; keep the + // browsed section visible. + scrollToIndex(index, anim::type::normal); + }, slider->lifetime()); + const auto applyFilter = [=](const Data::ChatFilter &filter) { if (slider->reordering()) { return; From 1120827c6e929f4331a3ca23030f5509c64fb644 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Sat, 22 Aug 2026 04:17:08 +0330 Subject: [PATCH 3/6] Order the chat list column visually in the Tab chain --- Telegram/SourceFiles/dialogs/dialogs_widget.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Telegram/SourceFiles/dialogs/dialogs_widget.cpp b/Telegram/SourceFiles/dialogs/dialogs_widget.cpp index d5e55de04cc41f..c925985bd9b95e 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_widget.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_widget.cpp @@ -730,6 +730,11 @@ Widget::Widget( _search->customUpDown(true); + // The bars above the list and the folder tabs are created only once they + // are needed, long after the list and the buttons below it, so the Tab + // chain has to follow the column instead of the creation order. + setVisualTabOrder(true); + updateJumpToDateVisibility(true); updateSearchFromVisibility(true); setupSupportMode(); From 15d8a782c44145a81d65cb3d91b0058d65eda022 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Sat, 22 Aug 2026 07:22:15 +0330 Subject: [PATCH 4/6] Open the folder tab menu from the keyboard on the browsed tab --- .../ui/widgets/chat_filters_tabs_slider.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp index 5249f5a78d38b6..15cb5f70523167 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp @@ -424,6 +424,18 @@ void ChatsFiltersTabs::mouseReleaseEvent(QMouseEvent *e) { } void ChatsFiltersTabs::contextMenuEvent(QContextMenuEvent *e) { + if (e->reason() == QContextMenuEvent::Keyboard) { + // A menu asked for from the keyboard comes with the center of an + // empty input method rect instead of a position over a tab, so the + // lookup below would land outside of them all - take the tab the + // browse position is on, or the active one when it is on none. + const auto browsed = accessibilityBrowsedSection(); + const auto index = (browsed >= 0) ? browsed : activeSection(); + if (!_lockedFrom || index < _lockedFrom) { + _contextMenuRequested.fire_copy(index); + } + return; + } const auto pos = e->pos(); if (pos.x() >= _lockedFromX) { return; From cb4b2fa70d2bfaf0383815ecf987cbe563b675fa Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Thu, 27 Aug 2026 05:22:37 +0330 Subject: [PATCH 5/6] Open the folder tab menu from the keyboard on the tab itself The context menu key already picked the browsed tab, but the menu still popped at the mouse cursor, which may sit nowhere near the tab or outside the window altogether. The slider now sends the position to show the menu at along with the tab index - the tab itself for a keyboard-invoked menu, the cursor as before for a mouse-invoked one. --- .../ui/widgets/chat_filters_tabs_slider.cpp | 17 ++++++++++++++--- .../ui/widgets/chat_filters_tabs_slider.h | 10 ++++++++-- .../ui/widgets/chat_filters_tabs_strip.cpp | 19 ++++++++++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp index 15cb5f70523167..7912bb5099fe77 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.cpp @@ -9,6 +9,7 @@ For license and copyright information please follow this link: #include "lang/lang_keys.h" #include "ui/effects/ripple_animation.h" +#include "ui/widgets/popup_menu.h" #include "ui/widgets/side_bar_button.h" #include "styles/style_dialogs.h" #include "styles/style_widgets.h" @@ -432,7 +433,13 @@ void ChatsFiltersTabs::contextMenuEvent(QContextMenuEvent *e) { const auto browsed = accessibilityBrowsedSection(); const auto index = (browsed >= 0) ? browsed : activeSection(); if (!_lockedFrom || index < _lockedFrom) { - _contextMenuRequested.fire_copy(index); + _contextMenuRequested.fire({ + .index = index, + .position = ContextMenuPosition( + this, + e, + accessibilityChildRect(index)), + }); } return; } @@ -451,7 +458,10 @@ void ChatsFiltersTabs::contextMenuEvent(QContextMenuEvent *e) { index++; return true; }); - _contextMenuRequested.fire_copy(index); + _contextMenuRequested.fire({ + .index = index, + .position = QCursor::pos(), + }); } QString ChatsFiltersTabs::accessibilityChildName(int index) const { @@ -489,7 +499,8 @@ void ChatsFiltersTabs::activateSectionByAccessibility(int index) { } } -rpl::producer ChatsFiltersTabs::contextMenuRequested() const { +auto ChatsFiltersTabs::contextMenuRequested() const +-> rpl::producer { return _contextMenuRequested.events(); } diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h index d0f3a3a9b6a09a..0452189e1bd0aa 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_slider.h @@ -25,6 +25,11 @@ class SettingsSlider; class ChatsFiltersTabsReorder; +struct ChatsFiltersTabMenuRequest { + int index = 0; + QPoint position; // Global coordinates for showing the menu. +}; + class ChatsFiltersTabs final : public Ui::SettingsSlider { public: ChatsFiltersTabs( @@ -45,7 +50,8 @@ class ChatsFiltersTabs final : public Ui::SettingsSlider { return _lockedFrom; } - [[nodiscard]] rpl::producer contextMenuRequested() const; + [[nodiscard]] auto contextMenuRequested() const + -> rpl::producer; [[nodiscard]] rpl::producer<> lockedClicked() const; void setHorizontalShift(int index, int shift); @@ -114,7 +120,7 @@ class ChatsFiltersTabs final : public Ui::SettingsSlider { int _reordering = 0; rpl::lifetime _paletteLifetime; - rpl::event_stream _contextMenuRequested; + rpl::event_stream _contextMenuRequested; rpl::event_stream<> _lockedClicked; }; diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp index d2e811cf9c200f..c7654df69f79f4 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp @@ -62,7 +62,8 @@ void ShowMenu( not_null parent, not_null controller, not_null state, - int index) { + int index, + QPoint position) { const auto session = &controller->session(); auto id = FilterId(0); @@ -133,7 +134,7 @@ void ShowMenu( state->menu = nullptr; return; } - state->menu->popup(QCursor::pos()); + state->menu->popup(position); } void ShowFiltersListMenu( @@ -141,6 +142,7 @@ void ShowFiltersListMenu( not_null session, not_null state, int active, + QPoint position, Fn changeActive) { const auto &list = session->data().chatsFilters().list(); @@ -195,7 +197,7 @@ void ShowFiltersListMenu( state->menu = nullptr; return; } - state->menu->popup(QCursor::pos()); + state->menu->popup(position); } } // namespace @@ -501,15 +503,22 @@ not_null AddChatFiltersTabsStrip( } applyFilter(filter); }, state->rebuildLifetime); - slider->contextMenuRequested() | rpl::on_next([=](int index) { + slider->contextMenuRequested() | rpl::on_next([=]( + Ui::ChatsFiltersTabMenuRequest request) { if (trackActiveFilterAndUnreadAndReorder) { - ShowMenu(wrap, controller, state, index); + ShowMenu( + wrap, + controller, + state, + request.index, + request.position); } else { ShowFiltersListMenu( wrap, session, state, slider->activeSection(), + request.position, [=](int i) { slider->setActiveSection(i); }); } }, state->rebuildLifetime); From f4bd0dbb9d366b0aa32fbc3dc15f754869cbf499 Mon Sep 17 00:00:00 2001 From: Reza Bakhshi Laktasaraei Date: Thu, 27 Aug 2026 16:43:43 +0330 Subject: [PATCH 6/6] Keep the folder tabs on manual activation --- Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp index c7654df69f79f4..1dc8cc6448e3a0 100644 --- a/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp +++ b/Telegram/SourceFiles/ui/widgets/chat_filters_tabs_strip.cpp @@ -233,6 +233,10 @@ not_null AddChatFiltersTabsStrip( ? st::dialogsSearchTabs : st::chatsFiltersTabs)); slider->setAccessibleName(tr::lng_filters_title(tr::now)); + // Switching a folder reloads the whole chat list, and a locked premium + // folder opens an upsell on activation - so the arrows only browse the + // folder tabs, committing on Enter / Space. + slider->setAccessibilityActivateOnBrowse(false); const auto state = wrap->lifetime().make_state(); const auto reassignUnreadValue = [=] { state->reorderLifetime.destroy();