From 462d89bb14f71162eec6b475ca2bcdb32ef3e60e 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/api/model/model.dart | 2 +- 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 | 220 +++++++++++++ lib/widgets/inbox.dart | 3 + lib/widgets/message_list.dart | 22 +- test/widgets/action_sheet_test.dart | 300 ++++++++++++++++++ 10 files changed, 691 insertions(+), 8 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1b8e6ff8b8..489ec8e972 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/api/model/model.dart b/lib/api/model/model.dart index d009bb9f29..b36e1f2490 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -493,7 +493,7 @@ enum UserTopicVisibilityPolicy { muted(apiValue: 1), unmuted(apiValue: 2), // TODO(server-7) newly added followed(apiValue: 3), // TODO(server-8) newly added - unknown(apiValue: null); + unknown(apiValue: null); // TODO(#1074) remove this const UserTopicVisibilityPolicy({required this.apiValue}); diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b058af8f2b..b3fbb72b57 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 95ff1d0aea..411e904316 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 d440ed2b10..d2708842a2 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 42128ba024..18a4a5a3ee 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 1b6c8b2e58..66c7112b70 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'; @@ -143,6 +144,225 @@ 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: + // TODO(#1074): This should be unreachable as we keep `unknown` out of + // our data structures. + assert(false); + } + } 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: + // TODO(#1074): This should be unreachable as we keep `unknown` out of + // our data structures. + assert(false); + } + } + } else { + // Not subscribed to the channel; there is no user topic change to be made. + } + + 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 topic". + 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: + // TODO(#1074): This should be unreachable as we keep `unknown` out of + // our data structures. + 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): + // This is unexpected because `UserTopicVisibilityPolicy.muted` and + // `UserTopicVisibilityPolicy.followed` (handled in separate `case`'s) + // are the only expected `currentVisibilityPolicy` + // when `newVisibilityPolicy` is `UserTopicVisibilityPolicy.none`. + assert(false); + return ''; + + case (_, UserTopicVisibilityPolicy.unknown): + // This case is unreachable (or should be) because we keep `unknown` out + // of our data structures. We plan to remove the `unknown` case in #1074. + 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): + // This is unexpected because `UserTopicVisibilityPolicy.muted` and + // `UserTopicVisibilityPolicy.followed` (handled in separate `case`'s) + // are the only expected `currentVisibilityPolicy` + // when `newVisibilityPolicy` is `UserTopicVisibilityPolicy.none`. + assert(false); + return ''; + + case (_, UserTopicVisibilityPolicy.unknown): + // This case is unreachable (or should be) because we keep `unknown` out + // of our data structures. We plan to remove the `unknown` case in #1074. + assert(false); + return ''; + } + } + + @override void onPressed() async { + try { + await updateUserTopicCompat( + 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 a8c70bee53..f276a1b5cd 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'; @@ -525,6 +526,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 0ffb8ee4c1..9914462f69 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -403,13 +403,19 @@ class MessageListAppBarTitle extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final stream = store.streams[streamId]; final centerTitle = _getEffectiveCenterTitle(theme); - return Column( - crossAxisAlignment: centerTitle ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - _buildStreamRow(context, stream: stream), - _buildTopicRow(context, stream: stream, topic: topic), - ]); + return SizedBox( + width: double.infinity, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: () => showTopicActionSheet(context, + channelId: streamId, topic: topic), + child: Column( + crossAxisAlignment: centerTitle ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + _buildStreamRow(context, stream: stream), + _buildTopicRow(context, stream: stream, topic: topic), + ]))); case DmNarrow(:var otherRecipientIds): final store = PerAccountStoreWidget.of(context); @@ -1084,6 +1090,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 b47a49dba9..09c4280e4b 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'; @@ -99,6 +103,302 @@ 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.selfUser, eg.otherUser], + streams: [channel], + subscriptions: [eg.subscription(channel)])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addMessage(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.descendant( + of: find.byType(RecipientHeader), matching: 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, + required String label, + }) { + return find.descendant(of: find.byWidgetPredicate((widget) { + return widget is UserTopicUpdateButton + && widget.currentVisibilityPolicy == from + && widget.newVisibilityPolicy == to; + }), matching: find.text(label)); + } + + final mute = findButton(to: UserTopicVisibilityPolicy.muted, + label: 'Mute topic'); + final unmute = findButton(from: UserTopicVisibilityPolicy.muted, + to: UserTopicVisibilityPolicy.none, + label: 'Unmute topic'); + final unmuteInMutedChannel = findButton(to: UserTopicVisibilityPolicy.unmuted, + label: 'Unmute topic'); + final follow = findButton(to: UserTopicVisibilityPolicy.followed, + label: 'Follow topic'); + final unfollow = findButton(from: UserTopicVisibilityPolicy.followed, + to: UserTopicVisibilityPolicy.none, + label: 'Unfollow topic'); + + /// Prepare store and bring up a topic action sheet. + /// + /// If `isChannelMuted` is `null`, the user is not subscribed to the + /// channel. + 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( + realmUsers: [eg.selfUser], + 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.descendant( + of: find.byType(RecipientHeader), matching: 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) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (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) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (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) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (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));