From 7d104af0fb25bbf3e826667edc90d86c10d2f180 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 4 Dec 2024 23:33:02 +0530 Subject: [PATCH] reactions: Support adding arbitary reactions --- lib/model/emoji.dart | 15 ++ lib/widgets/action_sheet.dart | 74 ++++++--- lib/widgets/emoji_reaction.dart | 235 ++++++++++++++++++++++++++++ lib/widgets/theme.dart | 7 + test/widgets/action_sheet_test.dart | 41 +++++ 5 files changed, 349 insertions(+), 23 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 5c4a6e6ef71..fb3542e85b5 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -120,6 +120,21 @@ final class EmojiCandidate { required List? aliases, required this.emojiDisplay, }) : _aliases = aliases; + + EmojiCandidate copyWith({ + ReactionType? emojiType, + String? emojiCode, + String? emojiName, + List? aliases, + EmojiDisplay? emojiDisplay, + }) { + return EmojiCandidate( + emojiType: emojiType ?? this.emojiType, + emojiCode: emojiCode ?? this.emojiCode, + emojiName: emojiName ?? this.emojiName, + aliases: aliases ?? _aliases, + emojiDisplay: emojiDisplay ?? this.emojiDisplay); + } } /// The portion of [PerAccountStore] describing what emoji exist. diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 32c7714a8e6..7ebcb075ae7 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -18,6 +18,7 @@ import 'color.dart'; import 'compose_box.dart'; import 'dialog.dart'; import 'emoji.dart'; +import 'emoji_reaction.dart'; import 'icons.dart'; import 'inset_shadow.dart'; import 'message_list.dart'; @@ -231,30 +232,57 @@ class ReactionButtons extends StatelessWidget { } return Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.only(left: 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)); - }))) + child: Row(children: [ + Flexible(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + 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)); + }))), + )), + TextButton( + onPressed: () { + showEmojiPickerSheet(context: pageContext, message: message); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.contextMenuItemText, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero) + ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => + states.contains(WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent)), + child: Row(children: [ + Text('other', + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 14) + .merge(weightVariableTextStyle(context, wght: 600))), + Icon(ZulipIcons.chevron_right, + color: designVariables.contextMenuItemText, + size: 24), + ])), + ]), ); } } diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 39264eb3b67..3848cae4e36 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/emoji.dart'; import 'color.dart'; +import 'dialog.dart'; import 'emoji.dart'; +import 'inset_shadow.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; /// Emoji-reaction styles that differ between light and dark themes. class EmojiReactionTheme extends ThemeExtension { @@ -360,3 +364,234 @@ class _TextEmoji extends StatelessWidget { text); } } + + +void showEmojiPickerSheet({required BuildContext context, required Message message}) { + final store = PerAccountStoreWidget.of(context); + + showModalBottomSheet( + context: context, + // Clip.hardEdge looks bad; Clip.antiAliasWithSaveLayer looks pixel-perfect + // on my iPhone 13 Pro but is marked as "much slower": + // https://api.flutter.dev/flutter/dart-ui/Clip.html + clipBehavior: Clip.antiAlias, + useSafeArea: true, + isScrollControlled: true, + builder: (BuildContext _) { + return SafeArea( + minimum: const EdgeInsets.only(bottom: 16), + // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. + child: PerAccountStoreWidget( + accountId: store.accountId, + child: EmojiPicker(pageContext: context, message: message))); + }); +} + +class EmojiPicker extends StatefulWidget { + const EmojiPicker({ + super.key, + required this.pageContext, + required this.message, + }); + + final BuildContext pageContext; + final Message message; + + @override + State createState() => _EmojiPickerState(); +} + +class _EmojiPickerState extends State with PerAccountStoreAwareStateMixin { + late List availableEmojiCandidates; + List? searchResults; + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + final allEmojiCandidates = store.allEmojiCandidates(); + + bool isPopularUnicodeEmoji(EmojiDisplay candidateEmojiDisplay) { + if (candidateEmojiDisplay is! UnicodeEmojiDisplay) return false; + return popularUnicodeEmojis.any( + (emoji) => emoji.emojiUnicode == candidateEmojiDisplay.emojiUnicode); + } + + bool isSelfVoted(EmojiCandidate candidate) { + return widget.message.reactions?.aggregated.any((reactionWithVotes) => + reactionWithVotes.reactionType == candidate.emojiType + && reactionWithVotes.emojiCode == candidate.emojiCode + && reactionWithVotes.userIds.contains(store.selfUserId) + ) ?? false; + } + + availableEmojiCandidates = allEmojiCandidates + .where((candidate) => + !isSelfVoted(candidate) && !isPopularUnicodeEmoji(candidate.emojiDisplay)) + .toList(growable: false); + searchResults = null; + } + + // Adapted from web/src/emoji_picker.ts + void _filterEmojis(String query) { + if (query.isEmpty) { + setState(() { searchResults = null; }); + return; + } + + final results = []; + final searchTerms = query.toLowerCase().split(' '); + for (final candidate in availableEmojiCandidates) { + for (final alias in [candidate.emojiName, ...candidate.aliases]) { + if (searchTerms.every((e) => alias.contains(e))) { + results.add(candidate.copyWith( + emojiName: alias, + aliases: const [])); + break; // We only need the first matching alias per emoji. + } + } + + // Using query instead of searchTerms because it's possible multiple + // emojis were input without being separated by spaces. + final emojiDisplay = candidate.emojiDisplay; + if (emojiDisplay is UnicodeEmojiDisplay && query.contains(emojiDisplay.emojiUnicode)) { + results.add(candidate.copyWith( + emojiName: candidate.emojiName, + aliases: const [])); + } + } + + // TODO sort emojis + + setState(() { searchResults = results; }); + } + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + + final emojiList = searchResults ?? availableEmojiCandidates; + + return Column(children: [ + Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Row(children: [ + Flexible(child: TextField( + onChanged: (query) => _filterEmojis(query), + autofocus: false, + decoration: InputDecoration( + hintText: 'Search emoji', + contentPadding: const EdgeInsets.only(left: 10, top: 6), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none)), + style: const TextStyle(fontSize: 19, height: 26 / 19) + .merge(weightVariableTextStyle(context, wght: 400)))), + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.contextMenuItemText, + ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => + states.contains(WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent)), + child: Text('Close', style: const TextStyle(fontSize: 20, height: 30 / 20) + .merge(weightVariableTextStyle(context, wght: 400)))), + ])), + Expanded(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: emojiList.length, + itemBuilder: (context, i) => EmojiPickerItem( + pageContext: widget.pageContext, + candidate: emojiList[i], + message: widget.message), + ), + )), + ]); + } +} + +@visibleForTesting +class EmojiPickerItem extends StatelessWidget { + const EmojiPickerItem({ + super.key, + required this.pageContext, + required this.candidate, + required this.message, + }); + + final BuildContext pageContext; + final EmojiCandidate candidate; + final Message message; + + static const _size = 24.0; + static const _notoColorEmojiTextSize = 24.0; + + void _onPressed() async { + String? errorMessage; + try { + await addReaction( + PerAccountStoreWidget.of(pageContext).connection, + messageId: message.id, + reactionType: candidate.emojiType, + emojiCode: candidate.emojiCode, + emojiName: candidate.emojiName, + ); + if (pageContext.mounted) Navigator.pop(pageContext); // Emoji picker + if (pageContext.mounted) Navigator.pop(pageContext); // Context menu + } catch (e) { + if (!pageContext.mounted) return; + + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + } + + showErrorDialog(context: pageContext, + title: 'Adding reaction failed', + message: errorMessage); + } + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings); + final Widget? glyph = switch (emojiDisplay) { + ImageEmojiDisplay() => + ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay), + UnicodeEmojiDisplay() => + UnicodeEmojiWidget( + size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize, + emojiDisplay: emojiDisplay), + TextEmojiDisplay() => null, // The text is already shown separately. + }; + + final label = candidate.aliases.isEmpty + ? candidate.emojiName + : [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080) + + return TextButton.icon( + onPressed: _onPressed, + icon: glyph, + style: MenuItemButton.styleFrom( + alignment: Alignment.centerLeft, + shape: const RoundedRectangleBorder(), + splashFactory: NoSplash.splashFactory), + label: Text(label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, height: 24 / 17) + .merge(weightVariableTextStyle(context, wght: 400)))); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 3ba52212453..eda52f07f91 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -134,6 +134,7 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xfff0f0f0), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), + bgSearchInput: const Color(0xffe3e3e3), channelColorSwatches: ChannelColorSwatches.light, atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), contextMenuCancelBg: const Color(0xff797986), @@ -174,6 +175,7 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xff1d1d1d), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff), + bgSearchInput: const Color(0xff313131), channelColorSwatches: ChannelColorSwatches.dark, contextMenuCancelBg: const Color(0xff797986), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -221,6 +223,7 @@ class DesignVariables extends ThemeExtension { required this.mainBackground, required this.textInput, required this.title, + required this.bgSearchInput, required this.channelColorSwatches, required this.atMentionMarker, required this.contextMenuCancelBg, @@ -269,6 +272,7 @@ class DesignVariables extends ThemeExtension { final Color mainBackground; final Color textInput; final Color title; + final Color bgSearchInput; // Not exactly from the Figma design, but from Vlad anyway. final ChannelColorSwatches channelColorSwatches; @@ -312,6 +316,7 @@ class DesignVariables extends ThemeExtension { Color? mainBackground, Color? textInput, Color? title, + Color? bgSearchInput, ChannelColorSwatches? channelColorSwatches, Color? atMentionMarker, Color? contextMenuCancelBg, @@ -350,6 +355,7 @@ class DesignVariables extends ThemeExtension { mainBackground: mainBackground ?? this.mainBackground, textInput: textInput ?? this.textInput, title: title ?? this.title, + bgSearchInput: bgSearchInput ?? this.bgSearchInput, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, atMentionMarker: atMentionMarker ?? this.atMentionMarker, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, @@ -395,6 +401,7 @@ class DesignVariables extends ThemeExtension { mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, + bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index cd4c8916f00..90e84b818cf 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; @@ -9,6 +10,7 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/api/route/realm.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; @@ -21,6 +23,7 @@ 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/emoji_reaction.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'; @@ -33,6 +36,7 @@ import '../model/emoji_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; @@ -205,6 +209,43 @@ void main() { }); } }); + + group('arbitary reactions;', () { + Future setupEmojiPicker(WidgetTester tester, { + required Message message, + required Narrow narrow, + }) async { + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + + await setupToMessageActionSheet(tester, message: message, narrow: narrow); + store.setServerEmojiData(ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + })); + await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), + })); + + await tester.tap(find.ancestor( + of: find.byIcon(ZulipIcons.chevron_right), + matching: find.byType(TextButton))); + await tester.pump(); + await tester.ensureVisible(find.byType(EmojiPicker)); + } + + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(); + await setupEmojiPicker(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + final list = tester.widgetList(find.byType(EmojiPickerItem)); + check(list).length.equals(3); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); group('StarButton', () {