Skip to content

Commit 4615ee1

Browse files
action_sheet: Support reacting with popular emojis
1 parent d50e048 commit 4615ee1

9 files changed

+240
-72
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
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

Lines changed: 12 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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

Lines changed: 87 additions & 38 deletions
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({required BuildContext context, required Message mes
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,94 @@ 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 _handleTapReaction({
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+
Widget _buildButton({
226+
required BuildContext context,
227+
required EmojiCandidate emoji,
228+
required bool isSelfVoted,
229+
required bool isFirst,
230+
}) {
231+
final designVariables = DesignVariables.of(context);
232+
return Flexible(child: InkWell(
233+
onTap: () => _handleTapReaction(emoji: emoji, isSelfVoted: isSelfVoted),
234+
splashFactory: NoSplash.splashFactory,
235+
borderRadius: isFirst
236+
? const BorderRadius.only(topLeft: Radius.circular(7))
237+
: null,
238+
overlayColor: WidgetStateColor.resolveWith((states) =>
239+
states.any((e) => e == WidgetState.pressed)
240+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
241+
: Colors.transparent),
242+
child: Container(
243+
width: double.infinity,
244+
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 5),
245+
alignment: Alignment.center,
246+
color: isSelfVoted
247+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
248+
: null,
249+
child: UnicodeEmojiWidget(
250+
emojiDisplay: emoji.emojiDisplay as UnicodeEmojiDisplay,
251+
notoColorEmojiTextSize: 20.1,
252+
size: 24))));
253+
}
220254

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-
}
255+
@override
256+
Widget build(BuildContext context) {
257+
assert(EmojiStore.popularEmojiCandidates.every(
258+
(emoji) => emoji.emojiType == ReactionType.unicodeEmoji));
228259

229-
showErrorDialog(context: pageContext,
230-
title: 'Adding reaction failed', message: errorMessage);
260+
final store = PerAccountStoreWidget.of(pageContext);
261+
final designVariables = DesignVariables.of(context);
262+
263+
bool hasSelfVote(EmojiCandidate emoji) {
264+
return message.reactions?.aggregated.any((reactionWithVotes) {
265+
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
266+
&& reactionWithVotes.emojiCode == emoji.emojiCode
267+
&& reactionWithVotes.userIds.contains(store.selfUserId);
268+
}) ?? false;
231269
}
270+
271+
return Container(
272+
decoration: BoxDecoration(
273+
color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
274+
child: Row(spacing: 1, children: List.unmodifiable(
275+
EmojiStore.popularEmojiCandidates.mapIndexed((index, emoji) =>
276+
_buildButton(
277+
context: context,
278+
emoji: emoji,
279+
isSelfVoted: hasSelfVote(emoji),
280+
isFirst: index == 0)))));
232281
}
233282
}
234283

lib/widgets/emoji_reaction.dart

Lines changed: 41 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
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)