From 0b6cdacf4d763ac90877c6cc2b5607c8fda4d6c4 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 24 Oct 2024 18:55:08 -0400 Subject: [PATCH] action_sheet: Support muting/unmuting/following topics Currently, we don't have buttons, like "resolve topic", other than the ones added here. The switch statements follow the layout of the legacy app implementation. See also: https://github.com/zulip/zulip-mobile/blob/715d60a5e87fe37032bce58bd72edb99208e15be/src/action-sheets/index.js#L656-L753 Fixes: #348 Signed-off-by: Zixuan James Li --- assets/l10n/app_en.arb | 32 ++ lib/generated/l10n/zulip_localizations.dart | 48 +++ .../l10n/zulip_localizations_ar.dart | 24 ++ .../l10n/zulip_localizations_en.dart | 24 ++ .../l10n/zulip_localizations_ja.dart | 24 ++ lib/widgets/action_sheet.dart | 194 ++++++++++++ lib/widgets/inbox.dart | 3 + lib/widgets/message_list.dart | 15 +- test/widgets/action_sheet_test.dart | 281 ++++++++++++++++++ 9 files changed, 644 insertions(+), 1 deletion(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index d1a08ded770..71fd315b252 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -59,6 +59,22 @@ "@permissionsDeniedReadExternalStorage": { "description": "Message for dialog asking the user to grant permissions for external storage read access." }, + "actionSheetOptionMuteTopic": "Mute topic", + "@actionSheetOptionMuteTopic": { + "description": "Label for muting a topic on action sheet." + }, + "actionSheetOptionUnmuteTopic": "Unmute topic", + "@actionSheetOptionUnmuteTopic": { + "description": "Label for unmuting a topic on action sheet." + }, + "actionSheetOptionFollowTopic": "Follow topic", + "@actionSheetOptionFollowTopic": { + "description": "Label for following a topic on action sheet." + }, + "actionSheetOptionUnfollowTopic": "Unfollow topic", + "@actionSheetOptionUnfollowTopic": { + "description": "Label for unfollowing a topic on action sheet." + }, "actionSheetOptionCopyMessageText": "Copy message text", "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." @@ -201,6 +217,22 @@ "event": {"type": "String", "example": "UpdateMessageEvent(id: 123, messageIds: [2345, 3456], newTopic: 'dinner')"} } }, + "errorMuteTopicFailed": "Failed to mute topic", + "@errorMuteTopicFailed": { + "description": "Error message when muting a topic failed." + }, + "errorUnmuteTopicFailed": "Failed to unmute topic", + "@errorUnmuteTopicFailed": { + "description": "Error message when unmuting a topic failed." + }, + "errorFollowTopicFailed": "Failed to follow topic", + "@errorFollowTopicFailed": { + "description": "Error message when following a topic failed." + }, + "errorUnfollowTopicFailed": "Failed to unfollow topic", + "@errorUnfollowTopicFailed": { + "description": "Error message when unfollowing a topic failed." + }, "errorSharingFailed": "Sharing failed", "@errorSharingFailed": { "description": "Error message when sharing a message failed." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 74856a64e0d..54e3d1f3a5b 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -187,6 +187,30 @@ abstract class ZulipLocalizations { /// **'To upload files, please grant Zulip additional permissions in Settings.'** String get permissionsDeniedReadExternalStorage; + /// Label for muting a topic on action sheet. + /// + /// In en, this message translates to: + /// **'Mute topic'** + String get actionSheetOptionMuteTopic; + + /// Label for unmuting a topic on action sheet. + /// + /// In en, this message translates to: + /// **'Unmute topic'** + String get actionSheetOptionUnmuteTopic; + + /// Label for following a topic on action sheet. + /// + /// In en, this message translates to: + /// **'Follow topic'** + String get actionSheetOptionFollowTopic; + + /// Label for unfollowing a topic on action sheet. + /// + /// In en, this message translates to: + /// **'Unfollow topic'** + String get actionSheetOptionUnfollowTopic; + /// Label for copy message text button on action sheet. /// /// In en, this message translates to: @@ -355,6 +379,30 @@ abstract class ZulipLocalizations { /// **'Error handling a Zulip event from {serverUrl}; will retry.\n\nError: {error}\n\nEvent: {event}'** String errorHandlingEventDetails(String serverUrl, String error, String event); + /// Error message when muting a topic failed. + /// + /// In en, this message translates to: + /// **'Failed to mute topic'** + String get errorMuteTopicFailed; + + /// Error message when unmuting a topic failed. + /// + /// In en, this message translates to: + /// **'Failed to unmute topic'** + String get errorUnmuteTopicFailed; + + /// Error message when following a topic failed. + /// + /// In en, this message translates to: + /// **'Failed to follow topic'** + String get errorFollowTopicFailed; + + /// Error message when unfollowing a topic failed. + /// + /// In en, this message translates to: + /// **'Failed to unfollow topic'** + String get errorUnfollowTopicFailed; + /// Error message when sharing a message failed. /// /// 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 09e4ae82e92..0c1be819daa 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -53,6 +53,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @override + String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -165,6 +177,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + @override String get errorSharingFailed => 'Sharing failed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index da5fad3552a..5bf929e4f29 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -53,6 +53,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @override + String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -165,6 +177,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + @override String get errorSharingFailed => 'Sharing failed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 693f0a36f9f..418184df360 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -53,6 +53,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get permissionsDeniedReadExternalStorage => 'To upload files, please grant Zulip additional permissions in Settings.'; + @override + String get actionSheetOptionMuteTopic => 'Mute topic'; + + @override + String get actionSheetOptionUnmuteTopic => 'Unmute topic'; + + @override + String get actionSheetOptionFollowTopic => 'Follow topic'; + + @override + String get actionSheetOptionUnfollowTopic => 'Unfollow topic'; + @override String get actionSheetOptionCopyMessageText => 'Copy message text'; @@ -165,6 +177,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { return 'Error handling a Zulip event from $serverUrl; will retry.\n\nError: $error\n\nEvent: $event'; } + @override + String get errorMuteTopicFailed => 'Failed to mute topic'; + + @override + String get errorUnmuteTopicFailed => 'Failed to unmute topic'; + + @override + String get errorFollowTopicFailed => 'Failed to follow topic'; + + @override + String get errorUnfollowTopicFailed => 'Failed to unfollow topic'; + @override String get errorSharingFailed => 'Sharing failed'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0a91e4bfaca..d16d37fd321 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; +import '../api/route/channels.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/internal_link.dart'; @@ -141,6 +142,199 @@ class ActionSheetCancelButton extends StatelessWidget { } } +/// Show a sheet of actions you can take on a topic. +void showTopicActionSheet(BuildContext context, { + required int channelId, + required String topic, +}) { + final narrow = TopicNarrow(channelId, topic); + UserTopicUpdateButton button({ + UserTopicVisibilityPolicy? from, + required UserTopicVisibilityPolicy to, + }) { + return UserTopicUpdateButton( + currentVisibilityPolicy: from, + newVisibilityPolicy: to, + narrow: narrow, + pageContext: context); + } + + final mute = button(to: UserTopicVisibilityPolicy.muted); + final unmute = button(from: UserTopicVisibilityPolicy.muted, + to: UserTopicVisibilityPolicy.none); + final unmuteInMutedChannel = button(to: UserTopicVisibilityPolicy.unmuted); + final follow = button(to: UserTopicVisibilityPolicy.followed); + final unfollow = button(from: UserTopicVisibilityPolicy.followed, + to: UserTopicVisibilityPolicy.none); + + final store = PerAccountStoreWidget.of(context); + final channelMuted = store.subscriptions[channelId]?.isMuted; + final visibilityPolicy = store.topicVisibilityPolicy(channelId, topic); + + // TODO(server-7): simplify this condition away + final supportsUnmutingTopics = store.connection.zulipFeatureLevel! >= 170; + // TODO(server-8): simplify this condition away + final supportsFollowingTopics = store.connection.zulipFeatureLevel! >= 219; + + final optionButtons = []; + if (channelMuted != null && !channelMuted) { + switch (visibilityPolicy) { + case UserTopicVisibilityPolicy.muted: + optionButtons.add(unmute); + if (supportsFollowingTopics) { + optionButtons.add(follow); + } + case UserTopicVisibilityPolicy.none: + case UserTopicVisibilityPolicy.unmuted: + optionButtons.add(mute); + if (supportsFollowingTopics) { + optionButtons.add(follow); + } + case UserTopicVisibilityPolicy.followed: + optionButtons.add(mute); + if (supportsFollowingTopics) { + optionButtons.add(unfollow); + } + case UserTopicVisibilityPolicy.unknown: + assert(false); + return; + } + } else if (channelMuted != null && channelMuted) { + if (supportsUnmutingTopics) { + switch (visibilityPolicy) { + case UserTopicVisibilityPolicy.none: + case UserTopicVisibilityPolicy.muted: + optionButtons.add(unmuteInMutedChannel); + if (supportsFollowingTopics) { + optionButtons.add(follow); + } + case UserTopicVisibilityPolicy.unmuted: + optionButtons.add(mute); + if (supportsFollowingTopics) { + optionButtons.add(follow); + } + case UserTopicVisibilityPolicy.followed: + optionButtons.add(mute); + if (supportsFollowingTopics) { + optionButtons.add(unfollow); + } + case UserTopicVisibilityPolicy.unknown: + assert(false); + return; + } + } + } else { + // Not subscribed to the channel; there is no user topic change to be made. + return; + } + + if (optionButtons.isEmpty) { + assert(!supportsUnmutingTopics); + return; + } + + _showActionSheet(context, optionButtons: optionButtons); +} + +class UserTopicUpdateButton extends ActionSheetMenuItemButton { + const UserTopicUpdateButton({ + super.key, + this.currentVisibilityPolicy, + required this.newVisibilityPolicy, + required this.narrow, + required super.pageContext, + }); + + final UserTopicVisibilityPolicy? currentVisibilityPolicy; + final UserTopicVisibilityPolicy newVisibilityPolicy; + final TopicNarrow narrow; + + @override IconData get icon { + switch (newVisibilityPolicy) { + case UserTopicVisibilityPolicy.none: + return ZulipIcons.inherit; + case UserTopicVisibilityPolicy.muted: + return ZulipIcons.mute; + case UserTopicVisibilityPolicy.unmuted: + return ZulipIcons.unmute; + case UserTopicVisibilityPolicy.followed: + return ZulipIcons.follow; + case UserTopicVisibilityPolicy.unknown: + assert(false); + return ZulipIcons.inherit; + } + } + + @override + String label(ZulipLocalizations zulipLocalizations) { + switch ((currentVisibilityPolicy, newVisibilityPolicy)) { + case (UserTopicVisibilityPolicy.muted, UserTopicVisibilityPolicy.none): + return zulipLocalizations.actionSheetOptionUnmuteTopic; + case (UserTopicVisibilityPolicy.followed, UserTopicVisibilityPolicy.none): + return zulipLocalizations.actionSheetOptionUnfollowTopic; + + case (_, UserTopicVisibilityPolicy.muted): + return zulipLocalizations.actionSheetOptionMuteTopic; + case (_, UserTopicVisibilityPolicy.unmuted): + return zulipLocalizations.actionSheetOptionUnmuteTopic; + case (_, UserTopicVisibilityPolicy.followed): + return zulipLocalizations.actionSheetOptionFollowTopic; + + case (_, UserTopicVisibilityPolicy.none): + case (_, UserTopicVisibilityPolicy.unknown): + assert(false); + return ''; + } + } + + String _errorTitle(ZulipLocalizations zulipLocalizations) { + switch ((currentVisibilityPolicy, newVisibilityPolicy)) { + case (UserTopicVisibilityPolicy.muted, UserTopicVisibilityPolicy.none): + return zulipLocalizations.errorUnmuteTopicFailed; + case (UserTopicVisibilityPolicy.followed, UserTopicVisibilityPolicy.none): + return zulipLocalizations.errorUnfollowTopicFailed; + + case (_, UserTopicVisibilityPolicy.muted): + return zulipLocalizations.errorMuteTopicFailed; + case (_, UserTopicVisibilityPolicy.unmuted): + return zulipLocalizations.errorUnmuteTopicFailed; + case (_, UserTopicVisibilityPolicy.followed): + return zulipLocalizations.errorFollowTopicFailed; + + case (_, UserTopicVisibilityPolicy.none): + case (_, UserTopicVisibilityPolicy.unknown): + assert(false); + return ''; + } + } + + @override void onPressed() async { + try { + await updateUserTopic( + PerAccountStoreWidget.of(pageContext).connection, + streamId: narrow.streamId, + topic: narrow.topic, + visibilityPolicy: newVisibilityPolicy); + } catch (e) { + if (!pageContext.mounted) return; + + String? errorMessage; + + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + final zulipLocalizations = ZulipLocalizations.of(pageContext); + showErrorDialog(context: pageContext, + title: _errorTitle(zulipLocalizations), message: errorMessage); + } + } +} + /// Show a sheet of actions you can take on a message in the message list. /// /// Must have a [MessageListPage] ancestor. diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index eb3a54da5a6..4377caa7f84 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -4,6 +4,7 @@ import '../api/model/model.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; +import 'action_sheet.dart'; import 'app_bar.dart'; import 'icons.dart'; import 'message_list.dart'; @@ -514,6 +515,8 @@ class _TopicItem extends StatelessWidget { Navigator.push(context, MessageListPage.buildRoute(context: context, narrow: narrow)); }, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, topic: topic), child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(width: 63), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index eb6fa44905f..113d687f63a 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -305,7 +305,7 @@ class MessageListAppBarTitle extends StatelessWidget { }) { // A null [Icon.icon] makes a blank space. final icon = (stream != null) ? iconDataForStream(stream) : null; - return Row( + final appBar = Row( mainAxisSize: MainAxisSize.min, // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc. // For screenshots of some experiments, see: @@ -316,6 +316,17 @@ class MessageListAppBarTitle extends StatelessWidget { const SizedBox(width: 4), Flexible(child: Text(text)), ]); + + if (narrow case TopicNarrow(:final streamId, :final topic)) { + return SizedBox( + width: double.infinity, + child: GestureDetector( + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, topic: topic), + child: appBar)); + } + + return appBar; } @override @@ -1016,6 +1027,8 @@ class StreamMessageRecipientHeader extends StatelessWidget { onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context, narrow: TopicNarrow.ofMessage(message))), + onLongPress: () => showTopicActionSheet(context, + channelId: message.streamId, topic: topic), child: ColoredBox( color: backgroundColor, child: Row( diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 3dade81b48b..07cec4d8e4e 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/model/events.dart'; @@ -16,9 +17,12 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/action_sheet.dart'; +import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; 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 '../api/fake_api.dart'; @@ -93,6 +97,283 @@ void main() { connection.prepare(httpStatus: 400, json: fakeResponseJson); } + group('showTopicActionSheet', () { + final channel = eg.stream(); + const topic = 'my topic'; + final message = eg.streamMessage( + stream: channel, topic: topic, sender: eg.otherUser); + + Future prepare() async { + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + realmUsers: [eg.otherUser], + streams: [channel], + subscriptions: [eg.subscription(channel)])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.handleEvent(MessageEvent(id: 1, message: message)); + } + + testWidgets('show from inbox', (tester) async { + await prepare(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const InboxPage())); + await tester.pump(); + + await tester.longPress(find.text(topic)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + }); + + testWidgets('show from app bar', (tester) async { + await prepare(); + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage( + initNarrow: TopicNarrow(channel.streamId, topic)))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(ZulipAppBar)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + }); + + testWidgets('show from recipient header', (tester) async { + await prepare(); + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.text(topic)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + check(find.byType(BottomSheet)).findsOne(); + }); + }); + + group('UserTopicUpdateButton', () { + late ZulipStream channel; + late String topic; + + Finder findButton({UserTopicVisibilityPolicy? from, required UserTopicVisibilityPolicy to}) { + return find.byWidgetPredicate((widget) { + return widget is UserTopicUpdateButton + && widget.currentVisibilityPolicy == from + && widget.newVisibilityPolicy == to; + }); + } + + final mute = findButton(to: UserTopicVisibilityPolicy.muted); + final unmute = findButton(from: UserTopicVisibilityPolicy.muted, + to: UserTopicVisibilityPolicy.none); + final unmuteInMutedChannel = findButton(to: UserTopicVisibilityPolicy.unmuted); + final follow = findButton(to: UserTopicVisibilityPolicy.followed); + final unfollow = findButton(from: UserTopicVisibilityPolicy.followed, + to: UserTopicVisibilityPolicy.none); + + Future setupToTopicActionSheet(WidgetTester tester, { + required bool? isChannelMuted, + required UserTopicVisibilityPolicy visibilityPolicy, + int? featureLevel, + }) async { + addTearDown(testBinding.reset); + + channel = eg.stream(); + topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + + final account = eg.selfAccount.copyWith(zulipFeatureLevel: featureLevel); + final subscriptions = isChannelMuted == null ? [] + : [eg.subscription(channel, isMuted: isChannelMuted)]; + await testBinding.globalStore.add(account, eg.initialSnapshot( + streams: [channel], + subscriptions: subscriptions, + userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], + zulipFeatureLevel: featureLevel)); + store = await testBinding.globalStore.perAccount(account.id); + connection = store.connection as FakeApiConnection; + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [ + eg.streamMessage(stream: channel, topic: topic)]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: account.id, + child: MessageListPage( + initNarrow: TopicNarrow(channel.streamId, topic)))); + await tester.pumpAndSettle(); + + await tester.longPress(find.text(topic)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + } + + void checkButtons(List expectedButtonFinders) { + if (expectedButtonFinders.isEmpty) { + check(find.byType(BottomSheet)).findsNothing(); + return; + } + check(find.byType(BottomSheet)).findsOne(); + + for (final buttonFinder in expectedButtonFinders) { + check(buttonFinder).findsOne(); + } + check(find.bySubtype()) + .findsExactly(expectedButtonFinders.length); + } + + void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': '${channel.streamId}', + 'topic': topic, + 'visibility_policy': '${expectedPolicy.toJson()}', + }); + } + + testWidgets('unmuteInMutedChannel', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: true, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(unmuteInMutedChannel); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); + }); + + testWidgets('unmute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.muted); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); + + testWidgets('mute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(mute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); + }); + + testWidgets('follow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(follow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); + }); + + testWidgets('unfollow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); + await tester.tap(unfollow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); + + testWidgets('request fails with an error dialog', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); + + connection.prepare(httpStatus: 400, json: { + 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); + await tester.tap(unfollow); + await tester.pumpAndSettle(); + + checkErrorDialog(tester, expectedTitle: 'Failed to unfollow topic'); + }); + + group('check expected buttons', () { + final testCases = { + (false, UserTopicVisibilityPolicy.muted, [unmute, follow]), + (false, UserTopicVisibilityPolicy.none, [mute, follow]), + (false, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (false, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (true, UserTopicVisibilityPolicy.muted, [unmuteInMutedChannel, follow]), + (true, UserTopicVisibilityPolicy.none, [unmuteInMutedChannel, follow]), + (true, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (true, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (null, UserTopicVisibilityPolicy.none, []), + }; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + testWidgets('$isChannelMuted $visibilityPolicy', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy); + checkButtons(buttons); + }); + } + }); + + group('legacy: follow is unsupported when FL < 219', () { + final testCases = { + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, [unmuteInMutedChannel]), + (true, UserTopicVisibilityPolicy.none, [unmuteInMutedChannel]), + (true, UserTopicVisibilityPolicy.unmuted, [mute]), + (true, UserTopicVisibilityPolicy.followed, [mute]), + + (null, UserTopicVisibilityPolicy.none, []), + }; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + testWidgets('$isChannelMuted $visibilityPolicy', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + featureLevel: 218); + checkButtons(buttons); + }); + } + }); + + group('legacy: unmute is unsupported when FL < 170', () { + final testCases = { + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, []), + (true, UserTopicVisibilityPolicy.none, []), + (true, UserTopicVisibilityPolicy.unmuted, []), + (true, UserTopicVisibilityPolicy.followed, []), + + (null, UserTopicVisibilityPolicy.none, []), + }; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + testWidgets('$isChannelMuted $visibilityPolicy', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + featureLevel: 169); + checkButtons(buttons); + }); + } + }); + }); + group('AddThumbsUpButton', () { Future tapButton(WidgetTester tester) async { await tester.ensureVisible(find.byIcon(ZulipIcons.smile, skipOffstage: false));