From dc1bb71b229ae01d30d79bf5ce6f4d0234bd8b92 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 4 Dec 2024 23:22:17 +0530 Subject: [PATCH] action_sheet: Support reacting with popular emojis --- lib/widgets/action_sheet.dart | 85 ++++++++++++----- test/flutter_checks.dart | 4 + test/widgets/action_sheet_test.dart | 137 +++++++++++++++++++++------- 3 files changed, 169 insertions(+), 57 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index c1e2c0e1f29..32c7714a8e6 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -9,6 +9,7 @@ import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/emoji.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; @@ -16,6 +17,7 @@ import 'clipboard.dart'; import 'color.dart'; import 'compose_box.dart'; import 'dialog.dart'; +import 'emoji.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; @@ -41,16 +43,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; - final hasThumbsUpReactionVote = message.reactions - ?.aggregated.any((reactionWithVotes) => - reactionWithVotes.reactionType == ReactionType.unicodeEmoji - && reactionWithVotes.emojiCode == '1f44d' - && reactionWithVotes.userIds.contains(store.selfUserId)) - ?? false; - final optionButtons = [ - if (!hasThumbsUpReactionVote) - AddThumbsUpButton(message: message, pageContext: context), + ReactionButtons(message: message, pageContext: context), StarButton(message: message, pageContext: context), if (isComposeBoxOffered) QuoteAndReplyButton(message: message, pageContext: context), @@ -182,27 +176,30 @@ class MessageActionSheetCancelButton extends StatelessWidget { } } -// This button is very temporary, to complete #125 before we have a way to -// choose an arbitrary reaction (#388). So, skipping i18n. -class AddThumbsUpButton extends MessageActionSheetMenuItemButton { - AddThumbsUpButton({super.key, required super.message, required super.pageContext}); +class ReactionButtons extends StatelessWidget { + const ReactionButtons({ + super.key, + required this.message, + required this.pageContext, + }); - @override IconData get icon => ZulipIcons.smile; + final Message message; - @override - String label(ZulipLocalizations zulipLocalizations) { - return 'React with 👍'; // TODO(i18n) skip translation for now - } + /// A context within the [MessageListPage] this action sheet was + /// triggered from. + final BuildContext pageContext; - @override void onPressed() async { + void _onPressed(UnicodeEmojiDisplay emoji, bool selfVoted) async { String? errorMessage; try { - await addReaction(PerAccountStoreWidget.of(pageContext).connection, + await (selfVoted ? removeReaction : addReaction).call( + PerAccountStoreWidget.of(pageContext).connection, messageId: message.id, reactionType: ReactionType.unicodeEmoji, - emojiCode: '1f44d', - emojiName: '+1', + emojiCode: emoji.emojiCode, + emojiName: emoji.emojiName, ); + if (pageContext.mounted) Navigator.pop(pageContext); } catch (e) { if (!pageContext.mounted) return; @@ -215,9 +212,51 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton { } showErrorDialog(context: pageContext, - title: 'Adding reaction failed', message: errorMessage); + title: '${selfVoted ? 'Removing' : 'Adding'} reaction failed', + message: errorMessage); } } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(pageContext); + final designVariables = DesignVariables.of(context); + + bool hasSelfVote(UnicodeEmojiDisplay emoji) { + return message.reactions?.aggregated.any((reactionWithVotes) { + return reactionWithVotes.reactionType == ReactionType.unicodeEmoji + && tryParseEmojiCodeToUnicode(reactionWithVotes.emojiCode) == emoji.emojiUnicode + && reactionWithVotes.userIds.contains(store.selfUserId); + }) ?? false; + } + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.unmodifiable(popularUnicodeEmojis.map((emoji) { + final selfVoted = hasSelfVote(emoji); + return IconButton( + onPressed: () => _onPressed(emoji, selfVoted), + isSelected: selfVoted, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.5)), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed || e == WidgetState.selected) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent)), + icon: UnicodeEmojiWidget( + emojiDisplay: emoji, + notoColorEmojiTextSize: 24, + size: 24)); + }))) + ); + } } class StarButton extends MessageActionSheetMenuItemButton { diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 9d81e8ea20b..5e0fe370b7f 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -158,3 +158,7 @@ extension TableRowChecks on Subject { extension TableChecks on Subject { Subject> get children => has((x) => x.children, 'children'); } + +extension IconButtonChecks on Subject { + Subject get isSelected => has((x) => x.isSelected, 'isSelected'); +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index b47a49dba9e..cd4c8916f00 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -11,13 +11,16 @@ import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/internal_link.dart'; 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/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart'; @@ -26,6 +29,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/emoji_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; @@ -99,46 +103,107 @@ void main() { connection.prepare(httpStatus: 400, json: fakeResponseJson); } - group('AddThumbsUpButton', () { - Future tapButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.smile, skipOffstage: false)); - await tester.tap(find.byIcon(ZulipIcons.smile)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - testWidgets('success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + group('ReactionButtons', () { + group('popular emoji reactions;', () { + testWidgets('ensure all are shown', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + check(popularUnicodeEmojis).length.equals(6); + + // Ensure there are only 6 buttons. + final buttons = tester.widgetList(find.descendant( + of: find.byType(ReactionButtons) , + matching: find.byType(IconButton))); + check(buttons).length.equals(6); + + // Ensure all are unicode emoji buttons. + final emojis = tester.widgetList(find.descendant( + of: find.ancestor( + of: find.byType(IconButton), + matching: find.byType(ReactionButtons)), + matching: find.byType(UnicodeEmojiWidget))); + check(emojis).length.equals(6); + check(emojis).deepEquals(popularUnicodeEmojis.map>((emoji) { + return (it) => it.isA() + ..emojiDisplay.which((it) => it + ..emojiName.equals(emoji.emojiName) + ..emojiUnicode.equals(emoji.emojiUnicode) + ..emojiCode.equals(emoji.emojiCode)); + })); + }); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') - ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': '1f44d', - 'emoji_name': '+1', - }); - }); + for (final popularEmoji in popularUnicodeEmojis) { + Future tapButton(WidgetTester tester, {required bool isSelected}) async { + final finder = find.ancestor( + of: find.text(popularEmoji.emojiUnicode), + matching: find.byType(IconButton)); + + check(tester.widget(finder)) + .isSelected.equals(isSelected); + await tester.tap(finder); + } + + testWidgets('${popularEmoji.emojiName} adding success', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester, isSelected: false); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': popularEmoji.emojiCode, + 'emoji_name': popularEmoji.emojiName, + }); + }); - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('${popularEmoji.emojiName} removing success', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: popularEmoji.emojiName, + emojiCode: popularEmoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester, isSelected: true); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': popularEmoji.emojiCode, + 'emoji_name': popularEmoji.emojiName, + }); + }); - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - await tapButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + testWidgets('${popularEmoji.emojiName} request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', - expectedMessage: 'Invalid message(s)'))); + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + await tapButton(tester, isSelected: false); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + }); + } }); }); @@ -700,3 +765,7 @@ void main() { }); }); } + +extension UnicodeEmojiWidgetChecks on Subject { + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +}