diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 9a7d0f9a5a..f3a09b8338 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/chevron_right.svg b/assets/icons/chevron_right.svg index dc422e86cf..727cba72d1 100644 --- a/assets/icons/chevron_right.svg +++ b/assets/icons/chevron_right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 226baddf66..58822303fd 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -375,6 +375,10 @@ "@dialogContinue": { "description": "Button label in dialogs to proceed." }, + "dialogClose": "Close", + "@dialogClose": { + "description": "Button label in dialogs to close." + }, "errorDialogContinue": "OK", "@errorDialogContinue": { "description": "Button label in error dialogs to acknowledge the error and close the dialog." @@ -660,5 +664,21 @@ "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", "@errorNotificationOpenAccountMissing": { "description": "Error message when the account associated with the notification is not found" + }, + "errorReactionAddingFailedTitle": "Adding reaction failed", + "@errorReactionAddingFailedTitle": { + "description": "Error title when adding a message reaction fails" + }, + "errorReactionRemovingFailedTitle": "Removing reaction failed", + "@errorReactionRemovingFailedTitle": { + "description": "Error title when removing a message reaction fails" + }, + "emojiReactionsMore": "more", + "@emojiReactionsMore": { + "description": "Label for a button opening the emoji picker." + }, + "emojiPickerSearchEmoji": "Search emoji", + "@emojiPickerSearchEmoji": { + "description": "Hint text for the emoji picker search text field." } } diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 73461849a4..00d7cfde72 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -601,6 +601,12 @@ abstract class ZulipLocalizations { /// **'Continue'** String get dialogContinue; + /// Button label in dialogs to close. + /// + /// In en, this message translates to: + /// **'Close'** + String get dialogClose; + /// Button label in error dialogs to acknowledge the error and close the dialog. /// /// In en, this message translates to: @@ -984,6 +990,30 @@ abstract class ZulipLocalizations { /// In en, this message translates to: /// **'The account associated with this notification no longer exists.'** String get errorNotificationOpenAccountMissing; + + /// Error title when adding a message reaction fails + /// + /// In en, this message translates to: + /// **'Adding reaction failed'** + String get errorReactionAddingFailedTitle; + + /// Error title when removing a message reaction fails + /// + /// In en, this message translates to: + /// **'Removing reaction failed'** + String get errorReactionRemovingFailedTitle; + + /// Label for a button opening the emoji picker. + /// + /// In en, this message translates to: + /// **'more'** + String get emojiReactionsMore; + + /// Hint text for the emoji picker search text field. + /// + /// In en, this message translates to: + /// **'Search emoji'** + String get emojiPickerSearchEmoji; } class _ZulipLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 09cee543aa..542b85031b 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get dialogContinue => 'Continue'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 7682853577..b6bc9f72e7 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get dialogContinue => 'Continue'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index be7cb7a511..c857da2c82 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get dialogContinue => 'Continue'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 5bcd724689..7adbc9ae8a 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get dialogContinue => 'Continue'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 10b7c29dd1..07746b3f27 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get dialogContinue => 'Kontynuuj'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'Konto związane z tym powiadomieniem już nie istnieje.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index a176891911..9c2065376b 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -295,6 +295,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get dialogContinue => 'Continue'; + @override + String get dialogClose => 'Close'; + @override String get errorDialogContinue => 'OK'; @@ -522,4 +525,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + + @override + String get errorReactionAddingFailedTitle => 'Adding reaction failed'; + + @override + String get errorReactionRemovingFailedTitle => 'Removing reaction failed'; + + @override + String get emojiReactionsMore => 'more'; + + @override + String get emojiPickerSearchEmoji => 'Search emoji'; } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 7b741797b0..ceacecd4bb 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,7 @@ import '../api/model/model.dart'; import '../api/route/channels.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'; @@ -17,6 +19,8 @@ import 'clipboard.dart'; 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'; @@ -26,7 +30,7 @@ import 'theme.dart'; void _showActionSheet( BuildContext context, { - required List optionButtons, + required List optionButtons, }) { showModalBottomSheet( context: context, @@ -383,16 +387,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), @@ -416,41 +412,130 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto final Message message; } -// 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; + + void _handleTapReaction({ + required EmojiCandidate emoji, + required bool isSelfVoted, + }) { + // Dismiss the enclosing action sheet immediately, + // for swift UI feedback that the user's selection was received. + Navigator.pop(pageContext); + + final zulipLocalizations = ZulipLocalizations.of(pageContext); + doAddOrRemoveReaction( + context: pageContext, + doRemoveReaction: isSelfVoted, + messageId: message.id, + emoji: emoji, + errorDialogTitle: isSelfVoted + ? zulipLocalizations.errorReactionRemovingFailedTitle + : zulipLocalizations.errorReactionAddingFailedTitle); } - @override void onPressed() async { - String? errorMessage; - try { - await addReaction(PerAccountStoreWidget.of(pageContext).connection, - messageId: message.id, - reactionType: ReactionType.unicodeEmoji, - emojiCode: '1f44d', - emojiName: '+1', - ); - } catch (e) { - if (!pageContext.mounted) return; + void _handleTapMore() { + // TODO(design): have emoji picker slide in from right and push + // action sheet off to the left - switch (e) { - case ZulipApiException(): - errorMessage = e.message; - // TODO(#741) specific messages for common errors, like network errors - // (support with reusable code) - default: - } + // Dismiss current action sheet before opening emoji picker sheet. + Navigator.of(pageContext).pop(); - showErrorDialog(context: pageContext, - title: 'Adding reaction failed', message: errorMessage); + showEmojiPickerSheet(pageContext: pageContext, message: message); + } + + Widget _buildButton({ + required BuildContext context, + required EmojiCandidate emoji, + required bool isSelfVoted, + required bool isFirst, + }) { + final designVariables = DesignVariables.of(context); + return Flexible(child: InkWell( + onTap: () => _handleTapReaction(emoji: emoji, isSelfVoted: isSelfVoted), + splashFactory: NoSplash.splashFactory, + borderRadius: isFirst + ? const BorderRadius.only(topLeft: Radius.circular(7)) + : null, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5), + alignment: Alignment.center, + color: isSelfVoted + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : null, + child: UnicodeEmojiWidget( + emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay, + notoColorEmojiTextSize: 20.1, + size: 24)))); + } + + @override + Widget build(BuildContext context) { + assert(EmojiStore.popularEmojiCandidates.every( + (emoji) => emoji.emojiType == ReactionType.unicodeEmoji)); + + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(pageContext); + final designVariables = DesignVariables.of(context); + + bool hasSelfVote(EmojiCandidate emoji) { + return message.reactions?.aggregated.any((reactionWithVotes) { + return reactionWithVotes.reactionType == ReactionType.unicodeEmoji + && reactionWithVotes.emojiCode == emoji.emojiCode + && reactionWithVotes.userIds.contains(store.selfUserId); + }) ?? false; } + + return Container( + decoration: BoxDecoration( + color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)), + child: Row(children: [ + Flexible(child: Row(spacing: 1, children: List.unmodifiable( + EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) => + _buildButton( + context: context, + emoji: emoji, + isSelfVoted: hasSelfVote(emoji), + isFirst: index == 0))))), + InkWell( + onTap: _handleTapMore, + splashFactory: NoSplash.splashFactory, + borderRadius: const BorderRadius.only(topRight: Radius.circular(7)), + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 12, 4, 12), + child: Row(children: [ + Text(zulipLocalizations.emojiReactionsMore, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.contextMenuItemText, + fontSize: 14, + ).merge(weightVariableTextStyle(context, wght: 600))), + Icon(ZulipIcons.chevron_right, + color: designVariables.contextMenuItemText, + size: 24), + ]), + )), + ]), + ); } } diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index ba0d003a8f..a1e5289b01 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -260,6 +260,7 @@ class _EmojiAutocompleteItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final candidate = option.candidate; + // TODO deduplicate this logic with [EmojiPickerListEntry] final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings); final Widget? glyph = switch (emojiDisplay) { ImageEmojiDisplay() => diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 39264eb3b6..98147a54d7 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../model/autocomplete.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 +366,256 @@ class _TextEmoji extends StatelessWidget { text); } } + +/// Adds or removes a reaction on the message corresponding to +/// the [messageId], showing an error dialog on failure. +/// Returns a Future resolving to true if operation succeeds. +Future doAddOrRemoveReaction({ + required BuildContext context, + required bool doRemoveReaction, + required int messageId, + required EmojiCandidate emoji, + required String errorDialogTitle, +}) async { + final store = PerAccountStoreWidget.of(context); + String? errorMessage; + try { + await (doRemoveReaction ? removeReaction : addReaction).call( + store.connection, + messageId: messageId, + reactionType: emoji.emojiType, + emojiCode: emoji.emojiCode, + emojiName: emoji.emojiName, + ); + } catch (e) { + if (!context.mounted) return; + + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO(#741) specific messages for common errors, like network errors + // (support with reusable code) + default: + // TODO(log) + } + + showErrorDialog(context: context, + title: errorDialogTitle, + message: errorMessage); + return; + } +} + +/// Opens a browsable and searchable emoji picker bottom sheet. +void showEmojiPickerSheet({ + required BuildContext pageContext, + required Message message, +}) { + final store = PerAccountStoreWidget.of(pageContext); + showModalBottomSheet( + context: pageContext, + // 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 context) { + return SafeArea( + child: Padding( + // By default, when software keyboard is opened, the ListView + // expands behind the software keyboard — resulting in some + // list entries being covered by the keyboard. Add explicit + // bottom padding the size of the keyboard, which fixes this. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + // For _EmojiPickerItem, and RealmContentNetworkImage used in ImageEmojiWidget. + child: PerAccountStoreWidget( + accountId: store.accountId, + child: EmojiPicker(pageContext: pageContext, message: message)))); + }); +} + +@visibleForTesting +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 TextEditingController _controller; + + EmojiAutocompleteView? _viewModel; + List _resultsToDisplay = const []; + + @override + void initState() { + super.initState(); + _controller = TextEditingController() + ..addListener(_handleControllerUpdate); + } + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + final query = EmojiAutocompleteQuery(_controller.text); + if (_viewModel != null) { + assert(_viewModel!.query == query); + _viewModel!.dispose(); + } + _viewModel = EmojiAutocompleteView.init(store: store, query: query) + ..addListener(_handleViewModelUpdate); + } + + void _handleControllerUpdate() { + _viewModel!.query = EmojiAutocompleteQuery(_controller.text); + } + + void _handleViewModelUpdate() { + setState(() { + _resultsToDisplay = List.unmodifiable(_viewModel!.results); + }); + } + + @override + void dispose() { + _viewModel?.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Column(children: [ + Padding(padding: const EdgeInsetsDirectional.only(start: 8, top: 4), + child: Row(children: [ + // TODO(design): Make sure if we need a button to clear the textfield. + Flexible(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + hintText: zulipLocalizations.emojiPickerSearchEmoji, + contentPadding: const EdgeInsetsDirectional.only(start: 10, top: 6), + filled: true, + fillColor: designVariables.bgSearchInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none), + hintStyle: TextStyle(color: designVariables.textMessage)), + style: const TextStyle(fontSize: 19, height: 26 / 19)))), + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + splashFactory: NoSplash.splashFactory, + foregroundColor: designVariables.contextMenuItemText, + ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => + states.contains(WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent)), + child: Text(zulipLocalizations.dialogClose, + style: const TextStyle(fontSize: 20, height: 30 / 20))), + ])), + Expanded(child: InsetShadowBox( + top: 8, bottom: 8, + color: designVariables.bgContextMenu, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _resultsToDisplay.length, + itemBuilder: (context, i) => EmojiPickerListEntry( + pageContext: widget.pageContext, + emoji: _resultsToDisplay[i].candidate, + message: widget.message)))), + ]); + } +} + +@visibleForTesting +class EmojiPickerListEntry extends StatelessWidget { + const EmojiPickerListEntry({ + super.key, + required this.pageContext, + required this.emoji, + required this.message, + }); + + final BuildContext pageContext; + final EmojiCandidate emoji; + final Message message; + + static const _emojiSize = 24.0; + static const _notoColorEmojiTextSize = 20.1; + + void _onPressed() { + // Dismiss the enclosing action sheet immediately, + // for swift UI feedback that the user's selection was received. + Navigator.pop(pageContext); + + doAddOrRemoveReaction( + context: pageContext, + doRemoveReaction: false, + messageId: message.id, + emoji: emoji, + errorDialogTitle: + ZulipLocalizations.of(pageContext).errorReactionAddingFailedTitle); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final designVariables = DesignVariables.of(context); + + // TODO deduplicate this logic with [_EmojiAutocompleteItem] + final emojiDisplay = emoji.emojiDisplay.resolve(store.userSettings); + final Widget? glyph = switch (emojiDisplay) { + ImageEmojiDisplay() => + ImageEmojiWidget(size: _emojiSize, emojiDisplay: emojiDisplay), + UnicodeEmojiDisplay() => + UnicodeEmojiWidget( + size: _emojiSize, notoColorEmojiTextSize: _notoColorEmojiTextSize, + emojiDisplay: emojiDisplay), + TextEmojiDisplay() => null, // The text is already shown separately. + }; + + final label = emoji.aliases.isEmpty + ? emoji.emojiName + : [emoji.emojiName, ...emoji.aliases].join(", "); // TODO(#1080) + + return InkWell( + onTap: _onPressed, + splashFactory: NoSplash.splashFactory, + overlayColor: WidgetStateColor.resolveWith((states) => + states.any((e) => e == WidgetState.pressed) + ? designVariables.contextMenuItemBg.withFadedAlpha(0.20) + : Colors.transparent), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(spacing: 4, children: [ + if (glyph != null) + Padding( + padding: const EdgeInsets.all(10), + child: glyph), + Flexible(child: Text(label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 17, + height: 18 / 17, + color: designVariables.textMessage))) + ]), + )); + } +} diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 5aaf1b9018..ec6cd81ce0 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -145,6 +145,8 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xfff0f0f0), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), + bgSearchInput: const Color(0xffe3e3e3), + textMessage: const Color(0xff262626), channelColorSwatches: ChannelColorSwatches.light, colorMessageHeaderIconInteractive: Colors.black.withValues(alpha: 0.2), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), @@ -193,6 +195,8 @@ class DesignVariables extends ThemeExtension { mainBackground: const Color(0xff1d1d1d), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff), + bgSearchInput: const Color(0xff313131), + textMessage: const Color(0xffffffff).withValues(alpha: 0.8), channelColorSwatches: ChannelColorSwatches.dark, contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma @@ -248,6 +252,8 @@ class DesignVariables extends ThemeExtension { required this.mainBackground, required this.textInput, required this.title, + required this.bgSearchInput, + required this.textMessage, required this.channelColorSwatches, required this.colorMessageHeaderIconInteractive, required this.contextMenuCancelBg, @@ -304,6 +310,8 @@ class DesignVariables extends ThemeExtension { final Color mainBackground; final Color textInput; final Color title; + final Color bgSearchInput; + final Color textMessage; // Not exactly from the Figma design, but from Vlad anyway. final ChannelColorSwatches channelColorSwatches; @@ -355,6 +363,8 @@ class DesignVariables extends ThemeExtension { Color? mainBackground, Color? textInput, Color? title, + Color? bgSearchInput, + Color? textMessage, ChannelColorSwatches? channelColorSwatches, Color? colorMessageHeaderIconInteractive, Color? contextMenuCancelBg, @@ -401,6 +411,8 @@ class DesignVariables extends ThemeExtension { mainBackground: mainBackground ?? this.mainBackground, textInput: textInput ?? this.textInput, title: title ?? this.title, + bgSearchInput: bgSearchInput ?? this.bgSearchInput, + textMessage: textMessage ?? this.textMessage, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, colorMessageHeaderIconInteractive: colorMessageHeaderIconInteractive ?? this.colorMessageHeaderIconInteractive, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, @@ -454,6 +466,8 @@ 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)!, + textMessage: Color.lerp(textMessage, other.textMessage, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), colorMessageHeaderIconInteractive: Color.lerp(colorMessageHeaderIconInteractive, other.colorMessageHeaderIconInteractive, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index d2b4b6f38d..4e4ae7d986 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -159,3 +159,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 f6b9fb1071..cf19bc3e9f 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -12,6 +12,7 @@ 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'; @@ -21,6 +22,7 @@ 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/emoji.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -384,47 +386,77 @@ void main() { }); }); - 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 - } + group('ReactionButtons', () { + final popularCandidates = EmojiStore.popularEmojiCandidates; - testWidgets('success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } - 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', - }); - }); + testWidgets('${emoji.emojiName} adding success', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - testWidgets('request has an error', (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); - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); }); - await tapButton(tester); - 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)'))); - }); + testWidgets('${emoji.emojiName} removing success', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester); + 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': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); + }); + + testWidgets('${emoji.emojiName} request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + 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 + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + }); + } }); group('StarButton', () { @@ -985,3 +1017,7 @@ void main() { }); }); } + +extension UnicodeEmojiWidgetChecks on Subject { + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +} diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 85b41f4002..58b4f64a77 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -8,17 +8,28 @@ 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'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/message_list.dart'; +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_images.dart'; import 'content_test.dart'; +import 'dialog_checks.dart'; import 'test_app.dart'; import 'text_test.dart'; @@ -26,6 +37,7 @@ void main() { TestZulipBinding.ensureInitialized(); late PerAccountStore store; + late FakeApiConnection connection; Future prepare() async { addTearDown(testBinding.reset); @@ -284,4 +296,184 @@ void main() { // - When a user isn't found, says "(unknown user)" // - More about layout? (not just that it's error-free) // - Non-animated image emoji is selected when intended + + group('EmojiPicker', () { + final popularCandidates = EmojiStore.popularEmojiCandidates; + + Future setupEmojiPicker(WidgetTester tester, { + required StreamMessage message, + required Narrow narrow, + }) async { + addTearDown(testBinding.reset); + assert(narrow.containsMessage(message)); + + final httpClient = FakeImageHttpClient(); + debugNetworkImageHttpClientProvider = () => httpClient; + httpClient.request.response + ..statusCode = HttpStatus.ok + ..content = kSolidBlueAvatar; + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.addUsers([ + eg.selfUser, + eg.user(userId: message.senderId), + ]); + final stream = eg.stream(streamId: message.streamId); + await store.addStream(stream); + await store.addSubscription(eg.subscription(stream)); + + connection = store.connection as FakeApiConnection; + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage(initNarrow: narrow))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + // request the message action sheet + await tester.longPress(find.byType(MessageContent)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + + 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'), + })); + + // request the emoji picker sheet + await tester.tap(find.byIcon(ZulipIcons.chevron_right)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(EmojiPicker)); + } + + final searchFieldFinder = find.widgetWithText(TextField, 'Search emoji'); + + Condition conditionEmojiListEntry({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName, + }) { + return (Subject it) => it.isA() + ..emoji.which((it) => it + ..emojiType.equals(emojiType) + ..emojiCode.equals(emojiCode) + ..emojiName.equals(emojiName)); + } + + List> arePopularEntries = popularCandidates.map((c) => + conditionEmojiListEntry( + emojiType: c.emojiType, + emojiCode: c.emojiCode, + emojiName: c.emojiName)).toList(); + + testWidgets('show, search', (tester) async { + final message = eg.streamMessage(); + await setupEmojiPicker(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + check(tester.widgetList(find.byType(EmojiPickerListEntry))).deepEquals([ + ...arePopularEntries, + conditionEmojiListEntry( + emojiType: ReactionType.realmEmoji, + emojiCode: '1', + emojiName: 'buzzing'), + conditionEmojiListEntry( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', + emojiName: 'zulip'), + conditionEmojiListEntry( + emojiType: ReactionType.unicodeEmoji, + emojiCode: '1f4a4', + emojiName: 'zzz'), + ]); + + await tester.enterText(searchFieldFinder, 'z'); + await tester.pump(); + + check(tester.widgetList(find.byType(EmojiPickerListEntry))).deepEquals([ + conditionEmojiListEntry( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', + emojiName: 'zulip'), + conditionEmojiListEntry( + emojiType: ReactionType.unicodeEmoji, + emojiCode: '1f4a4', + emojiName: 'zzz'), + conditionEmojiListEntry( + emojiType: ReactionType.realmEmoji, + emojiCode: '1', + emojiName: 'buzzing'), + ]); + + await tester.enterText(searchFieldFinder, 'zz'); + await tester.pump(); + + check(tester.widgetList(find.byType(EmojiPickerListEntry))).deepEquals([ + conditionEmojiListEntry( + emojiType: ReactionType.unicodeEmoji, + emojiCode: '1f4a4', + emojiName: 'zzz'), + conditionEmojiListEntry( + emojiType: ReactionType.realmEmoji, + emojiCode: '1', + emojiName: 'buzzing'), + ]); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('adding success', (tester) async { + final message = eg.streamMessage(); + await setupEmojiPicker(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text('\u{1f4a4}'))); // 'zzz' emoji + 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': '1f4a4', + 'emoji_name': 'zzz', + }); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('request has an error', (tester) async { + final message = eg.streamMessage(); + await setupEmojiPicker(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare( + delay: const Duration(seconds: 2), + httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text('\u{1f4a4}'))); // 'zzz' emoji + await tester.pump(); // register tap + await tester.pump(const Duration(seconds: 1)); // emoji picker animates away + await tester.pump(const Duration(seconds: 1)); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); + + debugNetworkImageHttpClientProvider = null; + }); + }); +} + +extension EmojiPickerListItemChecks on Subject { + Subject get emoji => has((x) => x.emoji, 'emoji'); }