From 9366da51b259adfd43529dcf633ce6e2b1f80a57 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 15:00:57 -0800 Subject: [PATCH 01/12] inbox [nfc]: Refactor condition for including _AllDmsSectionData --- lib/widgets/inbox.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 60c3eb00aa..f429700342 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -121,7 +121,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat dmItems.add((dmNarrow, countInNarrow, hasMention)); allDmsCount += countInNarrow; } - if (allDmsCount > 0) { + if (dmItems.isNotEmpty) { sections.add(_AllDmsSectionData(allDmsCount, allDmsHasMention, dmItems)); } From 16da46ddd1ad7d085f39d96b226de9cfb7af2081 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 15:10:18 -0800 Subject: [PATCH 02/12] inbox [nfc]: Group a channel-level metadata line with another one; shorten it --- lib/widgets/inbox.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index f429700342..7565f3b074 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -156,7 +156,6 @@ class _InboxPageState extends State with PerAccountStoreAwareStat if (!store.isTopicVisible(streamId, topic)) continue; final countInTopic = messageIds.length; final hasMention = messageIds.any((messageId) => unreadsModel!.mentions.contains(messageId)); - if (hasMention) streamHasMention = true; topicItems.add(InboxChannelSectionTopicData( topic: topic, count: countInTopic, @@ -164,6 +163,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat lastUnreadId: messageIds.last, )); countInStream += countInTopic; + streamHasMention |= hasMention; } if (countInStream == 0) { continue; From 40bd34bd9f0a7e7d661d5f1c5862399b74a68e93 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 15:41:20 -0800 Subject: [PATCH 03/12] inbox: Use ChannelStore.compareChannelsByName for channel sorting This is equivalent except that it puts channels that start with an emoji first; see implementation comment in the method. Test written with help from Claude. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/inbox.dart | 4 ++-- test/widgets/inbox_test.dart | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 7565f3b074..337e246a8f 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/channel.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; @@ -144,8 +145,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat return subA.pinToTop ? -1 : 1; } - // TODO(i18n) something like JS's String.prototype.localeCompare - return subA.name.toLowerCase().compareTo(subB.name.toLowerCase()); + return ChannelStore.compareChannelsByName(subA, subB); }); for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 2f09036373..e45cb7c826 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -290,11 +290,45 @@ void main() { check(find.textContaining('There are no unread messages in your inbox.')).findsOne(); }); - // TODO more checks: ordering, etc. testWidgets('page builds; not empty', (tester) async { await setupVarious(tester); }); + group('channel sorting', () { + testWidgets('channels with names starting with an emoji sort before others', (tester) async { + final channelBeta = eg.stream(name: 'Beta Stream'); + final channelRocket = eg.stream(name: '🚀 Rocket Stream'); + final channelAlpha = eg.stream(name: 'Alpha Stream'); + await setupPage(tester, + users: [eg.selfUser, eg.otherUser], + streams: [channelBeta, channelRocket, channelAlpha], + subscriptions: [ + eg.subscription(channelBeta), + eg.subscription(channelRocket), + eg.subscription(channelAlpha), + ], + unreadMessages: [ + // Add an unread DM to shift the channel headers downward, + // preventing a channel header being duplicated in the widget tree + // as a sticky header. + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + + eg.streamMessage(stream: channelBeta), + eg.streamMessage(stream: channelRocket), + eg.streamMessage(stream: channelAlpha), + ]); + + final listedChannelIds = + tester.widgetList(find.byType(InboxChannelHeaderItem)) + .map((item) => item.subscription.streamId).toList(); + check(listedChannelIds).deepEquals([ + channelRocket.streamId, + channelAlpha.streamId, + channelBeta.streamId, + ]); + }); + }); + testWidgets('UnreadCountBadge text color for a channel', (tester) async { // Regression test for a bug where // DesignVariables.labelCounterUnread was used for the text instead of From 9c88765a46f77238a23388e7ad8d8e4b260b815d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 15:08:39 -0800 Subject: [PATCH 04/12] inbox [nfc]: Build list of channel-section data first, then sort after --- lib/widgets/inbox.dart | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 337e246a8f..8725fc1264 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -126,7 +126,9 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_AllDmsSectionData(allDmsCount, allDmsHasMention, dmItems)); } - final sortedUnreadStreams = unreadsModel!.streams.entries + final channelSections = <_StreamSectionData>[]; + for (final MapEntry(key: streamId, value: topics) in unreadsModel!.streams.entries) { + final sub = subscriptions[streamId]; // Filter out any straggling unreads in unsubscribed streams. // There won't normally be any, but it happens with certain infrequent // state changes, typically for less than a few hundred milliseconds. @@ -134,21 +136,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat // // Also, we want to depend on the subscription data for things like // choosing the stream icon. - .where((entry) => subscriptions.containsKey(entry.key)) - .toList() - ..sort((a, b) { - final subA = subscriptions[a.key]!; - final subB = subscriptions[b.key]!; - - // TODO "pin" icon on the stream row? dividers in the list? - if (subA.pinToTop != subB.pinToTop) { - return subA.pinToTop ? -1 : 1; - } - - return ChannelStore.compareChannelsByName(subA, subB); - }); + if (sub == null) continue; - for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { final topicItems = []; int countInStream = 0; bool streamHasMention = false; @@ -173,9 +162,23 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); - sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); + channelSections.add( + _StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); } + channelSections.sort((a, b) { + final subA = subscriptions[a.streamId]!; + final subB = subscriptions[b.streamId]!; + + // TODO "pin" icon on the stream row? dividers in the list? + if (subA.pinToTop != subB.pinToTop) { + return subA.pinToTop ? -1 : 1; + } + + return ChannelStore.compareChannelsByName(subA, subB); + }); + sections.addAll(channelSections); + if (sections.isEmpty) { return PageBodyEmptyContentPlaceholder( // TODO(#315) add e.g. "You might be interested in recent conversations." From bdde511ca88c5704893343e9e3fadc427b01f744 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 16:07:18 -0800 Subject: [PATCH 05/12] inbox [nfc]: Refactor how we put pinned before non-pinned channels --- lib/widgets/inbox.dart | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 8725fc1264..e168f3c0d0 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -126,7 +126,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat sections.add(_AllDmsSectionData(allDmsCount, allDmsHasMention, dmItems)); } - final channelSections = <_StreamSectionData>[]; + final pinnedChannelSections = <_StreamSectionData>[]; + final otherChannelSections = <_StreamSectionData>[]; for (final MapEntry(key: streamId, value: topics) in unreadsModel!.streams.entries) { final sub = subscriptions[streamId]; // Filter out any straggling unreads in unsubscribed streams. @@ -162,22 +163,36 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); - channelSections.add( - _StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); + final section = _StreamSectionData( + streamId, countInStream, streamHasMention, topicItems); + if (sub.pinToTop) { + pinnedChannelSections.add(section); + } else { + otherChannelSections.add(section); + } } - channelSections.sort((a, b) { - final subA = subscriptions[a.streamId]!; - final subB = subscriptions[b.streamId]!; + // TODO add PINNED and OTHER folder headers; + // deduplicate sorting code within PINNED and OTHER + if (pinnedChannelSections.isNotEmpty) { + pinnedChannelSections.sort((a, b) { + final subA = subscriptions[a.streamId]!; + final subB = subscriptions[b.streamId]!; - // TODO "pin" icon on the stream row? dividers in the list? - if (subA.pinToTop != subB.pinToTop) { - return subA.pinToTop ? -1 : 1; - } + return ChannelStore.compareChannelsByName(subA, subB); + }); + sections.addAll(pinnedChannelSections); + } - return ChannelStore.compareChannelsByName(subA, subB); - }); - sections.addAll(channelSections); + if (otherChannelSections.isNotEmpty) { + otherChannelSections.sort((a, b) { + final subA = subscriptions[a.streamId]!; + final subB = subscriptions[b.streamId]!; + + return ChannelStore.compareChannelsByName(subA, subB); + }); + sections.addAll(otherChannelSections); + } if (sections.isEmpty) { return PageBodyEmptyContentPlaceholder( From d2bf8ca089c221e2d05d0a6e3fc22e932a6438a1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Feb 2026 16:04:12 -0800 Subject: [PATCH 06/12] inbox [nfc]: Call list items "items", not "sections" This more generic semantics can accommodate list items that aren't whole sections, such as folder headers (coming soon) and eventually topic items, for https://github.com/zulip/zulip-flutter/issues/389 . --- lib/widgets/inbox.dart | 46 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index e168f3c0d0..4aeaa7b9e4 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -103,7 +103,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final subscriptions = store.subscriptions; // TODO(#1065) make an incrementally-updated view-model for InboxPage - final sections = <_InboxSectionData>[]; + final items = <_InboxListItem>[]; // TODO efficiently include DM conversations that aren't recent enough // to appear in recentDmConversationsView, but still have unreads in @@ -123,11 +123,11 @@ class _InboxPageState extends State with PerAccountStoreAwareStat allDmsCount += countInNarrow; } if (dmItems.isNotEmpty) { - sections.add(_AllDmsSectionData(allDmsCount, allDmsHasMention, dmItems)); + items.add(_InboxListItemAllDmsSection(allDmsCount, allDmsHasMention, dmItems)); } - final pinnedChannelSections = <_StreamSectionData>[]; - final otherChannelSections = <_StreamSectionData>[]; + final pinnedChannelSections = <_InboxListItemChannelSection>[]; + final otherChannelSections = <_InboxListItemChannelSection>[]; for (final MapEntry(key: streamId, value: topics) in unreadsModel!.streams.entries) { final sub = subscriptions[streamId]; // Filter out any straggling unreads in unsubscribed streams. @@ -163,7 +163,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); - final section = _StreamSectionData( + final section = _InboxListItemChannelSection( streamId, countInStream, streamHasMention, topicItems); if (sub.pinToTop) { pinnedChannelSections.add(section); @@ -181,7 +181,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat return ChannelStore.compareChannelsByName(subA, subB); }); - sections.addAll(pinnedChannelSections); + items.addAll(pinnedChannelSections); } if (otherChannelSections.isNotEmpty) { @@ -191,10 +191,10 @@ class _InboxPageState extends State with PerAccountStoreAwareStat return ChannelStore.compareChannelsByName(subA, subB); }); - sections.addAll(otherChannelSections); + items.addAll(otherChannelSections); } - if (sections.isEmpty) { + if (items.isEmpty) { return PageBodyEmptyContentPlaceholder( // TODO(#315) add e.g. "You might be interested in recent conversations." header: zulipLocalizations.inboxEmptyPlaceholderHeader, @@ -203,43 +203,43 @@ class _InboxPageState extends State with PerAccountStoreAwareStat return SafeArea( // horizontal insets child: StickyHeaderListView.builder( - itemCount: sections.length, + itemCount: items.length, itemBuilder: (context, index) { - final section = sections[index]; - switch (section) { - case _AllDmsSectionData(): + final item = items[index]; + switch (item) { + case _InboxListItemAllDmsSection(): return _AllDmsSection( - data: section, + data: item, collapsed: allDmsCollapsed, pageState: this, ); - case _StreamSectionData(:var streamId): + case _InboxListItemChannelSection(:var streamId): final collapsed = collapsedStreamIds.contains(streamId); - return _StreamSection(data: section, collapsed: collapsed, pageState: this); + return _StreamSection(data: item, collapsed: collapsed, pageState: this); } })); } } -sealed class _InboxSectionData { - const _InboxSectionData(); +sealed class _InboxListItem { + const _InboxListItem(); } -class _AllDmsSectionData extends _InboxSectionData { +class _InboxListItemAllDmsSection extends _InboxListItem { final int count; final bool hasMention; final List<(DmNarrow, int, bool)> items; - const _AllDmsSectionData(this.count, this.hasMention, this.items); + const _InboxListItemAllDmsSection(this.count, this.hasMention, this.items); } -class _StreamSectionData extends _InboxSectionData { +class _InboxListItemChannelSection extends _InboxListItem { final int streamId; final int count; final bool hasMention; final List items; - const _StreamSectionData(this.streamId, this.count, this.hasMention, this.items); + const _InboxListItemChannelSection(this.streamId, this.count, this.hasMention, this.items); } @visibleForTesting @@ -391,7 +391,7 @@ class _AllDmsSection extends StatelessWidget { required this.pageState, }); - final _AllDmsSectionData data; + final _InboxListItemAllDmsSection data; final bool collapsed; final _InboxPageState pageState; @@ -540,7 +540,7 @@ class _StreamSection extends StatelessWidget { required this.pageState, }); - final _StreamSectionData data; + final _InboxListItemChannelSection data; final bool collapsed; final _InboxPageState pageState; From 1ce8a2804b09c25bfe4f64b4317d07242640f534 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Feb 2026 16:10:37 -0800 Subject: [PATCH 07/12] inbox [nfc]: Use named params for _InboxListItemChannelSection constructor --- lib/widgets/inbox.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 4aeaa7b9e4..db54d2d422 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -164,7 +164,11 @@ class _InboxPageState extends State with PerAccountStoreAwareStat return bLastUnreadId.compareTo(aLastUnreadId); }); final section = _InboxListItemChannelSection( - streamId, countInStream, streamHasMention, topicItems); + streamId: streamId, + count: countInStream, + hasMention: streamHasMention, + items: topicItems, + ); if (sub.pinToTop) { pinnedChannelSections.add(section); } else { @@ -234,12 +238,17 @@ class _InboxListItemAllDmsSection extends _InboxListItem { } class _InboxListItemChannelSection extends _InboxListItem { + const _InboxListItemChannelSection({ + required this.streamId, + required this.count, + required this.hasMention, + required this.items, + }); + final int streamId; final int count; final bool hasMention; final List items; - - const _InboxListItemChannelSection(this.streamId, this.count, this.hasMention, this.items); } @visibleForTesting From 6058f4896394af06f6c7b35ea1470197311dc350 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Feb 2026 15:59:42 -0800 Subject: [PATCH 08/12] inbox: Introduce folder-style headers, just for PINNED and OTHER Tests written with Claude. Co-Authored-By: Claude Opus 4.6 --- assets/l10n/app_en.arb | 8 +++ lib/generated/l10n/zulip_localizations.dart | 12 +++++ .../l10n/zulip_localizations_ar.dart | 6 +++ .../l10n/zulip_localizations_de.dart | 6 +++ .../l10n/zulip_localizations_el.dart | 6 +++ .../l10n/zulip_localizations_en.dart | 6 +++ .../l10n/zulip_localizations_es.dart | 6 +++ .../l10n/zulip_localizations_et.dart | 6 +++ .../l10n/zulip_localizations_fr.dart | 6 +++ .../l10n/zulip_localizations_he.dart | 6 +++ .../l10n/zulip_localizations_hu.dart | 6 +++ .../l10n/zulip_localizations_it.dart | 6 +++ .../l10n/zulip_localizations_ja.dart | 6 +++ .../l10n/zulip_localizations_kk.dart | 6 +++ .../l10n/zulip_localizations_lv.dart | 6 +++ .../l10n/zulip_localizations_nb.dart | 6 +++ .../l10n/zulip_localizations_pl.dart | 6 +++ .../l10n/zulip_localizations_pt.dart | 6 +++ .../l10n/zulip_localizations_ru.dart | 6 +++ .../l10n/zulip_localizations_sk.dart | 6 +++ .../l10n/zulip_localizations_sl.dart | 6 +++ .../l10n/zulip_localizations_uk.dart | 6 +++ .../l10n/zulip_localizations_vi.dart | 6 +++ .../l10n/zulip_localizations_zh.dart | 6 +++ lib/widgets/inbox.dart | 52 ++++++++++++++++++- test/widgets/inbox_test.dart | 49 +++++++++++++++++ 26 files changed, 251 insertions(+), 2 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index b394916e33..32fdc4e6d6 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1227,6 +1227,14 @@ "@inboxEmptyPlaceholderMessage": { "description": "Additional centered text on the 'Inbox' page saying that there is no content to show." }, + "pinnedChannelsFolderName": "Pinned channels", + "@pinnedChannelsFolderName": { + "description": "Label for the channel folder for pinned channels." + }, + "otherChannelsFolderName": "Other channels", + "@otherChannelsFolderName": { + "description": "Label for the channel folder for other channels." + }, "recentDmConversationsPageTitle": "Direct messages", "@recentDmConversationsPageTitle": { "description": "Title for the page with a list of DM conversations." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index afe5b70c1e..514cd0ef28 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1802,6 +1802,18 @@ abstract class ZulipLocalizations { /// **'Use the buttons below to view the combined feed or list of channels.'** String get inboxEmptyPlaceholderMessage; + /// Label for the channel folder for pinned channels. + /// + /// In en, this message translates to: + /// **'Pinned channels'** + String get pinnedChannelsFolderName; + + /// Label for the channel folder for other channels. + /// + /// In en, this message translates to: + /// **'Other channels'** + String get otherChannelsFolderName; + /// Title for the page with a list of DM conversations. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 8b1476727c..9a3a230a29 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 5bb6cd755a..4c2d0b48de 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -1062,6 +1062,12 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Nutze die Buttons unten, um den kombinierten Feed oder die Liste der Kanäle anzuzeigen.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direktnachrichten'; diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart index c0d7633707..900366f445 100644 --- a/lib/generated/l10n/zulip_localizations_el.dart +++ b/lib/generated/l10n/zulip_localizations_el.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsEl extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index ec2ce2e1c2..f4682e39df 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart index fe29aa121a..76c0adab56 100644 --- a/lib/generated/l10n/zulip_localizations_es.dart +++ b/lib/generated/l10n/zulip_localizations_es.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsEs extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_et.dart b/lib/generated/l10n/zulip_localizations_et.dart index 7f3ffb7f9a..10a0ef498c 100644 --- a/lib/generated/l10n/zulip_localizations_et.dart +++ b/lib/generated/l10n/zulip_localizations_et.dart @@ -1038,6 +1038,12 @@ class ZulipLocalizationsEt extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index c40ecc2cb4..0f3e86b675 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -1070,6 +1070,12 @@ class ZulipLocalizationsFr extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Utilisez les boutons ci-dessous pour voir le fil groupé ou la liste des canaux.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Messages directs'; diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart index 84337c381f..8343310435 100644 --- a/lib/generated/l10n/zulip_localizations_he.dart +++ b/lib/generated/l10n/zulip_localizations_he.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsHe extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart index 0cebf0c2a3..fe1de829dc 100644 --- a/lib/generated/l10n/zulip_localizations_hu.dart +++ b/lib/generated/l10n/zulip_localizations_hu.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsHu extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 2433f9b3b8..8a68ef62c0 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -1061,6 +1061,12 @@ class ZulipLocalizationsIt extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Usa i pulsanti qui sotto per visualzzare il feed combinato o la lista dei canali.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Messaggi diretti'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 2ad07f703a..90e71999be 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -1012,6 +1012,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => '下のボタンを使用して、統合フィードまたはチャンネル一覧を表示します。'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'ダイレクトメッセージ'; diff --git a/lib/generated/l10n/zulip_localizations_kk.dart b/lib/generated/l10n/zulip_localizations_kk.dart index a4b1a71b6a..eff97220ae 100644 --- a/lib/generated/l10n/zulip_localizations_kk.dart +++ b/lib/generated/l10n/zulip_localizations_kk.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsKk extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_lv.dart b/lib/generated/l10n/zulip_localizations_lv.dart index 679b2dc43d..d169293216 100644 --- a/lib/generated/l10n/zulip_localizations_lv.dart +++ b/lib/generated/l10n/zulip_localizations_lv.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsLv extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 516402903f..6db97297d5 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index c4a400f1d7..4c4f10c62b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -1054,6 +1054,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Użyj poniższych przycisków aby skorzystać z widoku mieszanego lub listy kanałów.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Wiadomości bezpośrednie'; diff --git a/lib/generated/l10n/zulip_localizations_pt.dart b/lib/generated/l10n/zulip_localizations_pt.dart index ef30202cb0..74f3eb5a30 100644 --- a/lib/generated/l10n/zulip_localizations_pt.dart +++ b/lib/generated/l10n/zulip_localizations_pt.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsPt extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 65d36a2b0f..b10ed3d602 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -1064,6 +1064,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Используйте кнопки внизу для просмотра объединенной ленты или списка каналов.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Личные сообщения'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 8ac7b7a949..aee0150d0c 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -1038,6 +1038,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Priama správa'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index fbfe28d646..a5481b4d52 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -1071,6 +1071,12 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Uporabite spodnje gumbe za ogled združenega vira ali seznama kanalov.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Neposredna sporočila'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 2887417edc..eec47284c6 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -1055,6 +1055,12 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Скористайтеся кнопками нижче, щоб переглянути об’єднану стрічку або список каналів.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Особисті повідомлення'; diff --git a/lib/generated/l10n/zulip_localizations_vi.dart b/lib/generated/l10n/zulip_localizations_vi.dart index a0fd7199d8..ee93593d1b 100644 --- a/lib/generated/l10n/zulip_localizations_vi.dart +++ b/lib/generated/l10n/zulip_localizations_vi.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsVi extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 343b2b36a0..a32ea2e037 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -1036,6 +1036,12 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get inboxEmptyPlaceholderMessage => 'Use the buttons below to view the combined feed or list of channels.'; + @override + String get pinnedChannelsFolderName => 'Pinned channels'; + + @override + String get otherChannelsFolderName => 'Other channels'; + @override String get recentDmConversationsPageTitle => 'Direct messages'; diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index db54d2d422..d6745dd759 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -176,9 +176,11 @@ class _InboxPageState extends State with PerAccountStoreAwareStat } } - // TODO add PINNED and OTHER folder headers; - // deduplicate sorting code within PINNED and OTHER + // TODO deduplicate sorting code within PINNED and OTHER; + // consider realm-level folders too if (pinnedChannelSections.isNotEmpty) { + final label = zulipLocalizations.pinnedChannelsFolderName; + items.add(_InboxListItemFolderHeader(label: label)); pinnedChannelSections.sort((a, b) { final subA = subscriptions[a.streamId]!; final subB = subscriptions[b.streamId]!; @@ -189,6 +191,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat } if (otherChannelSections.isNotEmpty) { + final label = zulipLocalizations.otherChannelsFolderName; + items.add(_InboxListItemFolderHeader(label: label)); otherChannelSections.sort((a, b) { final subA = subscriptions[a.streamId]!; final subB = subscriptions[b.streamId]!; @@ -211,6 +215,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat itemBuilder: (context, index) { final item = items[index]; switch (item) { + case _InboxListItemFolderHeader(): + return InboxFolderHeaderItem(label: item.label); case _InboxListItemAllDmsSection(): return _AllDmsSection( data: item, @@ -229,6 +235,15 @@ sealed class _InboxListItem { const _InboxListItem(); } +class _InboxListItemFolderHeader extends _InboxListItem { + const _InboxListItemFolderHeader({required this.label}); + + /// The label for this folder, not yet uppercased. + final String label; + + // TODO count, hasMention +} + class _InboxListItemAllDmsSection extends _InboxListItem { final int count; final bool hasMention; @@ -364,6 +379,39 @@ abstract class _HeaderItem extends StatelessWidget { } } +@visibleForTesting +class InboxFolderHeaderItem extends StatelessWidget { + const InboxFolderHeaderItem({super.key, required this.label}); + + /// The label for this folder header, not yet uppercased. + /// + /// The implementation will call [String.toUpperCase] on this. + final String label; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + Widget result = ColoredBox( + color: designVariables.background, // TODO(design) check if this is the right variable + child: Padding( + padding: EdgeInsetsDirectional.fromSTEB(14, 8, 12, 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, spacing: 8, children: [ + Expanded( + child: Text( + style: TextStyle( + color: designVariables.folderText, + fontSize: 16, + height: 20 / 16, + letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), + ).merge(weightVariableTextStyle(context, wght: 600)), + label.toUpperCase())), + ]))); + + return Semantics(container: true, + child: result); + } +} + @visibleForTesting class InboxAllDmsHeaderItem extends _HeaderItem { const InboxAllDmsHeaderItem({ diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index e45cb7c826..cf4c98edd3 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -126,6 +126,16 @@ void main() { ]); } + void checkFolderHeader(String label) { + check(find.widgetWithText(InboxFolderHeaderItem, label.toUpperCase())) + .findsOne(); + } + + void checkNoFolderHeader(String label) { + check(find.widgetWithText(InboxFolderHeaderItem, label.toUpperCase())) + .findsNothing(); + } + // TODO instead of .first, could look for both the row in the list *and* // in the sticky-header position, or at least target one or the other // intentionally. @@ -329,6 +339,45 @@ void main() { }); }); + group('folder headers', () { + testWidgets('only pinned channels: shows pinned header, no other header', (tester) async { + final channel = eg.stream(); + await setupPage(tester, + streams: [channel], + subscriptions: [eg.subscription(channel, pinToTop: true)], + unreadMessages: [eg.streamMessage(stream: channel)]); + checkFolderHeader('Pinned channels'); + checkNoFolderHeader('Other channels'); + }); + + testWidgets('only unpinned channels: shows other header, no pinned header', (tester) async { + final channel = eg.stream(); + await setupPage(tester, + streams: [channel], + subscriptions: [eg.subscription(channel, pinToTop: false)], + unreadMessages: [eg.streamMessage(stream: channel)]); + checkNoFolderHeader('Pinned channels'); + checkFolderHeader('Other channels'); + }); + + testWidgets('both pinned and unpinned channels: shows both headers', (tester) async { + final pinned = eg.stream(); + final unpinned = eg.stream(); + await setupPage(tester, + streams: [pinned, unpinned], + subscriptions: [ + eg.subscription(pinned, pinToTop: true), + eg.subscription(unpinned, pinToTop: false), + ], + unreadMessages: [ + eg.streamMessage(stream: pinned), + eg.streamMessage(stream: unpinned), + ]); + checkFolderHeader('Pinned channels'); + checkFolderHeader('Other channels'); + }); + }); + testWidgets('UnreadCountBadge text color for a channel', (tester) async { // Regression test for a bug where // DesignVariables.labelCounterUnread was used for the text instead of From 25235cac93a75be19174a348d282c5a72c77dfc8 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Feb 2026 15:29:00 -0800 Subject: [PATCH 09/12] inbox: Use folder-style header for all-DMs, too This removes the unread count and @-mention icon from the all-DMs header, which the channel-folder headers will also leave out for now. --- lib/widgets/inbox.dart | 98 +++++------------------------ test/widgets/inbox_test.dart | 117 +++-------------------------------- 2 files changed, 25 insertions(+), 190 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index d6745dd759..4fae2709b4 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -108,9 +108,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat // TODO efficiently include DM conversations that aren't recent enough // to appear in recentDmConversationsView, but still have unreads in // unreadsModel. - final dmItems = <(DmNarrow, int, bool)>[]; - int allDmsCount = 0; - bool allDmsHasMention = false; + final dmItems = <_InboxListItemDmConversation>[]; for (final dmNarrow in recentDmConversationsModel!.sorted) { final countInNarrow = unreadsModel!.countInDmNarrow(dmNarrow); if (countInNarrow == 0) { @@ -118,12 +116,13 @@ class _InboxPageState extends State with PerAccountStoreAwareStat } final hasMention = unreadsModel!.dms[dmNarrow]!.any( (messageId) => unreadsModel!.mentions.contains(messageId)); - if (hasMention) allDmsHasMention = true; - dmItems.add((dmNarrow, countInNarrow, hasMention)); - allDmsCount += countInNarrow; + dmItems.add(_InboxListItemDmConversation( + narrow: dmNarrow, count: countInNarrow, hasMention: hasMention)); } if (dmItems.isNotEmpty) { - items.add(_InboxListItemAllDmsSection(allDmsCount, allDmsHasMention, dmItems)); + items.add(_InboxListItemFolderHeader( + label: zulipLocalizations.recentDmConversationsSectionHeader)); + items.addAll(dmItems); } final pinnedChannelSections = <_InboxListItemChannelSection>[]; @@ -217,12 +216,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat switch (item) { case _InboxListItemFolderHeader(): return InboxFolderHeaderItem(label: item.label); - case _InboxListItemAllDmsSection(): - return _AllDmsSection( - data: item, - collapsed: allDmsCollapsed, - pageState: this, - ); + case _InboxListItemDmConversation(:final narrow, :final count, :final hasMention): + return InboxDmItem(narrow: narrow, count: count, hasMention: hasMention); case _InboxListItemChannelSection(:var streamId): final collapsed = collapsedStreamIds.contains(streamId); return _StreamSection(data: item, collapsed: collapsed, pageState: this); @@ -244,12 +239,16 @@ class _InboxListItemFolderHeader extends _InboxListItem { // TODO count, hasMention } -class _InboxListItemAllDmsSection extends _InboxListItem { +class _InboxListItemDmConversation extends _InboxListItem { + const _InboxListItemDmConversation({ + required this.narrow, + required this.count, + required this.hasMention, + }); + + final DmNarrow narrow; final int count; final bool hasMention; - final List<(DmNarrow, int, bool)> items; - - const _InboxListItemAllDmsSection(this.count, this.hasMention, this.items); } class _InboxListItemChannelSection extends _InboxListItem { @@ -412,71 +411,6 @@ class InboxFolderHeaderItem extends StatelessWidget { } } -@visibleForTesting -class InboxAllDmsHeaderItem extends _HeaderItem { - const InboxAllDmsHeaderItem({ - super.key, - required super.collapsed, - required super.pageState, - required super.count, - required super.hasMention, - required super.sectionContext, - }); - - @override String title(ZulipLocalizations zulipLocalizations) => - zulipLocalizations.recentDmConversationsSectionHeader; - @override IconData get icon => ZulipIcons.two_person; - - // TODO(design) check if this is the right variable for these - @override Color collapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; - @override Color uncollapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; - - @override Color uncollapsedBackgroundColor(context) => DesignVariables.of(context).dmHeaderBg; - @override int? get channelId => null; - - @override Future onCollapseButtonTap() async { - await super.onCollapseButtonTap(); - pageState.allDmsCollapsed = !collapsed; - } - @override Future onRowTap() => onCollapseButtonTap(); // TODO open all-DMs narrow? -} - -class _AllDmsSection extends StatelessWidget { - const _AllDmsSection({ - required this.data, - required this.collapsed, - required this.pageState, - }); - - final _InboxListItemAllDmsSection data; - final bool collapsed; - final _InboxPageState pageState; - - @override - Widget build(BuildContext context) { - final header = InboxAllDmsHeaderItem( - count: data.count, - hasMention: data.hasMention, - collapsed: collapsed, - pageState: pageState, - sectionContext: context, - ); - return StickyHeaderItem( - header: header, - child: Column(children: [ - header, - if (!collapsed) ...data.items.map((item) { - final (narrow, count, hasMention) = item; - return InboxDmItem( - narrow: narrow, - count: count, - hasMention: hasMention, - ); - }), - ])); - } -} - @visibleForTesting class InboxDmItem extends StatelessWidget { const InboxDmItem({ diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index cf4c98edd3..54fccd683c 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -136,54 +136,6 @@ void main() { .findsNothing(); } - // TODO instead of .first, could look for both the row in the list *and* - // in the sticky-header position, or at least target one or the other - // intentionally. - final findAllDmsHeader = find.byType(InboxAllDmsHeaderItem).first; - - /// Check details of the "Direct messages" header. - /// - /// For [findSectionContent], optionally pass a [Finder] - /// that will find some of the section's content if it is uncollapsed. - /// It will be expected to find something or nothing, - /// depending on [expectCollapsed]. - void checkAllDmsHeader(WidgetTester tester, { - Color? expectedBackgroundColor, - bool? expectAtSignIcon, - bool? expectCollapsed, - Finder? findSectionContent, - }) { - check(findAllDmsHeader).findsOne(); - - if (expectAtSignIcon != null) { - check(find.descendant(of: findAllDmsHeader, matching: find.byIcon(ZulipIcons.at_sign))) - .findsExactly(expectAtSignIcon ? 1 : 0); - } - - if (expectCollapsed != null) { - check(find.descendant( - of: findAllDmsHeader, - matching: find.byIcon( - expectCollapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down))).findsOne(); - - final renderObject = tester.renderObject(findAllDmsHeader); - final paintBounds = renderObject.paintBounds; - - // `paints` isn't a [Matcher] so we wrap it with `equals`; - // awkward but it works - check(renderObject).legacyMatcher(equals(paints..rrect( - rrect: RRect.fromRectAndRadius(paintBounds, Radius.zero), - style: .fill, - color: expectCollapsed - ? Colors.white - : const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor()))); - - if (findSectionContent != null) { - check(findSectionContent).findsExactly(expectCollapsed ? 0 : 1); - } - } - } - void checkDm(Pattern expectLabelContains, { bool expectAtSignIcon = false, String? expectCounterBadgeText, @@ -340,6 +292,13 @@ void main() { }); group('folder headers', () { + testWidgets('DMs header', (tester) async { + await setupPage(tester, + users: [eg.selfUser, eg.otherUser], + unreadMessages: [eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])]); + checkFolderHeader('Direct messages'); + }); + testWidgets('only pinned channels: shows pinned header, no other header', (tester) async { final channel = eg.stream(); await setupPage(tester, @@ -484,7 +443,7 @@ void main() { unreadMessages: [eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned])]); - checkAllDmsHeader(tester, expectAtSignIcon: true); + checkFolderHeader('Direct messages'); checkDm(eg.otherUser.fullName, expectAtSignIcon: true); }); @@ -494,7 +453,7 @@ void main() { unreadMessages: [eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [])]); - checkAllDmsHeader(tester, expectAtSignIcon: false); + checkFolderHeader('Direct messages'); checkDm(eg.otherUser.fullName, expectAtSignIcon: false); }); }); @@ -574,64 +533,6 @@ void main() { }); group('collapsing', () { - group('all-DMs section', () { - Future tapCollapseIcon(WidgetTester tester) async { - checkAllDmsHeader(tester); - await tester.tap(find.descendant( - of: findAllDmsHeader, - matching: find.byWidgetPredicate((widget) => - widget is Icon - && (widget.icon == ZulipIcons.arrow_down - || widget.icon == ZulipIcons.arrow_right)))); - await tester.pump(); - } - - testWidgets('appearance', (tester) async { - await setupVarious(tester); - - final findSectionContent = find.text(eg.otherUser.fullName); - - checkAllDmsHeader(tester, - expectCollapsed: false, findSectionContent: findSectionContent); - await tapCollapseIcon(tester); - checkAllDmsHeader(tester, - expectCollapsed: true, findSectionContent: findSectionContent); - await tapCollapseIcon(tester); - checkAllDmsHeader(tester, - expectCollapsed: false, findSectionContent: findSectionContent); - }); - - testWidgets('collapse all-DMs section when partially offscreen: ' - 'header remains sticky at top', (tester) async { - await setupVarious(tester); - - final listFinder = find.byType(Scrollable); - final dmFinder = find.text(eg.otherUser.fullName).hitTestable(); - - // Scroll part of [_AllDmsSection] offscreen. - await dragUntilInvisible( - tester, dmFinder, listFinder, const Offset(0, -50)); - - // Check that the header is present (which must therefore - // be as a sticky header). - checkAllDmsHeader(tester, expectCollapsed: false); - - await tapCollapseIcon(tester); - - // Check that the header is still visible even after - // collapsing the section. - checkAllDmsHeader(tester, expectCollapsed: true); - }); - - // TODO check it remains collapsed even if you scroll far away and back - - // TODO check that it's always uncollapsed when it appears after being - // absent, even if it was collapsed the last time it was present. - // (Could test multiple triggers for its reappearance: it could - // reappear because a new unread arrived, but with #296 it could also - // reappear because of a change in muted-users state.) - }); - group('stream section', () { Future tapCollapseIcon(WidgetTester tester, Subscription subscription) async { checkChannelHeader(tester, subscription); From 6c8ddb32d1b50b3039bf96cdb6deb03ad4233529 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Feb 2026 16:17:05 -0800 Subject: [PATCH 10/12] inbox [nfc]: Merge _HeaderItem base class into its only remaining subclass This _HeaderItem class had two subclasses before: (a) one for the all-DMs section header, InboxAllDmsHeaderItem (b) one for channel section headers, InboxChannelHeaderItem We recently restyled the all-DMs section header as a folder-style header, dropping (a). Now, simplify InboxChannelHeaderItem by having it absorb all that _HeaderItem was doing. --- lib/widgets/inbox.dart | 208 ++++++++++++++--------------------- test/widgets/inbox_test.dart | 2 +- 2 files changed, 81 insertions(+), 129 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 4fae2709b4..36e2d268a9 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -280,104 +280,6 @@ class InboxChannelSectionTopicData { }); } -abstract class _HeaderItem extends StatelessWidget { - final bool collapsed; - final InboxPageState pageState; - final int count; - final bool hasMention; - - /// A build context within the [_StreamSection] or [_AllDmsSection]. - /// - /// Used to ensure the [_StreamSection] or [_AllDmsSection] that encloses the - /// current [_HeaderItem] is visible after being collapsed through this - /// [_HeaderItem]. - final BuildContext sectionContext; - - const _HeaderItem({ - super.key, - required this.collapsed, - required this.pageState, - required this.count, - required this.hasMention, - required this.sectionContext, - }); - - String title(ZulipLocalizations zulipLocalizations); - IconData get icon; - Color collapsedIconColor(BuildContext context); - Color uncollapsedIconColor(BuildContext context); - Color uncollapsedBackgroundColor(BuildContext context); - - /// A channel ID, if this represents a channel, else null. - int? get channelId; - - Future onCollapseButtonTap() async { - if (!collapsed) { - await Scrollable.ensureVisible( - sectionContext, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, - ); - } - } - - Future onRowTap(); - - @override - Widget build(BuildContext context) { - final zulipLocalizations = ZulipLocalizations.of(context); - final designVariables = DesignVariables.of(context); - Widget result = Material( - color: collapsed - ? designVariables.background // TODO(design) check if this is the right variable - : uncollapsedBackgroundColor(context), - child: InkWell( - // TODO use onRowTap to handle taps that are not on the collapse button. - // Probably we should give the collapse button a 44px or 48px square - // touch target: - // - // But that's in tension with the Figma, which gives these header rows - // 40px min height. - onTap: onCollapseButtonTap, - onLongPress: this is _LongPressable - ? (this as _LongPressable).onLongPress - : null, - child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding(padding: const EdgeInsets.all(10), - child: Icon(size: 20, color: designVariables.sectionCollapseIcon, - collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), - Icon(size: 18, - color: collapsed - ? collapsedIconColor(context) - : uncollapsedIconColor(context), - icon), - const SizedBox(width: 5), - Expanded(child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - style: TextStyle( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, - ).merge(weightVariableTextStyle(context, wght: 600)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - title(zulipLocalizations)))), - const SizedBox(width: 12), - if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), - Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: CounterBadge( - // TODO(design) use CounterKind.quantity, following Figma - kind: CounterBadgeKind.unread, - channelIdForBackground: channelId, - count: count)), - ]))); - - return Semantics(container: true, - child: result); - } -} - @visibleForTesting class InboxFolderHeaderItem extends StatelessWidget { const InboxFolderHeaderItem({super.key, required this.label}); @@ -477,51 +379,101 @@ class InboxDmItem extends StatelessWidget { } } -mixin _LongPressable on _HeaderItem { - // TODO(#1272) move to _HeaderItem base class - // when DM headers become long-pressable; remove mixin - Future onLongPress(); -} - @visibleForTesting -class InboxChannelHeaderItem extends _HeaderItem with _LongPressable { - final Subscription subscription; - +class InboxChannelHeaderItem extends StatelessWidget { const InboxChannelHeaderItem({ super.key, required this.subscription, - required super.collapsed, - required super.pageState, - required super.count, - required super.hasMention, - required super.sectionContext, + required this.collapsed, + required this.pageState, + required this.count, + required this.hasMention, + required this.sectionContext, }); - @override String title(ZulipLocalizations zulipLocalizations) => - subscription.name; - @override IconData get icon => iconDataForStream(subscription); - @override Color collapsedIconColor(context) => - colorSwatchFor(context, subscription).iconOnPlainBackground; - @override Color uncollapsedIconColor(context) => - colorSwatchFor(context, subscription).iconOnBarBackground; - @override Color uncollapsedBackgroundColor(context) => - colorSwatchFor(context, subscription).barBackground; - @override int? get channelId => subscription.streamId; - - @override Future onCollapseButtonTap() async { - await super.onCollapseButtonTap(); + final Subscription subscription; + final bool collapsed; + final InboxPageState pageState; + final int count; + final bool hasMention; + + /// A build context within the [_StreamSection] or [_AllDmsSection]. + /// + /// Used to ensure the [_StreamSection] or [_AllDmsSection] that encloses the + /// current [InboxFolderHeaderItem] is visible after being collapsed through this + /// [InboxFolderHeaderItem]. + final BuildContext sectionContext; + + void _onCollapseButtonTap() async { if (collapsed) { pageState.uncollapseStream(subscription.streamId); } else { + await Scrollable.ensureVisible( + sectionContext, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + ); pageState.collapseStream(subscription.streamId); } } - @override Future onRowTap() => onCollapseButtonTap(); // TODO open channel narrow - @override - Future onLongPress() async { + void _onLongPress() async { showChannelActionSheet(sectionContext, channelId: subscription.streamId); } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final swatch = colorSwatchFor(context, subscription); + + Widget result = Material( + color: collapsed + ? designVariables.background // TODO(design) check if this is the right variable + : swatch.barBackground, + child: InkWell( + // TODO use onRowTap to handle taps that are not on the collapse button. + // Probably we should give the collapse button a 44px or 48px square + // touch target: + // + // But that's in tension with the Figma, which gives these header rows + // 40px min height. + onTap: _onCollapseButtonTap, + onLongPress: _onLongPress, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + child: Icon(size: 20, color: designVariables.sectionCollapseIcon, + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + Icon(size: 18, + color: collapsed + ? swatch.iconOnPlainBackground + : swatch.iconOnBarBackground, + iconDataForStream(subscription)), + const SizedBox(width: 5), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: TextStyle( + fontSize: 17, + height: (20 / 17), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ).merge(weightVariableTextStyle(context, wght: 600)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + subscription.name))), + const SizedBox(width: 12), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: CounterBadge( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterBadgeKind.unread, + channelIdForBackground: subscription.streamId, + count: count)), + ]))); + + return Semantics(container: true, + child: result); + } } class _StreamSection extends StatelessWidget { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 54fccd683c..3bd46c4b72 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -160,7 +160,7 @@ void main() { // in the sticky-header position, or at least target one or the other // intentionally. Finder findChannelHeader(int channelId) => find.byWidgetPredicate((widget) => - widget is InboxChannelHeaderItem && widget.channelId == channelId).first; + widget is InboxChannelHeaderItem && widget.subscription.streamId == channelId).first; /// Check details of a channel header. /// From 76d372bff428ac18041c88de0766f6d45264795d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Feb 2026 16:38:29 -0800 Subject: [PATCH 11/12] channel: Support "UI channel folder" concept Clients are actually expected to show "PINNED" and "OTHER" sections in the Inbox, with the same styling as channel folders that admins can create for the org with Zulip Server 11+. We can think of these two entities as "pseudo" channel folders. "UI channel folder" seems like a reasonable umbrella term for these "pseudo" channel folders and the realm/org-level channel folders. Also replace the existing compareChannelFolders function, which acted only on realm-level folders, with a new function compareUiChannelFolders that's actually what we'll want for the inbox. Co-Authored-By: Claude Opus 4.6 --- lib/model/channel.dart | 130 +++++++++++++++++++++++++++++++---- test/model/channel_test.dart | 44 ++++++++---- test/model/test_store.dart | 6 ++ 3 files changed, 152 insertions(+), 28 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index e5e3f3d87a..beb3ad6b37 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; import 'realm.dart'; import 'store.dart'; import 'user.dart'; @@ -63,21 +64,6 @@ mixin ChannelStore on UserStore { // ignore: valid_regexps static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true); - /// A compare function for [ChannelFolder]s, using [ChannelFolder.order]. - /// - /// Channels without [ChannelFolder.order] will come first, - /// sorted alphabetically. - // TODO(server-11) Once [ChannelFolder.order] is required, - // remove alphabetical sorting. - static int compareChannelFolders(ChannelFolder a, ChannelFolder b) { - return switch ((a.order, b.order)) { - (null, null) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - (null, int()) => -1, - (int(), null) => 1, - (int a, int b) => a.compareTo(b), - }; - } - /// The visibility policy that the self-user has for the given topic. /// /// This does not incorporate the user's channel-level policy, @@ -179,6 +165,53 @@ mixin ChannelStore on UserStore { } } + /// The channel folder of [channelId], + /// including the "PINNED" or "OTHER" pseudo-channels (e.g. for the inbox). + UiChannelFolder uiChannelFolder(int channelId) => + switch (streams[channelId]) { + Subscription(:final pinToTop) when pinToTop => + UiChannelFolderPseudoPinned(), + ZulipStream(:final folderId) when folderId != null => + UiChannelFolderRealmFolder(id: folderId), + _ => UiChannelFolderPseudoOther(), + }; + + /// A compare function for [UiChannelFolder]s, + /// using [ChannelFolder.order] for realm channel folders. + /// + /// Puts "PINNED CHANNELS" first, + /// then realm channel folders by [ChannelFolder.order] + /// (or alphabetically if that's absent), + /// then "OTHER CHANNELS". + // TODO(server-11) Once [ChannelFolder.order] is required, + // remove alphabetical sorting and update dartdoc. + int compareUiChannelFolders(UiChannelFolder a, UiChannelFolder b) { + switch ((a, b)) { + case (UiChannelFolderPseudoPinned(), _): return -1; + case (_, UiChannelFolderPseudoPinned()): return 1; + case (UiChannelFolderPseudoOther(), _): return 1; + case (_, UiChannelFolderPseudoOther()): return -1; + + case ( + UiChannelFolderRealmFolder(id: final idA), + UiChannelFolderRealmFolder(id: final idB), + ): + final folderA = channelFolders[idA]; + final folderB = channelFolders[idB]; + if (folderA == null || folderB == null) { // TODO(log) + assert(false); + return 0; + } + + return switch ((folderA.order, folderB.order)) { + (null, null) => folderA.name.toLowerCase().compareTo(folderB.name.toLowerCase()), + (null, int()) => -1, + (int(), null) => 1, + (int a, int b) => a.compareTo(b), + }; + } + } + bool selfHasContentAccess(ZulipStream channel) { // Compare web's stream_data.has_content_access. if (channel.isWebPublic) return true; @@ -272,6 +305,73 @@ enum UserTopicVisibilityEffect { } } +/// A realm-level channel folder or the "PINNED" or "OTHER" channel folder. +/// +/// See the Help Center doc: +/// https://zulip.com/help/channel-folders +sealed class UiChannelFolder { + const UiChannelFolder(); + + /// This folder's name, not yet UPPERCASED for the UI. + String name({ + required ChannelStore store, + required ZulipLocalizations zulipLocalizations, + }); +} + +class UiChannelFolderPseudoPinned extends UiChannelFolder { + @override + String name({required store, required zulipLocalizations}) => + zulipLocalizations.pinnedChannelsFolderName; + + @override + bool operator ==(Object other) { + if (other is! UiChannelFolderPseudoPinned) return false; + // Conceptually there's only one value of this type. + return true; + } + + @override + int get hashCode => 'UiChannelFolderPseudoPinned'.hashCode; +} + +class UiChannelFolderPseudoOther extends UiChannelFolder { + @override + String name({required store, required zulipLocalizations}) => + zulipLocalizations.otherChannelsFolderName; + + @override + bool operator ==(Object other) { + if (other is! UiChannelFolderPseudoOther) return false; + // Conceptually there's only one value of this type. + return true; + } + + @override + int get hashCode => 'UiChannelFolderPseudoOther'.hashCode; +} + +class UiChannelFolderRealmFolder extends UiChannelFolder { + const UiChannelFolderRealmFolder({required this.id}); + + final int id; + + @override + String name({required store, required zulipLocalizations}) { + final folder = store.channelFolders[id]; + return folder!.name; + } + + @override + bool operator ==(Object other) { + if (other is! UiChannelFolderRealmFolder) return false; + return id == other.id; + } + + @override + int get hashCode => Object.hash('UiChannelFolderRealmFolder', id); +} + mixin ProxyChannelStore on ChannelStore { @protected ChannelStore get channelStore; diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 6c2de3e83c..a098b1760a 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -92,19 +92,37 @@ void main() { }); }); - group('channelFolderComparator', () { - final folder1 = eg.channelFolder(id: 1, order: null, name: 'M'); - final folder2 = eg.channelFolder(id: 2, order: null, name: 'n'); - final folder3 = eg.channelFolder(id: 3, order: 2, name: 'a'); - final folder4 = eg.channelFolder(id: 4, order: 0, name: 'b'); - final folder5 = eg.channelFolder(id: 5, order: 1, name: 'c'); - - final store = eg.store(initialSnapshot: eg.initialSnapshot( - channelFolders: [folder1, folder2, folder3, folder4, folder5])); - - final sorted = store.channelFolders.values.toList() - .sorted(ChannelStore.compareChannelFolders); - check(sorted).deepEquals([folder1, folder2, folder4, folder5, folder3]); + test('compareUiChannelFolders', () async { + final store = eg.store(); + + final folder1 = eg.channelFolder(order: null, name: 'M'); + final folder2 = eg.channelFolder(order: null, name: 'n'); + final folder3 = eg.channelFolder(order: 2, name: 'a'); + final folder4 = eg.channelFolder(order: 0, name: 'b'); + final folder5 = eg.channelFolder(order: 1, name: 'c'); + + await store.addChannelFolders([folder1, folder2, folder3, folder4, folder5]); + + final unsorted = [ + UiChannelFolderRealmFolder(id: folder1.id), + UiChannelFolderRealmFolder(id: folder2.id), + UiChannelFolderRealmFolder(id: folder3.id), + UiChannelFolderRealmFolder(id: folder4.id), + UiChannelFolderRealmFolder(id: folder5.id), + UiChannelFolderPseudoOther(), + UiChannelFolderPseudoPinned(), + ]; + + final sorted = unsorted.toList().sorted(store.compareUiChannelFolders); + check(sorted).deepEquals([ + UiChannelFolderPseudoPinned(), + UiChannelFolderRealmFolder(id: folder1.id), + UiChannelFolderRealmFolder(id: folder2.id), + UiChannelFolderRealmFolder(id: folder4.id), + UiChannelFolderRealmFolder(id: folder5.id), + UiChannelFolderRealmFolder(id: folder3.id), + UiChannelFolderPseudoOther(), + ]); }); group('SubscriptionEvent', () { diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 9996e7b4c0..ec4b748e82 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -397,6 +397,12 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(ChannelFolderAddEvent(id: 1, channelFolder: channelFolder)); } + Future addChannelFolders(Iterable channelFolders) async { + for (final channelFolder in channelFolders) { + await addChannelFolder(channelFolder); + } + } + Future setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { await handleEvent(eg.userTopicEvent(stream.streamId, topic, visibilityPolicy)); } From 0b772ba7b4b8e1fd1170dd01810fa6353b3faf05 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 2 Feb 2026 13:11:33 -0800 Subject: [PATCH 12/12] inbox: Group channels by folder, including realm-level folders Fixes #1765. Tests written with help from Claude. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/inbox.dart | 50 +++++-------- test/widgets/inbox_test.dart | 134 ++++++++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 41 deletions(-) diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 36e2d268a9..dabf665334 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -125,8 +125,8 @@ class _InboxPageState extends State with PerAccountStoreAwareStat items.addAll(dmItems); } - final pinnedChannelSections = <_InboxListItemChannelSection>[]; - final otherChannelSections = <_InboxListItemChannelSection>[]; + final channelSectionsByFolder = >{}; + for (final MapEntry(key: streamId, value: topics) in unreadsModel!.streams.entries) { final sub = subscriptions[streamId]; // Filter out any straggling unreads in unsubscribed streams. @@ -162,43 +162,31 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); - final section = _InboxListItemChannelSection( - streamId: streamId, - count: countInStream, - hasMention: streamHasMention, - items: topicItems, - ); - if (sub.pinToTop) { - pinnedChannelSections.add(section); - } else { - otherChannelSections.add(section); - } - } - - // TODO deduplicate sorting code within PINNED and OTHER; - // consider realm-level folders too - if (pinnedChannelSections.isNotEmpty) { - final label = zulipLocalizations.pinnedChannelsFolderName; - items.add(_InboxListItemFolderHeader(label: label)); - pinnedChannelSections.sort((a, b) { - final subA = subscriptions[a.streamId]!; - final subB = subscriptions[b.streamId]!; - return ChannelStore.compareChannelsByName(subA, subB); - }); - items.addAll(pinnedChannelSections); + final uiChannelFolder = store.uiChannelFolder(streamId); + (channelSectionsByFolder[uiChannelFolder] ??= []) + .add(_InboxListItemChannelSection( + streamId: streamId, + count: countInStream, + hasMention: streamHasMention, + items: topicItems, + )); } - if (otherChannelSections.isNotEmpty) { - final label = zulipLocalizations.otherChannelsFolderName; - items.add(_InboxListItemFolderHeader(label: label)); - otherChannelSections.sort((a, b) { + final sortedFolders = channelSectionsByFolder.keys.toList() + ..sort(store.compareUiChannelFolders); + + for (final folder in sortedFolders) { + items.add(_InboxListItemFolderHeader( + label: folder.name(store: store, zulipLocalizations: zulipLocalizations))); + final channelSections = channelSectionsByFolder[folder]!; + channelSections.sort((a, b) { final subA = subscriptions[a.streamId]!; final subB = subscriptions[b.streamId]!; return ChannelStore.compareChannelsByName(subA, subB); }); - items.addAll(otherChannelSections); + items.addAll(channelSections); } if (items.isEmpty) { diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 3bd46c4b72..d78a00a60c 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -65,6 +65,7 @@ void main() { Future setupPage(WidgetTester tester, { List? streams, List? subscriptions, + List? channelFolders, List? users, required List unreadMessages, List? otherMessages, @@ -76,6 +77,7 @@ void main() { await store.addStreams(streams ?? []); await store.addSubscriptions(subscriptions ?? []); + await store.addChannelFolders(channelFolders ?? []); await store.addUsers(users ?? [eg.selfUser]); for (final message in unreadMessages) { @@ -299,22 +301,30 @@ void main() { checkFolderHeader('Direct messages'); }); - testWidgets('only pinned channels: shows pinned header, no other header', (tester) async { - final channel = eg.stream(); + testWidgets('unreads only in pinned channels: shows pinned header, no other header', (tester) async { + final pinnedChannel = eg.stream(); + final unpinnedChannel = eg.stream(); await setupPage(tester, - streams: [channel], - subscriptions: [eg.subscription(channel, pinToTop: true)], - unreadMessages: [eg.streamMessage(stream: channel)]); + streams: [pinnedChannel, unpinnedChannel], + subscriptions: [ + eg.subscription(pinnedChannel, pinToTop: true), + eg.subscription(unpinnedChannel, pinToTop: false), + ], + unreadMessages: [eg.streamMessage(stream: pinnedChannel)]); checkFolderHeader('Pinned channels'); checkNoFolderHeader('Other channels'); }); - testWidgets('only unpinned channels: shows other header, no pinned header', (tester) async { - final channel = eg.stream(); + testWidgets('unreads only in unpinned channels: shows other header, no pinned header', (tester) async { + final pinnedChannel = eg.stream(); + final unpinnedChannel = eg.stream(); await setupPage(tester, - streams: [channel], - subscriptions: [eg.subscription(channel, pinToTop: false)], - unreadMessages: [eg.streamMessage(stream: channel)]); + streams: [pinnedChannel, unpinnedChannel], + subscriptions: [ + eg.subscription(pinnedChannel, pinToTop: true), + eg.subscription(unpinnedChannel, pinToTop: false), + ], + unreadMessages: [eg.streamMessage(stream: unpinnedChannel)]); checkNoFolderHeader('Pinned channels'); checkFolderHeader('Other channels'); }); @@ -335,6 +345,110 @@ void main() { checkFolderHeader('Pinned channels'); checkFolderHeader('Other channels'); }); + + testWidgets('channel in a realm folder: shows folder name as header', (tester) async { + final folder = eg.channelFolder(name: 'Engineering'); + final channel = eg.stream(folderId: folder.id); + await setupPage(tester, + streams: [channel], + subscriptions: [eg.subscription(channel)], + channelFolders: [folder], + unreadMessages: [eg.streamMessage(stream: channel)]); + checkFolderHeader('Engineering'); + checkNoFolderHeader('Pinned channels'); + checkNoFolderHeader('Other channels'); + }); + + testWidgets('channels in different realm folders: each gets its own header', (tester) async { + final folder1 = eg.channelFolder(name: 'Engineering', order: 0); + final folder2 = eg.channelFolder(name: 'Marketing', order: 1); + final channel1 = eg.stream(folderId: folder1.id); + final channel2 = eg.stream(folderId: folder2.id); + await setupPage(tester, + streams: [channel1, channel2], + subscriptions: [ + eg.subscription(channel1), + eg.subscription(channel2), + ], + channelFolders: [folder1, folder2], + unreadMessages: [ + eg.streamMessage(stream: channel1), + eg.streamMessage(stream: channel2), + ]); + checkFolderHeader('Engineering'); + checkFolderHeader('Marketing'); + }); + + testWidgets('mix of pinned, realm folder, and other channels', (tester) async { + final folder = eg.channelFolder(name: 'Design'); + final pinned = eg.stream(); + final inFolder = eg.stream(folderId: folder.id); + final other = eg.stream(); + await setupPage(tester, + streams: [pinned, inFolder, other], + subscriptions: [ + eg.subscription(pinned, pinToTop: true), + eg.subscription(inFolder), + eg.subscription(other), + ], + channelFolders: [folder], + unreadMessages: [ + eg.streamMessage(stream: pinned), + eg.streamMessage(stream: inFolder), + eg.streamMessage(stream: other), + ]); + checkFolderHeader('Pinned channels'); + checkFolderHeader('Design'); + checkFolderHeader('Other channels'); + }); + + testWidgets('pinned channel in a realm folder: goes under pinned, not the folder', (tester) async { + final folder = eg.channelFolder(name: 'Engineering'); + final channel = eg.stream(folderId: folder.id); + await setupPage(tester, + streams: [channel], + subscriptions: [eg.subscription(channel, pinToTop: true)], + channelFolders: [folder], + unreadMessages: [eg.streamMessage(stream: channel)]); + checkFolderHeader('Pinned channels'); + checkNoFolderHeader('Engineering'); + }); + + testWidgets('DMs, pinned, realm folders in order, other', (tester) async { + final folder1 = eg.channelFolder(name: 'Zebra', order: 1); + final folder2 = eg.channelFolder(name: 'Alpha', order: 0); + final pinned = eg.stream(); + final inFolder1 = eg.stream(folderId: folder1.id); + final inFolder2 = eg.stream(folderId: folder2.id); + final other = eg.stream(); + await setupPage(tester, + users: [eg.selfUser, eg.otherUser], + streams: [pinned, inFolder1, inFolder2, other], + subscriptions: [ + eg.subscription(pinned, pinToTop: true), + eg.subscription(inFolder1), + eg.subscription(inFolder2), + eg.subscription(other), + ], + channelFolders: [folder1, folder2], + unreadMessages: [ + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + eg.streamMessage(stream: pinned), + eg.streamMessage(stream: inFolder1), + eg.streamMessage(stream: inFolder2), + eg.streamMessage(stream: other), + ]); + + final headers = tester.widgetList( + find.byType(InboxFolderHeaderItem)).toList(); + check(headers.map((h) => h.label)).deepEquals([ + 'Direct messages', + 'Pinned channels', + 'Alpha', + 'Zebra', + 'Other channels', + ]); + }); }); testWidgets('UnreadCountBadge text color for a channel', (tester) async {