diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index df2c4ab947..d6e19a44ca 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000..880b02b674 --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/list.svg b/assets/icons/list.svg new file mode 100644 index 0000000000..c07afa80b3 --- /dev/null +++ b/assets/icons/list.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..321a3984c3 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -108,6 +108,10 @@ "@actionSheetOptionUnresolveTopic": { "description": "Label for the 'Mark as unresolved' button on the topic action sheet." }, + "actionSheetOptionTopicList": "Topic list", + "@actionSheetOptionTopicList": { + "description": "Label for a button in the channel action sheet that opens the list of topics in the channel" + }, "errorResolveTopicFailedTitle": "Failed to mark topic as resolved", "@errorResolveTopicFailedTitle": { "description": "Error title when marking a topic as resolved failed." @@ -710,6 +714,10 @@ "@channelFeedButtonTooltip": { "description": "Tooltip for button to navigate to a given channel's feed" }, + "topicListButtonTooltip": "Topic list", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to topic list page." + }, "notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}", "@notifGroupDmConversationLabel": { "description": "Label for a group DM conversation notification.", @@ -868,6 +876,14 @@ "@emojiPickerSearchEmoji": { "description": "Hint text for the emoji picker search text field." }, + "errorFetchingTopics": "Error fetching topics", + "@errorFetchingTopics": { + "description": "Error title when fetching the topics failed." + }, + "noTopicsInChannel": "No topics in the channel", + "@noTopicsInChannel": { + "description": "Text to show when a channel has no topics." + }, "noEarlierMessages": "No earlier messages", "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index d09393e774..ab306a7ecc 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -267,6 +267,12 @@ abstract class ZulipLocalizations { /// **'Mark as unresolved'** String get actionSheetOptionUnresolveTopic; + /// Label for a button in the channel action sheet that opens the list of topics in the channel + /// + /// In en, this message translates to: + /// **'Topic list'** + String get actionSheetOptionTopicList; + /// Error title when marking a topic as resolved failed. /// /// In en, this message translates to: @@ -1047,6 +1053,12 @@ abstract class ZulipLocalizations { /// **'Channel feed'** String get channelFeedButtonTooltip; + /// Tooltip for button to navigate to topic list page. + /// + /// In en, this message translates to: + /// **'Topic list'** + String get topicListButtonTooltip; + /// Label for a group DM conversation notification. /// /// In en, this message translates to: @@ -1263,6 +1275,18 @@ abstract class ZulipLocalizations { /// **'Search emoji'** String get emojiPickerSearchEmoji; + /// Error title when fetching the topics failed. + /// + /// In en, this message translates to: + /// **'Error fetching topics'** + String get errorFetchingTopics; + + /// Text to show when a channel has no topics. + /// + /// In en, this message translates to: + /// **'No topics in the channel'** + String get noTopicsInChannel; + /// Text to show at the start of a message list if there are no earlier messages. /// /// 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 c2478f4613..0159b8351d 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Channel feed'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 289ba33af2..1be876f1b8 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Channel feed'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 00537f73a2..efd2bee1aa 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Channel feed'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3c063e91da..15cdca1dbb 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Channel feed'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Search emoji'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 64705fbe02..035b5bfbef 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Oznacz brak rozwiązania'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Nie udało się oznaczyć jako rozwiązany'; @@ -553,6 +556,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Strumień kanału'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Szukaj emoji'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'Brak historii'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 911fc281b2..fa00e183b0 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Лента канала'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Поиск эмодзи'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0cb42c3a37..5371c69e12 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -91,6 +91,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionUnresolveTopic => 'Mark as unresolved'; + @override + String get actionSheetOptionTopicList => 'Topic list'; + @override String get errorResolveTopicFailedTitle => 'Failed to mark topic as resolved'; @@ -553,6 +556,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get channelFeedButtonTooltip => 'Channel feed'; + @override + String get topicListButtonTooltip => 'Topic list'; + @override String notifGroupDmConversationLabel(String senderFullName, int numOthers) { String _temp0 = intl.Intl.pluralLogic( @@ -675,6 +681,12 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get emojiPickerSearchEmoji => 'Hľadať emotikon'; + @override + String get errorFetchingTopics => 'Error fetching topics'; + + @override + String get noTopicsInChannel => 'No topics in the channel'; + @override String get noEarlierMessages => 'No earlier messages'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0594bd27d2..69117e2ef0 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -28,6 +28,7 @@ import 'page.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; void _showActionSheet( BuildContext context, { @@ -175,23 +176,46 @@ void showChannelActionSheet(BuildContext context, { final store = PerAccountStoreWidget.of(pageContext); final optionButtons = []; + + optionButtons.add( + TopicListButton(pageContext: pageContext, channelId: channelId)); + final unreadCount = store.unreads.countInChannelNarrow(channelId); if (unreadCount > 0) { optionButtons.add( MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId)); } - if (optionButtons.isEmpty) { - // TODO(a11y): This case makes a no-op gesture handler; as a consequence, - // we're presenting some UI (to people who use screen-reader software) as - // though it offers a gesture interaction that it doesn't meaningfully - // offer, which is confusing. The solution here is probably to remove this - // is-empty case by having at least one button that's always present, - // such as "copy link to channel". - return; - } + _showActionSheet(pageContext, optionButtons: optionButtons); } +class TopicListButton extends ActionSheetMenuItemButton { + const TopicListButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.list; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionTopicList; + } + + @override + void onPressed() { + Navigator.push(pageContext, + TopicListPage.buildRoute( + context: pageContext, + streamId: channelId, + )); + } +} + class MarkChannelAsReadButton extends ActionSheetMenuItemButton { const MarkChannelAsReadButton({ super.key, diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index ff9b2f7794..66026870d8 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -51,101 +51,107 @@ abstract final class ZulipIcons { /// The Zulip custom icon "check_remove". static const IconData check_remove = IconData(0xf109, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_down". + static const IconData chevron_down = IconData(0xf10a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "chevron_right". - static const IconData chevron_right = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData chevron_right = IconData(0xf10b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "clock". - static const IconData clock = IconData(0xf10b, fontFamily: "Zulip Icons"); + static const IconData clock = IconData(0xf10c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "contacts". - static const IconData contacts = IconData(0xf10c, fontFamily: "Zulip Icons"); + static const IconData contacts = IconData(0xf10d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "copy". - static const IconData copy = IconData(0xf10d, fontFamily: "Zulip Icons"); + static const IconData copy = IconData(0xf10e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10e, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf10f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf110, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf119, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "list". + static const IconData list = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf120, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1e95a1be49..9546d6e7dd 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -27,6 +27,7 @@ import 'sticky_header.dart'; import 'store.dart'; import 'text.dart'; import 'theme.dart'; +import 'topic_list.dart'; /// Message-list styles that differ between light and dark themes. class MessageListTheme extends ThemeExtension { @@ -252,6 +253,29 @@ class _MessageListPageState extends State implements MessageLis narrow: ChannelNarrow(streamId))))); } + if (narrow case ChannelNarrow(:final streamId)) { + final designVariables = DesignVariables.of(context); + (actions ??= []).add(TextButton( + child: Text('TOPICS', + style: TextStyle( + color: designVariables.icon, + fontWeight: FontWeight.w600, + fontSize: 18, + height: 19/18, + letterSpacing: 0, + textBaseline: TextBaseline.alphabetic, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + onPressed: () => + Navigator.push(context, + TopicListPage.buildRoute( + context: context, + streamId: streamId, + )) + )); + } + // Insert a PageRoot here, to provide a context that can be used for // MessageListPage.ancestorOf. return PageRoot(child: Scaffold( diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index eea9677045..2a56c1131e 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -135,6 +135,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15), bgMenuButtonActive: Colors.black.withValues(alpha: 0.05), bgMenuButtonSelected: Colors.white, + bgMessageRegular: const Color(0xffffffff), bgTopBar: const Color(0xfff5f5f5), borderBar: Colors.black.withValues(alpha: 0.2), borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2), @@ -192,6 +193,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37), bgMenuButtonActive: Colors.black.withValues(alpha: 0.2), bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25), + bgMessageRegular: const Color(0xff1d1d1d), bgTopBar: const Color(0xff242424), borderBar: const Color(0xffffffff).withValues(alpha: 0.1), borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1), @@ -257,6 +259,7 @@ class DesignVariables extends ThemeExtension { required this.bgCounterUnread, required this.bgMenuButtonActive, required this.bgMenuButtonSelected, + required this.bgMessageRegular, required this.bgTopBar, required this.borderBar, required this.borderMenuButtonSelected, @@ -323,6 +326,7 @@ class DesignVariables extends ThemeExtension { final Color bgCounterUnread; final Color bgMenuButtonActive; final Color bgMenuButtonSelected; + final Color bgMessageRegular; final Color bgTopBar; final Color borderBar; final Color borderMenuButtonSelected; @@ -384,6 +388,7 @@ class DesignVariables extends ThemeExtension { Color? bgCounterUnread, Color? bgMenuButtonActive, Color? bgMenuButtonSelected, + Color? bgMessageRegular, Color? bgTopBar, Color? borderBar, Color? borderMenuButtonSelected, @@ -440,6 +445,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread, bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive, bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected, + bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular, bgTopBar: bgTopBar ?? this.bgTopBar, borderBar: borderBar ?? this.borderBar, borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected, @@ -503,6 +509,7 @@ class DesignVariables extends ThemeExtension { bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!, bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!, bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!, + bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!, bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!, borderBar: Color.lerp(borderBar, other.borderBar, t)!, borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart new file mode 100644 index 0000000000..59c4c87177 --- /dev/null +++ b/lib/widgets/topic_list.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; + +import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'action_sheet.dart'; +import 'app_bar.dart'; +import 'channel_colors.dart'; +import 'dialog.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; +import 'unread_count_badge.dart'; + +class TopicListPage extends StatelessWidget { + const TopicListPage({super.key, required this.streamId}); + + static AccountRoute buildRoute({int? accountId, BuildContext? context, + required int streamId}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, + page: TopicListPage(streamId: streamId)); + } + + final int streamId; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + return PageRoot(child: Scaffold( + appBar: ZulipAppBar( + buildTitle: (willCenterTitle) => _TopicListAppBarTitle( + streamId: streamId, + willCenterTitle: willCenterTitle + ), + backgroundColor: () { + final store = PerAccountStoreWidget.of(context); + final subscription = store.subscriptions[streamId]; + if (subscription == null) return Colors.transparent; + + return Theme.of(context).brightness == Brightness.light + ? ChannelColorSwatches.light.forBaseColor(subscription.color).barBackground + : ChannelColorSwatches.dark.forBaseColor(subscription.color).barBackground; + }(), + shape: Border(bottom: BorderSide( + color: designVariables.borderBar, + width: 1, + )), + actions: [ + IconButton( + icon: const Icon(ZulipIcons.message_feed), + onPressed: () { + Navigator.push(context, MessageListPage.buildRoute( + context: context, + narrow: ChannelNarrow(streamId), + )); + }, + ), + ], + ), + body: TopicListPageBody(streamId: streamId), + )); + } +} + +class _TopicListAppBarTitle extends StatelessWidget { + const _TopicListAppBarTitle({ + required this.streamId, + required this.willCenterTitle, + }); + + final int streamId; + final bool willCenterTitle; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final stream = store.streams[streamId]; + final subscription = store.subscriptions[streamId]; + final designVariables = DesignVariables.of(context); + + final iconData = subscription != null ? iconDataForStream(subscription) : null; + final alignment = willCenterTitle + ? Alignment.center + : AlignmentDirectional.centerStart; + + return SizedBox( + width: double.infinity, + child: Align(alignment: alignment, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(size: 16, iconData), + const SizedBox(width: 4), + Flexible(child: Row( + children: [ + Text( + stream?.name ?? zulipLocalizations.unknownChannelName, + style: recipientHeaderTextStyle(context), + ), + const SizedBox(width: 10), + Icon(ZulipIcons.chevron_down, size: 10, color: designVariables.icon), + ], + )), + ])) + ); + } +} + +class TopicListPageBody extends StatefulWidget { + const TopicListPageBody({super.key, required this.streamId}); + + final int streamId; + + @override + State createState() => _TopicListPageBodyState(); +} + +class _TopicListPageBodyState extends State + with PerAccountStoreAwareStateMixin { + bool _isLoading = true; + List? _topics; + Unreads? _unreadsModel; + + @override + void onNewStore() { + _unreadsModel?.removeListener(_modelChanged); + _unreadsModel = PerAccountStoreWidget.of(context).unreads + ..addListener(_modelChanged); + _fetchTopics(); + } + + Future _fetchTopics() async { + setState(() { + _isLoading = true; + }); + + try { + final store = PerAccountStoreWidget.of(context); + final response = await getStreamTopics(store.connection, streamId: widget.streamId); + + if (!mounted) return; + + final sortedTopics = response.topics; + sortedTopics.sort((a, b) => b.maxId.compareTo(a.maxId)); + + setState(() { + _topics = sortedTopics; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog( + context: context, + title: zulipLocalizations.errorFetchingTopics, + message: e.toString()); + } + } + + void _modelChanged() { + setState(() { + // The actual state lives in the models. + // This method was called because they just changed. + }); + } + + @override + void dispose() { + _unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_topics == null || _topics!.isEmpty) { + return Center( + child: Text(ZulipLocalizations.of(context).noTopicsInChannel, + style: TextStyle( + color: DesignVariables.of(context).labelMenuButton, + fontSize: 16, + )), + ); + } + + return SafeArea( + child: ListView.builder( + itemCount: _topics!.length, + itemBuilder: (context, index) { + final topic = _topics![index]; + return TopicListItem( + streamId: widget.streamId, + topic: topic, + ); + }, + ), + ); + } +} + +class TopicListItem extends StatelessWidget { + const TopicListItem({ + super.key, + required this.streamId, + required this.topic, + }); + + final int streamId; + final GetStreamTopicsEntry topic; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + final unreads = store.unreads.countInNarrow( + TopicNarrow(streamId, topic.name) + ); + final hasUnreads = unreads > 0; + + bool hasMention = false; + if (hasUnreads) { + final unreadMessageIds = store.unreads.streams[streamId]?[topic.name] ?? []; + hasMention = unreadMessageIds.any( + (messageId) => store.unreads.mentions.contains(messageId)); + } + + final visibilityIcon = iconDataForTopicVisibilityPolicy( + store.topicVisibilityPolicy(streamId, topic.name)); + + return Material( + color: designVariables.bgMessageRegular, + child: InkWell( + onTap: () { + Navigator.push(context, MessageListPage.buildRoute( + context: context, + narrow: TopicNarrow(streamId, topic.name), + )); + }, + onLongPress: () { + showTopicActionSheet( + context, + channelId: streamId, + topic: topic.name, + someMessageIdInTopic: topic.maxId, + ); + }, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(width: 28), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: SizedBox( + height: 16, + width: 16, + child: topic.name.isResolved + ? Opacity( + opacity: 0.4, + child: Icon( + ZulipIcons.check, + size: 16, + color: designVariables.textMessage, + ), + ) + : null, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(topic.name.unresolve().toString(), + style: TextStyle( + fontSize: 17, + height: (20 / 17), + color: designVariables.textMessage, + ).merge(weightVariableTextStyle(context, wght: 400)), + ), + ), + const SizedBox(width: 8), + if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), + if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), + if (hasUnreads) Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: UnreadCountBadge(count: unreads, + backgroundColor: designVariables.bgCounterUnread, bold: true), + ), + const SizedBox(width: 8), + ]))), + ]))); + } +} + +class _IconMarker extends StatelessWidget { + const _IconMarker({required this.icon}); + + final IconData icon; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Opacity(opacity: 0.4, + child: Icon(icon, size: 16, color: designVariables.textMessage), + ), + ); + } +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 8aeeec4eed..806acaa4c8 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -31,7 +31,9 @@ import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -40,9 +42,11 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_navigation.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; +import 'page_checks.dart'; import 'test_app.dart'; late PerAccountStore store; @@ -154,6 +158,7 @@ void main() { ZulipStream? channel, List? messages, required Narrow narrow, + List? navObservers, }) async { channel ??= someChannel; messages ??= [someMessage]; @@ -162,6 +167,7 @@ void main() { foundOldest: true, messages: messages).toJson()); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, + navigatorObservers: navObservers ?? [], child: MessageListPage( initNarrow: narrow))); await tester.pumpAndSettle(); @@ -201,6 +207,7 @@ void main() { void checkButtons() { check(actionSheetFinder).findsOne(); checkButton('Mark channel as read'); + checkButton('Topic list'); } testWidgets('show from inbox', (tester) async { @@ -218,7 +225,7 @@ void main() { testWidgets('show with no unread messages', (tester) async { await prepare(hasUnreadMessages: false); await showFromSubscriptionList(tester); - check(actionSheetFinder).findsNothing(); + check(findButtonForLabel('Mark channel as read')).findsNothing(); }); testWidgets('show from app bar in channel narrow', (tester) async { @@ -287,6 +294,31 @@ void main() { expectedTitle: "Mark as read failed"); }); }); + + group('TopicListButton', () { + testWidgets('navigates to topic list page', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await prepare(); + await showFromAppBar(tester, + narrow: ChannelNarrow(someChannel.streamId), + navObservers: [navObserver]); + + pushedRoutes.clear(); + await tester.tap(findButtonForLabel('Topic list')); + await tester.pumpAndSettle(); + + final topicListRoute = pushedRoutes + .whereType>() + .single; + check(topicListRoute) + .page.isA() + .has((page) => page.streamId, 'streamId') + .equals(someChannel.streamId); + }); + }); }); group('topic action sheet', () { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 53ad1334ef..8f31fefb40 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -26,6 +26,7 @@ import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/channel_colors.dart'; +import 'package:zulip/widgets/topic_list.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -226,6 +227,32 @@ void main() { .equals(ChannelNarrow(channel.streamId)); }); + testWidgets('has topic list button for topic list page', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final message = eg.streamMessage(stream: channel); + await setupMessageListPage(tester, narrow: ChannelNarrow(channel.streamId), + navObservers: [navObserver], + streams: [channel], + messages: [message]); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + await tester.tap(find.text('TOPICS')); + await tester.pumpAndSettle(); + + final topicListRoute = pushedRoutes + .whereType>() + .single; + check(topicListRoute) + .page.isA() + .has((page) => page.streamId, 'streamId') + .equals(channel.streamId); + }); + testWidgets('show topic visibility policy for topic narrows', (tester) async { final channel = eg.stream(); const topic = 'topic'; diff --git a/test/widgets/topic_list_test.dart b/test/widgets/topic_list_test.dart new file mode 100644 index 0000000000..8581ea1b07 --- /dev/null +++ b/test/widgets/topic_list_test.dart @@ -0,0 +1,375 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/topic_list.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; + +import '../api/fake_api.dart'; +import '../model/binding.dart'; +import '../example_data.dart' as eg; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'message_list_checks.dart'; +import 'page_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + Widget? findRowByLabel(WidgetTester tester, String label) { + final textWidgets = tester.widgetList(find.text(label)); + if (textWidgets.isEmpty) return null; + + final rows = tester.widgetList( + find.descendant( + of: find.byType(TopicListItem), + matching: find.byType(Row), + )); + + for (final row in rows) { + if (tester.widgetList(find.descendant( + of: find.byWidget(row), + matching: find.text(label), + )).isNotEmpty) { + return row; + } + } + return null; + } + + Future setupTopicListPage(WidgetTester tester, { + required List topics, + ZulipStream? stream, + Subscription? subscription, + UnreadMessagesSnapshot? unreadMsgs, + List navObservers = const [], + }) async { + addTearDown(testBinding.reset); + final effectiveStream = stream ?? eg.stream(); + final initialSnapshot = eg.initialSnapshot( + streams: [effectiveStream], + subscriptions: subscription != null ? [subscription] : null, + unreadMsgs: unreadMsgs, + ); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson()); + + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + navigatorObservers: navObservers, + child: TopicListPage(streamId: effectiveStream.streamId))); + } + + group('TopicListPage', () { + testWidgets('shows loading indicator initially', (tester) async { + await setupTopicListPage(tester, topics: []); + check(find.byType(CircularProgressIndicator)).findsOne(); + }); + + testWidgets('shows empty state when no topics', (tester) async { + await setupTopicListPage(tester, topics: []); + await tester.pumpAndSettle(); + check(find.text('No topics in the channel')).findsOne(); + }); + + testWidgets('shows topics sorted by maxId', (tester) async { + final topics = [ + eg.getStreamTopicsEntry(maxId: 1, name: 'Topic A'), + eg.getStreamTopicsEntry(maxId: 3, name: 'Topic B'), + eg.getStreamTopicsEntry(maxId: 2, name: 'Topic C'), + ]; + await setupTopicListPage(tester, topics: topics); + await tester.pumpAndSettle(); + + final topicWidgets = tester.widgetList(find.byType(Text)) + .where((widget) => ['Topic A', 'Topic B', 'Topic C'] + .contains(widget.data)).toList(); + + check(topicWidgets.map((w) => w.data)) + .deepEquals(['Topic B', 'Topic C', 'Topic A']); + }); + + testWidgets('navigates to message list on topic tap', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final stream = eg.stream(); + final topic = eg.getStreamTopicsEntry(name: 'test topic'); + final message = eg.streamMessage(stream: stream, topic: 'test topic'); + + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream), + navObservers: [navObserver]); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); + + await tester.pumpAndSettle(); + pushedRoutes.clear(); + + await tester.tap(find.text('test topic')); + await tester.pumpAndSettle(); + + check(pushedRoutes).single + .isA() + .page.isA() + .initNarrow.equals(TopicNarrow(stream.streamId, topic.name)); + }); + + testWidgets('shows unread count badge', (tester) async { + final stream = eg.stream(); + final topic = eg.getStreamTopicsEntry(name: 'test topic'); + + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + unreadMsgs: eg.unreadMsgs(channels: [ + eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [1, 2, 3]), + ])); + await tester.pumpAndSettle(); + + check(find.text('3')).findsOne(); + }); + + testWidgets('shows channel name in app bar', (tester) async { + final stream = eg.stream(name: 'Test Stream'); + await setupTopicListPage(tester, + stream: stream, + topics: [], + subscription: eg.subscription(stream)); + await tester.pumpAndSettle(); + + check(find.text('Test Stream')).findsOne(); + }); + + testWidgets('shows channel feed button in app bar', (tester) async { + final stream = eg.stream(); + + await setupTopicListPage(tester, + stream: stream, + topics: [], + subscription: eg.subscription(stream)); + await tester.pumpAndSettle(); + + check(find.byIcon(ZulipIcons.message_feed)).findsOne(); + }); + + testWidgets('navigates to channel narrow on channel feed button tap', (tester) async { + final pushedRoutes = >[]; + final navObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + + await setupTopicListPage(tester, + stream: stream, + topics: [], + subscription: eg.subscription(stream), + navObservers: [navObserver]); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUser(eg.selfUser); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, + messages: [message], + ).toJson()); + + await tester.pumpAndSettle(); + pushedRoutes.clear(); + + await tester.tap(find.byIcon(ZulipIcons.message_feed)); + await tester.pumpAndSettle(); + + check(pushedRoutes).single + .isA() + .page.isA() + .initNarrow.equals(ChannelNarrow(stream.streamId)); + }); + + bool hasIcon(WidgetTester tester, { + required Widget? parent, + required IconData icon, + }) { + check(parent).isNotNull(); + return tester.widgetList(find.descendant( + of: find.byWidget(parent!), + matching: find.byIcon(icon), + )).isNotEmpty; + } + + group('mentions', () { + final stream = eg.stream(); + final topic = eg.getStreamTopicsEntry(name: 'test topic'); + + testWidgets('topic with a mention', (tester) async { + final message = eg.streamMessage( + stream: stream, + topic: 'test topic', + flags: [MessageFlag.mentioned]); + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream), + unreadMsgs: eg.unreadMsgs( + mentions: [message.id], + channels: [eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [message.id]), + ])); + await tester.pumpAndSettle(); + + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.at_sign)).isTrue(); + }); + + testWidgets('topic without a mention', (tester) async { + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream), + unreadMsgs: eg.unreadMsgs(channels: [ + eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [1]), + ])); + await tester.pumpAndSettle(); + + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.at_sign)).isFalse(); + }); + }); + + group('topic visibility', () { + final stream = eg.stream(); + final topic = eg.getStreamTopicsEntry(name: 'test topic'); + + testWidgets('followed', (tester) async { + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream), + unreadMsgs: eg.unreadMsgs(channels: [ + eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [1]), + ])); + await tester.pumpAndSettle(); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.followed); + await tester.pump(); + + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.follow)).isTrue(); + }); + + testWidgets('followed and mentioned', (tester) async { + final message = eg.streamMessage( + stream: stream, + topic: 'test topic', + flags: [MessageFlag.mentioned]); + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream), + unreadMsgs: eg.unreadMsgs( + mentions: [message.id], + channels: [eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [message.id]), + ])); + await tester.pumpAndSettle(); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.followed); + await tester.pump(); + + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.follow)).isTrue(); + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.at_sign)).isTrue(); + }); + + testWidgets('unmuted', (tester) async { + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + subscription: eg.subscription(stream, isMuted: true), + unreadMsgs: eg.unreadMsgs(channels: [ + eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [1]), + ])); + await tester.pumpAndSettle(); + + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUserTopic(stream, 'test topic', UserTopicVisibilityPolicy.unmuted); + await tester.pump(); + + check(hasIcon(tester, + parent: findRowByLabel(tester, topic.name.displayName), + icon: ZulipIcons.unmute)).isTrue(); + }); + }); + + group('unread badge', () { + final stream = eg.stream(); + final topic = eg.getStreamTopicsEntry(name: 'test topic'); + + testWidgets('shows unread count badge with correct count', (tester) async { + await setupTopicListPage(tester, + stream: stream, + topics: [topic], + unreadMsgs: eg.unreadMsgs(channels: [ + eg.unreadChannelMsgs( + streamId: stream.streamId, + topic: 'test topic', + unreadMessageIds: [1, 2, 3, 4, 5]), + ])); + await tester.pumpAndSettle(); + + check(find.text('5')).findsOne(); + }); + + testWidgets('does not show unread badge when no unreads', (tester) async { + await setupTopicListPage(tester, + stream: stream, + topics: [topic]); + await tester.pumpAndSettle(); + + check(find.byType(UnreadCountBadge)).findsNothing(); + }); + }); + }); +}