Skip to content

Commit f32cc98

Browse files
rajveermalviyagnprice
authored andcommitted
action_sheet: Support reacting with popular emojis
1 parent afb3215 commit f32cc98

12 files changed

+258
-72
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -635,5 +635,13 @@
635635
"errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.",
636636
"@errorNotificationOpenAccountMissing": {
637637
"description": "Error message when the account associated with the notification is not found"
638+
},
639+
"errorReactionAddingFailedTitle": "Adding reaction failed",
640+
"@errorReactionAddingFailedTitle": {
641+
"description": "Error title when adding a message reaction fails"
642+
},
643+
"errorReactionRemovingFailedTitle": "Removing reaction failed",
644+
"@errorReactionRemovingFailedTitle": {
645+
"description": "Error title when removing a message reaction fails"
638646
}
639647
}

lib/generated/l10n/zulip_localizations.dart

+12
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,18 @@ abstract class ZulipLocalizations {
948948
/// In en, this message translates to:
949949
/// **'The account associated with this notification no longer exists.'**
950950
String get errorNotificationOpenAccountMissing;
951+
952+
/// Error title when adding a message reaction fails
953+
///
954+
/// In en, this message translates to:
955+
/// **'Adding reaction failed'**
956+
String get errorReactionAddingFailedTitle;
957+
958+
/// Error title when removing a message reaction fails
959+
///
960+
/// In en, this message translates to:
961+
/// **'Removing reaction failed'**
962+
String get errorReactionRemovingFailedTitle;
951963
}
952964

953965
class _ZulipLocalizationsDelegate extends LocalizationsDelegate<ZulipLocalizations> {

lib/generated/l10n/zulip_localizations_ar.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/generated/l10n/zulip_localizations_en.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/generated/l10n/zulip_localizations_fr.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/generated/l10n/zulip_localizations_ja.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/generated/l10n/zulip_localizations_pl.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'Konto związane z tym powiadomieniem już nie istnieje.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/generated/l10n/zulip_localizations_ru.dart

+6
Original file line numberDiff line numberDiff line change
@@ -502,4 +502,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
502502

503503
@override
504504
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
505+
506+
@override
507+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
508+
509+
@override
510+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
505511
}

lib/widgets/action_sheet.dart

+87-38
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22

3+
import 'package:collection/collection.dart';
34
import 'package:flutter/foundation.dart';
45
import 'package:flutter/material.dart';
56
import 'package:flutter/services.dart';
@@ -10,13 +11,16 @@ import '../api/model/model.dart';
1011
import '../api/route/channels.dart';
1112
import '../api/route/messages.dart';
1213
import '../generated/l10n/zulip_localizations.dart';
14+
import '../model/emoji.dart';
1315
import '../model/internal_link.dart';
1416
import '../model/narrow.dart';
1517
import 'actions.dart';
1618
import 'clipboard.dart';
1719
import 'color.dart';
1820
import 'compose_box.dart';
1921
import 'dialog.dart';
22+
import 'emoji.dart';
23+
import 'emoji_reaction.dart';
2024
import 'icons.dart';
2125
import 'inset_shadow.dart';
2226
import 'message_list.dart';
@@ -26,7 +30,7 @@ import 'theme.dart';
2630

2731
void _showActionSheet(
2832
BuildContext context, {
29-
required List<ActionSheetMenuItemButton> optionButtons,
33+
required List<Widget> optionButtons,
3034
}) {
3135
showModalBottomSheet<void>(
3236
context: context,
@@ -383,16 +387,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
383387
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
384388
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
385389

386-
final hasThumbsUpReactionVote = message.reactions
387-
?.aggregated.any((reactionWithVotes) =>
388-
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
389-
&& reactionWithVotes.emojiCode == '1f44d'
390-
&& reactionWithVotes.userIds.contains(store.selfUserId))
391-
?? false;
392-
393390
final optionButtons = [
394-
if (!hasThumbsUpReactionVote)
395-
AddThumbsUpButton(message: message, pageContext: context),
391+
ReactionButtons(message: message, pageContext: context),
396392
StarButton(message: message, pageContext: context),
397393
if (isComposeBoxOffered)
398394
QuoteAndReplyButton(message: message, pageContext: context),
@@ -416,41 +412,94 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto
416412
final Message message;
417413
}
418414

419-
// This button is very temporary, to complete #125 before we have a way to
420-
// choose an arbitrary reaction (#388). So, skipping i18n.
421-
class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
422-
AddThumbsUpButton({super.key, required super.message, required super.pageContext});
415+
class ReactionButtons extends StatelessWidget {
416+
const ReactionButtons({
417+
super.key,
418+
required this.message,
419+
required this.pageContext,
420+
});
423421

424-
@override IconData get icon => ZulipIcons.smile;
422+
final Message message;
425423

426-
@override
427-
String label(ZulipLocalizations zulipLocalizations) {
428-
return 'React with 👍'; // TODO(i18n) skip translation for now
424+
/// A context within the [MessageListPage] this action sheet was
425+
/// triggered from.
426+
final BuildContext pageContext;
427+
428+
void _handleTapReaction({
429+
required EmojiCandidate emoji,
430+
required bool isSelfVoted,
431+
}) {
432+
// Dismiss the enclosing action sheet immediately,
433+
// for swift UI feedback that the user's selection was received.
434+
Navigator.pop(pageContext);
435+
436+
final zulipLocalizations = ZulipLocalizations.of(pageContext);
437+
doAddOrRemoveReaction(
438+
context: pageContext,
439+
doRemoveReaction: isSelfVoted,
440+
messageId: message.id,
441+
emoji: emoji,
442+
errorDialogTitle: isSelfVoted
443+
? zulipLocalizations.errorReactionRemovingFailedTitle
444+
: zulipLocalizations.errorReactionAddingFailedTitle);
429445
}
430446

431-
@override void onPressed() async {
432-
String? errorMessage;
433-
try {
434-
await addReaction(PerAccountStoreWidget.of(pageContext).connection,
435-
messageId: message.id,
436-
reactionType: ReactionType.unicodeEmoji,
437-
emojiCode: '1f44d',
438-
emojiName: '+1',
439-
);
440-
} catch (e) {
441-
if (!pageContext.mounted) return;
447+
Widget _buildButton({
448+
required BuildContext context,
449+
required EmojiCandidate emoji,
450+
required bool isSelfVoted,
451+
required bool isFirst,
452+
}) {
453+
final designVariables = DesignVariables.of(context);
454+
return Flexible(child: InkWell(
455+
onTap: () => _handleTapReaction(emoji: emoji, isSelfVoted: isSelfVoted),
456+
splashFactory: NoSplash.splashFactory,
457+
borderRadius: isFirst
458+
? const BorderRadius.only(topLeft: Radius.circular(7))
459+
: null,
460+
overlayColor: WidgetStateColor.resolveWith((states) =>
461+
states.any((e) => e == WidgetState.pressed)
462+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
463+
: Colors.transparent),
464+
child: Container(
465+
width: double.infinity,
466+
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5),
467+
alignment: Alignment.center,
468+
color: isSelfVoted
469+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
470+
: null,
471+
child: UnicodeEmojiWidget(
472+
emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay,
473+
notoColorEmojiTextSize: 20.1,
474+
size: 24))));
475+
}
442476

443-
switch (e) {
444-
case ZulipApiException():
445-
errorMessage = e.message;
446-
// TODO(#741) specific messages for common errors, like network errors
447-
// (support with reusable code)
448-
default:
449-
}
477+
@override
478+
Widget build(BuildContext context) {
479+
assert(EmojiStore.popularEmojiCandidates.every(
480+
(emoji) => emoji.emojiType == ReactionType.unicodeEmoji));
450481

451-
showErrorDialog(context: pageContext,
452-
title: 'Adding reaction failed', message: errorMessage);
482+
final store = PerAccountStoreWidget.of(pageContext);
483+
final designVariables = DesignVariables.of(context);
484+
485+
bool hasSelfVote(EmojiCandidate emoji) {
486+
return message.reactions?.aggregated.any((reactionWithVotes) {
487+
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
488+
&& reactionWithVotes.emojiCode == emoji.emojiCode
489+
&& reactionWithVotes.userIds.contains(store.selfUserId);
490+
}) ?? false;
453491
}
492+
493+
return Container(
494+
decoration: BoxDecoration(
495+
color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
496+
child: Row(spacing: 1, children: List.unmodifiable(
497+
EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) =>
498+
_buildButton(
499+
context: context,
500+
emoji: emoji,
501+
isSelfVoted: hasSelfVote(emoji),
502+
isFirst: index == 0)))));
454503
}
455504
}
456505

lib/widgets/emoji_reaction.dart

+41
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import 'package:flutter/material.dart';
22

3+
import '../api/exception.dart';
34
import '../api/model/model.dart';
45
import '../api/route/messages.dart';
56
import '../model/emoji.dart';
67
import 'color.dart';
8+
import 'dialog.dart';
79
import 'emoji.dart';
810
import 'store.dart';
911
import 'text.dart';
@@ -360,3 +362,42 @@ class _TextEmoji extends StatelessWidget {
360362
text);
361363
}
362364
}
365+
366+
/// Adds or removes a reaction on the message corresponding to
367+
/// the [messageId], showing an error dialog on failure.
368+
/// Returns a Future resolving to true if operation succeeds.
369+
Future<void> doAddOrRemoveReaction({
370+
required BuildContext context,
371+
required bool doRemoveReaction,
372+
required int messageId,
373+
required EmojiCandidate emoji,
374+
required String errorDialogTitle,
375+
}) async {
376+
final store = PerAccountStoreWidget.of(context);
377+
String? errorMessage;
378+
try {
379+
await (doRemoveReaction ? removeReaction : addReaction).call(
380+
store.connection,
381+
messageId: messageId,
382+
reactionType: emoji.emojiType,
383+
emojiCode: emoji.emojiCode,
384+
emojiName: emoji.emojiName,
385+
);
386+
} catch (e) {
387+
if (!context.mounted) return;
388+
389+
switch (e) {
390+
case ZulipApiException():
391+
errorMessage = e.message;
392+
// TODO(#741) specific messages for common errors, like network errors
393+
// (support with reusable code)
394+
default:
395+
// TODO(log)
396+
}
397+
398+
showErrorDialog(context: context,
399+
title: errorDialogTitle,
400+
message: errorMessage);
401+
return;
402+
}
403+
}

test/flutter_checks.dart

+4
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,7 @@ extension TableRowChecks on Subject<TableRow> {
158158
extension TableChecks on Subject<Table> {
159159
Subject<List<TableRow>> get children => has((x) => x.children, 'children');
160160
}
161+
162+
extension IconButtonChecks on Subject<IconButton> {
163+
Subject<bool?> get isSelected => has((x) => x.isSelected, 'isSelected');
164+
}

0 commit comments

Comments
 (0)