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();
+ });
+ });
+ });
+}