diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index aea0c83d387..98955f0a372 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -43,6 +43,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." @@ -172,6 +188,22 @@ "error": {"type": "String", "example": "Invalid format"} } }, + "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/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7830f9e5bcd..d3c34178899 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -8,6 +8,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 '../model/internal_link.dart'; import '../model/narrow.dart'; @@ -140,6 +141,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 9259c813649..ef82d426184 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 b33116d2eca..f21dba09c68 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 aaf13c0b176..68bc5dcbe8a 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'; @@ -94,6 +98,288 @@ 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: newestResult( + 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: newestResult( + 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 prepareAndCheckButtons(WidgetTester tester, { + required bool? isChannelMuted, + required UserTopicVisibilityPolicy visibilityPolicy, + int? featureLevel, + required List expectedButtonFinders, + }) 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; + + final message = eg.streamMessage(stream: channel, topic: topic); + connection.prepare(json: newestResult( + foundOldest: true, messages: [message]).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)); + 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 prepareAndCheckButtons(tester, + isChannelMuted: true, + visibilityPolicy: UserTopicVisibilityPolicy.none, + expectedButtonFinders: [unmuteInMutedChannel, follow]); + await tester.tap(unmuteInMutedChannel); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); + }); + + testWidgets('unmute', (tester) async { + await prepareAndCheckButtons(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.muted, + expectedButtonFinders: [unmute, follow]); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); + + testWidgets('mute', (tester) async { + await prepareAndCheckButtons(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none, + expectedButtonFinders: [mute, follow]); + await tester.tap(mute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); + }); + + testWidgets('follow', (tester) async { + await prepareAndCheckButtons(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none, + expectedButtonFinders: [mute, follow]); + await tester.tap(follow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); + }); + + testWidgets('unfollow', (tester) async { + await prepareAndCheckButtons(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed, + expectedButtonFinders: [mute, unfollow]); + await tester.tap(unfollow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); + + testWidgets('request fails with an error dialog', (tester) async { + await prepareAndCheckButtons(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed, + expectedButtonFinders: [mute, unfollow]); + + 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 prepareAndCheckButtons(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + expectedButtonFinders: 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 prepareAndCheckButtons(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + featureLevel: 218, + expectedButtonFinders: 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 prepareAndCheckButtons(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + featureLevel: 169, + expectedButtonFinders: buttons); + }); + } + }); + }); + group('AddThumbsUpButton', () { Future tapButton(WidgetTester tester) async { await tester.ensureVisible(find.byIcon(ZulipIcons.smile, skipOffstage: false));