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/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/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 60c3eb00aa..dabf665334 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'; @@ -102,14 +103,12 @@ 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 // 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) { @@ -117,15 +116,19 @@ 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 (allDmsCount > 0) { - sections.add(_AllDmsSectionData(allDmsCount, allDmsHasMention, dmItems)); + if (dmItems.isNotEmpty) { + items.add(_InboxListItemFolderHeader( + label: zulipLocalizations.recentDmConversationsSectionHeader)); + items.addAll(dmItems); } - final sortedUnreadStreams = unreadsModel!.streams.entries + 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. // There won't normally be any, but it happens with certain infrequent // state changes, typically for less than a few hundred milliseconds. @@ -133,22 +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; - } - - // TODO(i18n) something like JS's String.prototype.localeCompare - return subA.name.toLowerCase().compareTo(subB.name.toLowerCase()); - }); + if (sub == null) continue; - for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { final topicItems = []; int countInStream = 0; bool streamHasMention = false; @@ -156,7 +145,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 +152,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat lastUnreadId: messageIds.last, )); countInStream += countInTopic; + streamHasMention |= hasMention; } if (countInStream == 0) { continue; @@ -173,10 +162,34 @@ class _InboxPageState extends State with PerAccountStoreAwareStat final bLastUnreadId = b.lastUnreadId; return bLastUnreadId.compareTo(aLastUnreadId); }); - sections.add(_StreamSectionData(streamId, countInStream, streamHasMention, topicItems)); + + final uiChannelFolder = store.uiChannelFolder(streamId); + (channelSectionsByFolder[uiChannelFolder] ??= []) + .add(_InboxListItemChannelSection( + streamId: streamId, + count: countInStream, + hasMention: streamHasMention, + items: topicItems, + )); } - if (sections.isEmpty) { + 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(channelSections); + } + + if (items.isEmpty) { return PageBodyEmptyContentPlaceholder( // TODO(#315) add e.g. "You might be interested in recent conversations." header: zulipLocalizations.inboxEmptyPlaceholderHeader, @@ -185,43 +198,59 @@ 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(): - return _AllDmsSection( - data: section, - collapsed: allDmsCollapsed, - pageState: this, - ); - case _StreamSectionData(:var streamId): + final item = items[index]; + switch (item) { + case _InboxListItemFolderHeader(): + return InboxFolderHeaderItem(label: item.label); + 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: section, collapsed: collapsed, pageState: this); + return _StreamSection(data: item, collapsed: collapsed, pageState: this); } })); } } -sealed class _InboxSectionData { - const _InboxSectionData(); +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 _AllDmsSectionData extends _InboxSectionData { +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 _AllDmsSectionData(this.count, this.hasMention, this.items); } -class _StreamSectionData extends _InboxSectionData { +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 _StreamSectionData(this.streamId, this.count, this.hasMention, this.items); } @visibleForTesting @@ -239,97 +268,32 @@ class InboxChannelSectionTopicData { }); } -abstract class _HeaderItem extends StatelessWidget { - final bool collapsed; - final InboxPageState pageState; - final int count; - final bool hasMention; +@visibleForTesting +class InboxFolderHeaderItem extends StatelessWidget { + const InboxFolderHeaderItem({super.key, required this.label}); - /// A build context within the [_StreamSection] or [_AllDmsSection]. + /// The label for this folder header, not yet uppercased. /// - /// 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(); + /// The implementation will call [String.toUpperCase] on this. + final String label; @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), + 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( - fontSize: 17, - height: (20 / 17), - // TODO(design) check if this is the right variable - color: designVariables.labelMenuButton, + color: designVariables.folderText, + fontSize: 16, + height: 20 / 16, + letterSpacing: proportionalLetterSpacing(context, 0.02, baseFontSize: 16), ).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)), + label.toUpperCase())), ]))); return Semantics(container: true, @@ -337,71 +301,6 @@ abstract class _HeaderItem 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 _AllDmsSectionData 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({ @@ -468,51 +367,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 { @@ -522,7 +471,7 @@ class _StreamSection extends StatelessWidget { required this.pageState, }); - final _StreamSectionData data; + final _InboxListItemChannelSection data; final bool collapsed; final _InboxPageState pageState; 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)); } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 2f09036373..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) { @@ -126,52 +128,14 @@ void main() { ]); } - // 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()))); + void checkFolderHeader(String label) { + check(find.widgetWithText(InboxFolderHeaderItem, label.toUpperCase())) + .findsOne(); + } - if (findSectionContent != null) { - check(findSectionContent).findsExactly(expectCollapsed ? 0 : 1); - } - } + void checkNoFolderHeader(String label) { + check(find.widgetWithText(InboxFolderHeaderItem, label.toUpperCase())) + .findsNothing(); } void checkDm(Pattern expectLabelContains, { @@ -198,7 +162,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. /// @@ -290,11 +254,203 @@ 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, + ]); + }); + }); + + 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('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: [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('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: [pinnedChannel, unpinnedChannel], + subscriptions: [ + eg.subscription(pinnedChannel, pinToTop: true), + eg.subscription(unpinnedChannel, pinToTop: false), + ], + unreadMessages: [eg.streamMessage(stream: unpinnedChannel)]); + 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('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 { // Regression test for a bug where // DesignVariables.labelCounterUnread was used for the text instead of @@ -401,7 +557,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); }); @@ -411,7 +567,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); }); }); @@ -491,64 +647,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);