diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 77200c01eb..a51ed6b186 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -116,6 +116,10 @@ "@actionSheetOptionListOfTopics": { "description": "Label for navigating to a channel's topic-list page." }, + "actionSheetOptionChannelFeed": "Channel feed", + "@actionSheetOptionChannelFeed": { + "description": "Label for navigating to a channel's channel-feed page." + }, "actionSheetOptionUnsubscribe": "Unsubscribe", "@actionSheetOptionUnsubscribe": { "description": "Label in the channel action sheet for unsubscribing from the channel." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index d7e9c25f8f..bda5addcd7 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -275,7 +275,7 @@ abstract class ZulipLocalizations { /// **'To upload files, please grant Zulip additional permissions in Settings.'** String get permissionsDeniedReadExternalStorage; - /// Label in the channel context menu for subscribing to the channel. + /// Label in the channel action sheet for subscribing to the channel. /// /// In en, this message translates to: /// **'Subscribe'** @@ -305,7 +305,13 @@ abstract class ZulipLocalizations { /// **'List of topics'** String get actionSheetOptionListOfTopics; - /// Label in the channel context menu for unsubscribing from the channel. + /// Label for navigating to a channel's channel-feed page. + /// + /// In en, this message translates to: + /// **'Channel feed'** + String get actionSheetOptionChannelFeed; + + /// Label in the channel action sheet for unsubscribing from the channel. /// /// In en, this message translates to: /// **'Unsubscribe'** diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 5ff9981001..6d0bbf8a86 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 8d0826bac9..1f56cc575e 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Themenliste'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index bffdb9f9a9..ff1087d2f4 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index 5001c79eed..17fa68ce9c 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 25a5c4e999..4cf5f471e3 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -103,6 +103,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Elenco degli argomenti'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index e39ebe4377..cac6a6b087 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -101,6 +101,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'トピック一覧'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d08ca0eaf0..50f4fcde2d 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 9b856a6aae..eea24acaf5 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Lista wątków'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 848b02eefb..8ee4350cc8 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -104,6 +104,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Список тем'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 96e9e0c542..d4b6538447 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index bdb56cf44d..f045eb5f21 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Seznam tem'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 0c2f49363e..79929630e9 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -105,6 +105,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'Список тем'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index fdfd2966b5..ad0f0b5949 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -102,6 +102,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get actionSheetOptionListOfTopics => 'List of topics'; + @override + String get actionSheetOptionChannelFeed => 'Channel feed'; + @override String get actionSheetOptionUnsubscribe => 'Unsubscribe'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7adfb2ba24..b7f1e2b3cb 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -239,11 +239,18 @@ enum BottomSheetDismissButtonStyle { /// Show a sheet of actions you can take on a channel. /// /// Needs a [PageRoot] ancestor. +/// May or may not have a [MessageListPage] ancestor; +/// some callers are on that page and some aren't. void showChannelActionSheet(BuildContext context, { required int channelId, }) { final pageContext = PageRoot.contextOf(context); final store = PerAccountStoreWidget.of(pageContext); + final messageListPageState = MessageListPage.maybeAncestorOf(pageContext); + + final messageListPageNarrow = messageListPageState?.narrow; + final isOnChannelFeed = messageListPageNarrow is ChannelNarrow + && messageListPageNarrow.streamId == channelId; final unreadCount = store.unreads.countInChannelNarrow(channelId); final isSubscribed = store.subscriptions[channelId] != null; @@ -255,6 +262,8 @@ void showChannelActionSheet(BuildContext context, { if (unreadCount > 0) MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId), TopicListButton(pageContext: pageContext, channelId: channelId), + if (!isOnChannelFeed) + ChannelFeedButton(pageContext: pageContext, channelId: channelId), CopyChannelLinkButton(channelId: channelId, pageContext: pageContext) ], if (isSubscribed) @@ -355,6 +364,30 @@ class TopicListButton extends ActionSheetMenuItemButton { } } +class ChannelFeedButton extends ActionSheetMenuItemButton { + const ChannelFeedButton({ + super.key, + required this.channelId, + required super.pageContext, + }); + + final int channelId; + + @override + IconData get icon => ZulipIcons.message_feed; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionChannelFeed; + } + + @override + void onPressed() { + Navigator.push(pageContext, + MessageListPage.buildRoute(context: pageContext, narrow: ChannelNarrow(channelId))); + } +} + class CopyChannelLinkButton extends ActionSheetMenuItemButton { const CopyChannelLinkButton({ super.key, diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index fa6c8b9229..61dbbe1d4c 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -197,14 +197,30 @@ class MessageListPage extends StatefulWidget { /// /// Uses the inefficient [BuildContext.findAncestorStateOfType]; /// don't call this in a build method. - // If we do find ourselves wanting this in a build method, it won't be hard - // to enable that: we'd just need to add an [InheritedWidget] here. + /// + /// See also: + /// * [maybeAncestorOf], which returns null instead of throwing + /// when an ancestor [MessageListPageState] is not found. static MessageListPageState ancestorOf(BuildContext context) { - final state = context.findAncestorStateOfType<_MessageListPageState>(); + final state = maybeAncestorOf(context); assert(state != null, 'No MessageListPage ancestor'); return state!; } + /// The [MessageListPageState] above this context in the tree, if any. + /// + /// Uses the inefficient [BuildContext.findAncestorStateOfType]; + /// don't call this in a build method. + /// + /// See also: + /// * [ancestorOf], which throws instead of returning null + /// when an ancestor [MessageListPageState] is not found. + // If we do find ourselves wanting this in a build method, it won't be hard + // to enable that: we'd just need to add an [InheritedWidget] here. + static MessageListPageState? maybeAncestorOf(BuildContext context) { + return context.findAncestorStateOfType<_MessageListPageState>(); + } + final Narrow initNarrow; final int? initAnchorMessageId; // TODO(#1564) highlight target upon load diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 41e0462233..36e1f1ef78 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -33,6 +33,7 @@ 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/subscription_list.dart'; +import 'package:zulip/widgets/topic_list.dart'; import 'package:zulip/widgets/user.dart'; import '../api/fake_api.dart'; @@ -176,7 +177,9 @@ void main() { } Future showFromInbox(WidgetTester tester) async { + transitionDurationObserver = TransitionDurationObserver(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + navigatorObservers: [transitionDurationObserver], child: const HomePage())); await tester.pump(); check(find.byType(InboxPageBody)).findsOne(); @@ -197,16 +200,19 @@ void main() { await tester.pump(const Duration(milliseconds: 250)); } - Future showFromAppBar(WidgetTester tester, { + Future showFromMsglistAppBar(WidgetTester tester, { ZulipStream? channel, - List? messages, required Narrow narrow, }) async { channel ??= someChannel; - messages ??= [someMessage]; connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: messages).toJson()); + foundOldest: true, messages: []).toJson()); + if (narrow case ChannelNarrow()) { + // We auto-focus the topic input when there are no messages; + // this is for topic autocomplete. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, child: MessageListPage( @@ -236,6 +242,22 @@ void main() { await tester.pump(const Duration(milliseconds: 250)); } + Future showFromTopicListAppBar(WidgetTester tester) async { + final transitionDurationObserver = TransitionDurationObserver(); + + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await tester.pumpWidget(TestZulipApp( + navigatorObservers: [transitionDurationObserver], + accountId: eg.selfAccount.id, + child: TopicListPage(streamId: someChannel.streamId))); + await tester.pump(); + + await tester.longPress(find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(someChannel.name))); + await transitionDurationObserver.pumpPastTransition(tester); + } + final actionSheetFinder = find.byType(BottomSheet); Finder findButtonForLabel(String label) => find.descendant(of: actionSheetFinder, matching: find.text(label)); @@ -274,17 +296,17 @@ void main() { check(findButtonForLabel('Mark channel as read')).findsNothing(); }); - testWidgets('show from app bar in channel narrow', (tester) async { + testWidgets('show from message-list app bar in channel narrow', (tester) async { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkButtons(); }); - testWidgets('show from app bar in topic narrow', (tester) async { + testWidgets('show from message-list app bar in topic narrow', (tester) async { await prepare(); final narrow = eg.topicNarrow(someChannel.streamId, someTopic); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkButtons(); }); @@ -293,6 +315,12 @@ void main() { await showFromRecipientHeader(tester, message: someMessage); checkButtons(); }); + + testWidgets('show from topic-list app bar', (tester) async { + await prepare(); + await showFromTopicListAppBar(tester); + checkButtons(); + }); }); group('SubscribeButton', () { @@ -305,7 +333,7 @@ void main() { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); await store.removeSubscription(narrow.streamId); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkButton('Subscribe'); }); @@ -313,7 +341,7 @@ void main() { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); check(store.subscriptions[narrow.streamId]).isNotNull(); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkNoButton('Subscribe'); }); @@ -321,7 +349,7 @@ void main() { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); await store.removeSubscription(narrow.streamId); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); connection.prepare(json: {}); await tapButton(tester); @@ -384,7 +412,7 @@ void main() { testWidgets('TopicListButton', (tester) async { await prepare(); - await showFromAppBar(tester, + await showFromMsglistAppBar(tester, narrow: ChannelNarrow(someChannel.streamId)); connection.prepare(json: GetStreamTopicsResult(topics: [ @@ -395,6 +423,61 @@ void main() { check(find.text('some topic foo')).findsOne(); }); + group('ChannelFeedButton', () { + Future tapButtonAndPump(WidgetTester tester) async { + await tester.tap(findButtonForLabel('Channel feed')); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('from inbox: visible ', (tester) async { + await prepare(); + await showFromInbox(tester); + checkButton('Channel feed'); + }); + + testWidgets('from subscription list: visible ', (tester) async { + await prepare(); + await showFromInbox(tester); + checkButton('Channel feed'); + }); + + testWidgets('from recipient header in combined feed: visible ', (tester) async { + await prepare(); + await showFromRecipientHeader(tester); + checkButton('Channel feed'); + }); + + testWidgets('from app bar on topic list: visible ', (tester) async { + await prepare(); + await showFromTopicListAppBar(tester); + checkButton('Channel feed'); + }); + + testWidgets('from msglist app bar on channel feed: not visible ', (tester) async { + await prepare(); + await showFromMsglistAppBar(tester, narrow: ChannelNarrow(someChannel.streamId)); + checkNoButton('Channel feed'); + }); + + // (The channel action sheet isn't reached from a recipient header + // in the channel feed.) + + testWidgets('navigates to channel feed', (tester) async { + await prepare(); + await showFromInbox(tester); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: []).toJson()); + // for topic autocomplete + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await tapButtonAndPump(tester); + await transitionDurationObserver.pumpPastTransition(tester); + + final appBar = tester.widget(find.byType(MessageListAppBarTitle)) as MessageListAppBarTitle; + check(appBar.narrow).equals(ChannelNarrow(someChannel.streamId)); + }); + }); + group('CopyChannelLinkButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( @@ -412,7 +495,7 @@ void main() { testWidgets('copies channel link to clipboard', (tester) async { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); await tapCopyChannelLinkButton(tester); await tester.pump(Duration.zero); @@ -432,7 +515,7 @@ void main() { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); check(store.subscriptions[narrow.streamId]).isNotNull(); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkButton('Unsubscribe'); }); @@ -440,19 +523,17 @@ void main() { await prepare(); final narrow = ChannelNarrow(someChannel.streamId); await store.removeSubscription(narrow.streamId); - await showFromAppBar(tester, narrow: narrow); + await showFromMsglistAppBar(tester, narrow: narrow); checkNoButton('Unsubscribe'); }); testWidgets('smoke, public channel', (tester) async { final channel = eg.stream(inviteOnly: false); - final message = eg.streamMessage(stream: channel); await prepare(); await store.addStream(channel); await store.addSubscription(eg.subscription(channel)); final narrow = ChannelNarrow(channel.streamId); - await showFromAppBar(tester, - channel: channel, narrow: narrow, messages: [message]); + await showFromMsglistAppBar(tester, channel: channel, narrow: narrow); connection.prepare(json: {}); await tapButton(tester); @@ -470,13 +551,11 @@ void main() { testWidgets('smoke, private channel', (tester) async { final channel = eg.stream(inviteOnly: true); - final message = eg.streamMessage(stream: channel); await prepare(); await store.addStream(channel); await store.addSubscription(eg.subscription(channel)); final narrow = ChannelNarrow(channel.streamId); - await showFromAppBar(tester, - channel: channel, narrow: narrow, messages: [message]); + await showFromMsglistAppBar(tester, channel: channel, narrow: narrow); connection.takeRequests(); connection.prepare(json: {});