diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 5ca1208723..8060920af9 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -7,5 +7,13 @@ "wildcardMentionChannelDescription": "إخطار القناة", "wildcardMentionStreamDescription": "إخطار الدفق", "wildcardMentionAllDmDescription": "إخطار المستلمين", - "wildcardMentionTopicDescription": "إخطار الموضوع" + "wildcardMentionTopicDescription": "إخطار الموضوع", + "topicListButtonTooltip": "عرض المواضيع", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "بحث المواضيع", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" + } } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ec0de4dd8f..7029866f76 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -830,5 +830,13 @@ "zulipAppTitle": "Zulip", "@zulipAppTitle": { "description": "The name of Zulip. This should be either 'Zulip' or a transliteration." + }, + "topicListButtonTooltip": "Show topics", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "Search topics", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" } } diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb index a66aede69e..b24ff7a97e 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -16,5 +16,13 @@ "userRoleGuest": "ゲスト", "@userRoleGuest": {}, "userRoleUnknown": "不明", - "@userRoleUnknown": {} + "@userRoleUnknown": {}, + "topicListButtonTooltip": "トピックを表示", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "トピックを検索", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" + } } diff --git a/assets/l10n/app_nb.arb b/assets/l10n/app_nb.arb index fb72c02a9d..0a1b621065 100644 --- a/assets/l10n/app_nb.arb +++ b/assets/l10n/app_nb.arb @@ -10,5 +10,13 @@ "aboutPageOpenSourceLicenses": "Lisenser for åpen kildekode", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" + }, + "topicListButtonTooltip": "Vis emner", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "Søk emner", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" } } diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index c43e57e573..5358495361 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -886,5 +886,13 @@ "unpinnedSubscriptionsLabel": "Odpięte", "@unpinnedSubscriptionsLabel": { "description": "Label for the list of unpinned subscribed channels." + }, + "topicListButtonTooltip": "Pokaż tematy", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "Szukaj tematów", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" } } diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 5df7840006..a0c00b7778 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -758,5 +758,13 @@ "errorMessageNotSent": "Сообщение не отправлено", "@errorMessageNotSent": { "description": "Error message for compose box when a message could not be sent." + }, + "topicListButtonTooltip": "Показать темы", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "Поиск тем", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" } } diff --git a/assets/l10n/app_sk.arb b/assets/l10n/app_sk.arb index 087f459697..c1b8d05f97 100644 --- a/assets/l10n/app_sk.arb +++ b/assets/l10n/app_sk.arb @@ -546,5 +546,13 @@ "errorNotificationOpenTitle": "Nepodarilo sa otvoriť oznámenie", "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" + }, + "topicListButtonTooltip": "Zobraziť témy", + "@topicListButtonTooltip": { + "description": "Tooltip for button to navigate to a given channel's topic list" + }, + "searchTopicsPlaceholder": "Hľadať témy", + "@searchTopicsPlaceholder": { + "description": "Placeholder text for the search field in the topic list page" } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 1b339ce823..eebc0be22c 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1214,6 +1214,18 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'Zulip'** String get zulipAppTitle; + + /// Tooltip for button to navigate to a given channel's topic list + /// + /// In en, this message translates to: + /// **'Show topics'** + String get topicListButtonTooltip; + + /// Placeholder text for the search field in the topic list page + /// + /// In en, this message translates to: + /// **'Search topics'** + String get searchTopicsPlaceholder; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 890bf68596..1aef92c9f5 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'عرض المواضيع'; + + @override + String get searchTopicsPlaceholder => 'بحث المواضيع'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 72b7e9ad69..07175135df 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'Show topics'; + + @override + String get searchTopicsPlaceholder => 'Search topics'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 5bfacf60d9..b670cbca3d 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'トピックを表示'; + + @override + String get searchTopicsPlaceholder => 'トピックを検索'; } diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 9698d16c96..5a4b2ace92 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'Vis emner'; + + @override + String get searchTopicsPlaceholder => 'Søk emner'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 01bee756da..64ff5420e4 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'Pokaż tematy'; + + @override + String get searchTopicsPlaceholder => 'Szukaj tematów'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 2c01dddb61..cc1ce47d13 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'Показать темы'; + + @override + String get searchTopicsPlaceholder => 'Поиск тем'; } diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index a69cfc2d8a..38084682a7 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -648,4 +648,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get zulipAppTitle => 'Zulip'; + + @override + String get topicListButtonTooltip => 'Zobraziť témy'; + + @override + String get searchTopicsPlaceholder => 'Hľadať témy'; } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index c4ca22bce3..9b2ac94194 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -24,6 +24,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 { @@ -275,6 +276,13 @@ class _MessageListPageState extends State implements MessageLis onPressed: () => Navigator.push(context, MessageListPage.buildRoute(context: context, narrow: ChannelNarrow(streamId))))); + } else if (narrow case ChannelNarrow(:final streamId)) { + (actions ??= []).add(IconButton( + icon: const Icon(ZulipIcons.topic), + tooltip: zulipLocalizations.topicListButtonTooltip, + onPressed: () => Navigator.push(context, + TopicListPage.buildRoute(context: context, streamId: streamId, messageListView: model!)), + )); } // Insert a PageRoot here, to provide a context that can be used for diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart new file mode 100644 index 0000000000..bf00456493 --- /dev/null +++ b/lib/widgets/topic_list.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/message_list.dart'; +import '../model/narrow.dart'; +import 'app_bar.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'theme.dart'; +import 'unread_count_badge.dart'; + +class TopicListPage extends StatefulWidget { + const TopicListPage({ + super.key, + required this.streamId, + required this.messageListView, + }); + + final int streamId; + final MessageListView messageListView; + static AccountRoute buildRoute({ + int? accountId, + BuildContext? context, + required int streamId, + required MessageListView messageListView, + }) { + return MaterialAccountWidgetRoute( + accountId: accountId, + context: context, + page: TopicListPage(streamId: streamId, messageListView: messageListView), + ); + } + + @override + State createState() => _TopicListPageState(); +} + +class _TopicListPageState extends State with PerAccountStoreAwareStateMixin { + bool _isLoading = true; + List _topics = []; + List _filteredTopics = []; + MessageListView? _model; + + late TextEditingController _searchController; + bool _isSearching = false; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _searchController.addListener(_filterTopics); + } + + @override + void onNewStore() { + _model = widget.messageListView; + _model!.addListener(_onMessageListChanged); + _fetchTopics(); + } + + void _onMessageListChanged() { + _fetchTopics(); + } + + Future _fetchTopics() async { + final store = PerAccountStoreWidget.of(context); + final result = await getStreamTopics( + store.connection, + streamId: widget.streamId, + ); + + setState(() { + _topics = result.topics; + _filterTopics(); + _isLoading = false; + }); + } + + void _filterTopics() { + setState(() { + final query = _searchController.text.trim().toLowerCase(); + if (query.isEmpty) { + _filteredTopics = List.from(_topics); + } else { + _filteredTopics = _topics + .where((topic) => topic.name.displayName.toLowerCase().contains(query)) + .toList(); + } + + _filteredTopics.sort((a, b) => b.maxId.compareTo(a.maxId)); + }); + } + + void _toggleSearch() { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchController.clear(); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + _model?.removeListener(_onMessageListChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final stream = store.streams[widget.streamId]; + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + + return Scaffold( + appBar: _isSearching + ? AppBar( + backgroundColor: designVariables.background, + title: TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: zulipLocalizations.searchTopicsPlaceholder, + border: InputBorder.none, + ), + style: const TextStyle(fontSize: 16), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _toggleSearch, + ), + actions: [ + if (_searchController.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () => _searchController.clear(), + ), + ], + ) + : ZulipAppBar( + title: Text(stream?.name ?? 'Topics'), + backgroundColor: designVariables.background, + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: _toggleSearch, + tooltip: zulipLocalizations.searchTopicsPlaceholder, + ), + ], + ), + body: _buildBody(context), + ); + } + + Widget _buildBody(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_topics.isEmpty) { + return const Center( + child: Text('No topics in this channel'), + ); + } + + if (_filteredTopics.isEmpty && _searchController.text.isNotEmpty) { + return Center( + child: Text('No topics matching "${_searchController.text}"'), + ); + } + + return ListView.builder( + itemCount: _filteredTopics.length, + itemBuilder: (context, index) { + final topic = _filteredTopics[index]; + return _TopicItem( + streamId: widget.streamId, + topic: topic.name, + ); + }, + ); + } +} + +class _TopicItem extends StatelessWidget { + const _TopicItem({ + required this.streamId, + required this.topic, + }); + + final int streamId; + final TopicName topic; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final unreads = store.unreads; + final unreadCount = unreads.countInTopicNarrow(streamId, topic); + final hasMentions = unreads.mentions.any((id) { + final message = store.messages[id]; + return message is StreamMessage && + message.streamId == streamId && + message.topic == topic; + }); + final isMuted = !store.isTopicVisibleInStream(streamId, topic); + + final designVariables = DesignVariables.of(context); + final opacity = isMuted ? 0.55 : 1.0; + + return Material( + color: designVariables.background, + child: InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: TopicNarrow(streamId, topic))); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + Opacity( + opacity: opacity, + child: Icon( + ZulipIcons.topic, + size: 18, + color: designVariables.icon, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Opacity( + opacity: opacity, + child: Text( + topic.displayName, + style: TextStyle( + fontWeight: unreadCount > 0 ? FontWeight.bold : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (isMuted) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + ZulipIcons.mute, + size: 16, + color: designVariables.icon.withValues(alpha: 0.5), + ), + ), + if (unreadCount > 0) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: UnreadCountBadge( + count: unreadCount, + backgroundColor: null, + bold: hasMentions, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b5d3844c1a..e4cf9d3f6f 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -26,6 +26,8 @@ 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 'package:zulip/api/route/channels.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; @@ -1470,4 +1472,116 @@ void main() { ..status.equals(AnimationStatus.dismissed); }); }); -} + + group('TopicListPage', () { + testWidgets('navigates to TopicListPage on tapping topic list button in ChannelNarrow', (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), + streams: [channel], + messages: [message], + navObservers: [navObserver]); + + // Clear initial route + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); + + // Prepare API responses + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic 1'), + eg.getStreamTopicsEntry(name: 'topic 2'), + ]).toJson()); + + // Find and tap the topic list button + final topicListButton = find.byIcon(ZulipIcons.topic); + check(topicListButton).findsOne(); + await tester.tap(topicListButton); + + // Wait for navigation and page to fully load + await tester.pumpAndSettle(); + + // Verify navigation to TopicListPage with correct parameters + final route = pushedRoutes.single as MaterialAccountWidgetRoute; + final page = route.page as TopicListPage; + check(page.streamId).equals(channel.streamId); + check(page.messageListView.narrow).equals(ChannelNarrow(channel.streamId)); + check(find.text('topic 1')).findsOne(); + check(find.text('topic 2')).findsOne(); + }); + + testWidgets('live updates topic list when new message is added', (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), + streams: [channel], + messages: [message], + navObservers: [navObserver]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic 1'), + eg.getStreamTopicsEntry(name: 'topic 2'), + ]).toJson()); + + // Navigate to TopicListPage + await tester.tap(find.byIcon(ZulipIcons.topic)); + await tester.pumpAndSettle(); + + // Clear routes pushed during navigation to TopicListPage + pushedRoutes.clear(); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic 1'), + eg.getStreamTopicsEntry(name: 'topic 2'), + eg.getStreamTopicsEntry(name: 'topic 3'), + ]).toJson()); + + final newMessage = eg.streamMessage(stream: channel, topic: 'topic 3'); + await store.handleEvent(MessageEvent(id: 0, message: newMessage)); + await tester.pumpAndSettle(); + + check(find.text('topic 1')).findsOne(); + check(find.text('topic 2')).findsOne(); + check(find.text('topic 3')).findsOne(); + }); + + testWidgets('navigates to topic narrow on tapping topic in TopicListPage', (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), + streams: [channel], + messages: [message], + navObservers: [navObserver]); + + connection.prepare(json: GetStreamTopicsResult(topics: [ + eg.getStreamTopicsEntry(name: 'topic 1'), + eg.getStreamTopicsEntry(name: 'topic 2'), + eg.getStreamTopicsEntry(name: 'topic 3'), + ]).toJson()); + + await tester.tap(find.byIcon(ZulipIcons.topic)); + await tester.pumpAndSettle(); + pushedRoutes.clear(); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + + await tester.tap(find.text('topic 3')); + await tester.pumpAndSettle(); + + check(pushedRoutes).single.isA().page.isA() + .initNarrow.equals(TopicNarrow(channel.streamId, TopicName('topic 3'))); + }); + }); +} \ No newline at end of file