Skip to content

Commit 7199ab5

Browse files
action_sheet: Support reacting with popular emojis
1 parent 7b22eb1 commit 7199ab5

9 files changed

+255
-72
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -603,5 +603,13 @@
603603
"errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.",
604604
"@errorNotificationOpenAccountMissing": {
605605
"description": "Error message when the account associated with the notification is not found"
606+
},
607+
"errorReactionAddingFailedTitle": "Adding reaction failed",
608+
"@errorReactionAddingFailedTitle": {
609+
"description": "Error title when adding a message reaction fails"
610+
},
611+
"errorReactionRemovingFailedTitle": "Removing reaction failed",
612+
"@errorReactionRemovingFailedTitle": {
613+
"description": "Error title when removing a message reaction fails"
606614
}
607615
}

lib/generated/l10n/zulip_localizations.dart

+12
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,18 @@ abstract class ZulipLocalizations {
894894
/// In en, this message translates to:
895895
/// **'The account associated with this notification no longer exists.'**
896896
String get errorNotificationOpenAccountMissing;
897+
898+
/// Error title when adding a message reaction fails
899+
///
900+
/// In en, this message translates to:
901+
/// **'Adding reaction failed'**
902+
String get errorReactionAddingFailedTitle;
903+
904+
/// Error title when removing a message reaction fails
905+
///
906+
/// In en, this message translates to:
907+
/// **'Removing reaction failed'**
908+
String get errorReactionRemovingFailedTitle;
897909
}
898910

899911
class _ZulipLocalizationsDelegate extends LocalizationsDelegate<ZulipLocalizations> {

lib/generated/l10n/zulip_localizations_ar.dart

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

479479
@override
480480
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
481+
482+
@override
483+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
484+
485+
@override
486+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
481487
}

lib/generated/l10n/zulip_localizations_en.dart

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

479479
@override
480480
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
481+
482+
@override
483+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
484+
485+
@override
486+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
481487
}

lib/generated/l10n/zulip_localizations_ja.dart

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

479479
@override
480480
String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.';
481+
482+
@override
483+
String get errorReactionAddingFailedTitle => 'Adding reaction failed';
484+
485+
@override
486+
String get errorReactionRemovingFailedTitle => 'Removing reaction failed';
481487
}

lib/widgets/action_sheet.dart

+75-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';
@@ -9,13 +10,16 @@ import '../api/exception.dart';
910
import '../api/model/model.dart';
1011
import '../api/route/messages.dart';
1112
import '../generated/l10n/zulip_localizations.dart';
13+
import '../model/emoji.dart';
1214
import '../model/internal_link.dart';
1315
import '../model/narrow.dart';
1416
import 'actions.dart';
1517
import 'clipboard.dart';
1618
import 'color.dart';
1719
import 'compose_box.dart';
1820
import 'dialog.dart';
21+
import 'emoji.dart';
22+
import 'emoji_reaction.dart';
1923
import 'icons.dart';
2024
import 'inset_shadow.dart';
2125
import 'message_list.dart';
@@ -25,7 +29,7 @@ import 'theme.dart';
2529

2630
void _showActionSheet(
2731
BuildContext context, {
28-
required List<ActionSheetMenuItemButton> optionButtons,
32+
required List<Widget> optionButtons,
2933
}) {
3034
showModalBottomSheet<void>(
3135
context: context,
@@ -161,16 +165,8 @@ void showMessageActionSheet(BuildContext context, {required Message message}) {
161165
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
162166
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
163167

164-
final hasThumbsUpReactionVote = message.reactions
165-
?.aggregated.any((reactionWithVotes) =>
166-
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
167-
&& reactionWithVotes.emojiCode == '1f44d'
168-
&& reactionWithVotes.userIds.contains(store.selfUserId))
169-
?? false;
170-
171168
final optionButtons = [
172-
if (!hasThumbsUpReactionVote)
173-
AddThumbsUpButton(message: message, pageContext: context),
169+
ReactionButtons(message: message, pageContext: context),
174170
StarButton(message: message, pageContext: context),
175171
if (isComposeBoxOffered)
176172
QuoteAndReplyButton(message: message, pageContext: context),
@@ -194,41 +190,82 @@ abstract class MessageActionSheetMenuItemButton extends ActionSheetMenuItemButto
194190
final Message message;
195191
}
196192

197-
// This button is very temporary, to complete #125 before we have a way to
198-
// choose an arbitrary reaction (#388). So, skipping i18n.
199-
class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
200-
AddThumbsUpButton({super.key, required super.message, required super.pageContext});
193+
class ReactionButtons extends StatelessWidget {
194+
const ReactionButtons({
195+
super.key,
196+
required this.message,
197+
required this.pageContext,
198+
});
201199

202-
@override IconData get icon => ZulipIcons.smile;
200+
final Message message;
203201

204-
@override
205-
String label(ZulipLocalizations zulipLocalizations) {
206-
return 'React with 👍'; // TODO(i18n) skip translation for now
202+
/// A context within the [MessageListPage] this action sheet was
203+
/// triggered from.
204+
final BuildContext pageContext;
205+
206+
void _onReactionPressed({
207+
required EmojiCandidate emoji,
208+
required bool isSelfVoted,
209+
}) {
210+
// Dismiss the enclosing action sheet immediately,
211+
// for swift UI feedback that the user's selection was received.
212+
Navigator.pop(pageContext);
213+
214+
final zulipLocalizations = ZulipLocalizations.of(pageContext);
215+
doAddOrRemoveReaction(
216+
context: pageContext,
217+
doRemoveReaction: isSelfVoted,
218+
messageId: message.id,
219+
emoji: emoji,
220+
errorDialogTitle: isSelfVoted
221+
? zulipLocalizations.errorReactionRemovingFailedTitle
222+
: zulipLocalizations.errorReactionAddingFailedTitle);
207223
}
208224

209-
@override void onPressed() async {
210-
String? errorMessage;
211-
try {
212-
await addReaction(PerAccountStoreWidget.of(pageContext).connection,
213-
messageId: message.id,
214-
reactionType: ReactionType.unicodeEmoji,
215-
emojiCode: '1f44d',
216-
emojiName: '+1',
217-
);
218-
} catch (e) {
219-
if (!pageContext.mounted) return;
225+
@override
226+
Widget build(BuildContext context) {
227+
assert(EmojiStore.popularEmojiCandidates.every(
228+
(emoji) => emoji.emojiType == ReactionType.unicodeEmoji));
220229

221-
switch (e) {
222-
case ZulipApiException():
223-
errorMessage = e.message;
224-
// TODO(#741) specific messages for common errors, like network errors
225-
// (support with reusable code)
226-
default:
227-
}
230+
final store = PerAccountStoreWidget.of(pageContext);
231+
final designVariables = DesignVariables.of(context);
228232

229-
showErrorDialog(context: pageContext,
230-
title: 'Adding reaction failed', message: errorMessage);
233+
bool hasSelfVote(EmojiCandidate emoji) {
234+
return message.reactions?.aggregated.any((reactionWithVotes) {
235+
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
236+
&& reactionWithVotes.emojiCode == emoji.emojiCode
237+
&& reactionWithVotes.userIds.contains(store.selfUserId);
238+
}) ?? false;
231239
}
240+
241+
return Container(
242+
decoration: BoxDecoration(color: designVariables.contextMenuCancelBg),
243+
child: Row(
244+
spacing: 1,
245+
children: List.unmodifiable(EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) {
246+
final isSelfVoted = hasSelfVote(emoji);
247+
return Flexible(child: InkWell(
248+
onTap: () => _onReactionPressed(emoji: emoji, isSelfVoted: isSelfVoted),
249+
splashFactory: NoSplash.splashFactory,
250+
borderRadius: index == 0
251+
? const BorderRadius.only(topLeft: Radius.circular(7))
252+
: null,
253+
overlayColor: WidgetStateColor.resolveWith((states) =>
254+
states.any((e) => e == WidgetState.pressed)
255+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
256+
: Colors.transparent),
257+
child: Container(
258+
width: double.infinity,
259+
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5),
260+
alignment: Alignment.center,
261+
color: isSelfVoted
262+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
263+
: null,
264+
child: UnicodeEmojiWidget(
265+
emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay,
266+
notoColorEmojiTextSize: 20.1,
267+
size: 24))));
268+
}))));
232269
}
233270
}
234271

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)