Skip to content

Commit 46be522

Browse files
action_sheet: Support reacting with popular emojis
1 parent 3efe038 commit 46be522

File tree

3 files changed

+169
-57
lines changed

3 files changed

+169
-57
lines changed

lib/widgets/action_sheet.dart

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import '../api/exception.dart';
99
import '../api/model/model.dart';
1010
import '../api/route/messages.dart';
1111
import '../generated/l10n/zulip_localizations.dart';
12+
import '../model/emoji.dart';
1213
import '../model/internal_link.dart';
1314
import '../model/narrow.dart';
1415
import 'actions.dart';
1516
import 'clipboard.dart';
1617
import 'color.dart';
1718
import 'compose_box.dart';
1819
import 'dialog.dart';
20+
import 'emoji.dart';
1921
import 'icons.dart';
2022
import 'inset_shadow.dart';
2123
import 'message_list.dart';
@@ -41,16 +43,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes
4143
final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6)
4244
final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead;
4345

44-
final hasThumbsUpReactionVote = message.reactions
45-
?.aggregated.any((reactionWithVotes) =>
46-
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
47-
&& reactionWithVotes.emojiCode == '1f44d'
48-
&& reactionWithVotes.userIds.contains(store.selfUserId))
49-
?? false;
50-
5146
final optionButtons = [
52-
if (!hasThumbsUpReactionVote)
53-
AddThumbsUpButton(message: message, pageContext: context),
47+
ReactionButtons(message: message, pageContext: context),
5448
StarButton(message: message, pageContext: context),
5549
if (isComposeBoxOffered)
5650
QuoteAndReplyButton(message: message, pageContext: context),
@@ -182,27 +176,30 @@ class MessageActionSheetCancelButton extends StatelessWidget {
182176
}
183177
}
184178

185-
// This button is very temporary, to complete #125 before we have a way to
186-
// choose an arbitrary reaction (#388). So, skipping i18n.
187-
class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
188-
AddThumbsUpButton({super.key, required super.message, required super.pageContext});
179+
class ReactionButtons extends StatelessWidget {
180+
const ReactionButtons({
181+
super.key,
182+
required this.message,
183+
required this.pageContext,
184+
});
189185

190-
@override IconData get icon => ZulipIcons.smile;
186+
final Message message;
191187

192-
@override
193-
String label(ZulipLocalizations zulipLocalizations) {
194-
return 'React with 👍'; // TODO(i18n) skip translation for now
195-
}
188+
/// A context within the [MessageListPage] this action sheet was
189+
/// triggered from.
190+
final BuildContext pageContext;
196191

197-
@override void onPressed() async {
192+
void _onPressed(UnicodeEmojiDisplay emoji, bool selfVoted) async {
198193
String? errorMessage;
199194
try {
200-
await addReaction(PerAccountStoreWidget.of(pageContext).connection,
195+
await (selfVoted ? removeReaction : addReaction).call(
196+
PerAccountStoreWidget.of(pageContext).connection,
201197
messageId: message.id,
202198
reactionType: ReactionType.unicodeEmoji,
203-
emojiCode: '1f44d',
204-
emojiName: '+1',
199+
emojiCode: emoji.emojiCode,
200+
emojiName: emoji.emojiName,
205201
);
202+
if (pageContext.mounted) Navigator.pop(pageContext);
206203
} catch (e) {
207204
if (!pageContext.mounted) return;
208205

@@ -215,9 +212,51 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
215212
}
216213

217214
showErrorDialog(context: pageContext,
218-
title: 'Adding reaction failed', message: errorMessage);
215+
title: '${selfVoted ? 'Removing' : 'Adding'} reaction failed',
216+
message: errorMessage);
219217
}
220218
}
219+
220+
@override
221+
Widget build(BuildContext context) {
222+
final store = PerAccountStoreWidget.of(pageContext);
223+
final designVariables = DesignVariables.of(context);
224+
225+
bool hasSelfVote(UnicodeEmojiDisplay emoji) {
226+
return message.reactions?.aggregated.any((reactionWithVotes) {
227+
return reactionWithVotes.reactionType == ReactionType.unicodeEmoji
228+
&& tryParseEmojiCodeToUnicode(reactionWithVotes.emojiCode) == emoji.emojiUnicode
229+
&& reactionWithVotes.userIds.contains(store.selfUserId);
230+
}) ?? false;
231+
}
232+
233+
return Container(
234+
padding: const EdgeInsets.all(8),
235+
decoration: BoxDecoration(color: designVariables.contextMenuItemBg.withFadedAlpha(0.12)),
236+
child: Row(
237+
mainAxisAlignment: MainAxisAlignment.spaceAround,
238+
children: List.unmodifiable(popularUnicodeEmojis.map((emoji) {
239+
final selfVoted = hasSelfVote(emoji);
240+
return IconButton(
241+
onPressed: () => _onPressed(emoji, selfVoted),
242+
isSelected: selfVoted,
243+
style: IconButton.styleFrom(
244+
padding: EdgeInsets.zero,
245+
splashFactory: NoSplash.splashFactory,
246+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.5)),
247+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
248+
visualDensity: VisualDensity.compact,
249+
).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) =>
250+
states.any((e) => e == WidgetState.pressed || e == WidgetState.selected)
251+
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
252+
: Colors.transparent)),
253+
icon: UnicodeEmojiWidget(
254+
emojiDisplay: emoji,
255+
notoColorEmojiTextSize: 20.1,
256+
size: 24));
257+
})))
258+
);
259+
}
221260
}
222261

223262
class StarButton extends MessageActionSheetMenuItemButton {

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+
}

test/widgets/action_sheet_test.dart

Lines changed: 103 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import 'package:zulip/api/route/channels.dart';
1111
import 'package:zulip/api/route/messages.dart';
1212
import 'package:zulip/model/binding.dart';
1313
import 'package:zulip/model/compose.dart';
14+
import 'package:zulip/model/emoji.dart';
1415
import 'package:zulip/model/internal_link.dart';
1516
import 'package:zulip/model/localizations.dart';
1617
import 'package:zulip/model/narrow.dart';
1718
import 'package:zulip/model/store.dart';
1819
import 'package:zulip/model/typing_status.dart';
20+
import 'package:zulip/widgets/action_sheet.dart';
1921
import 'package:zulip/widgets/compose_box.dart';
2022
import 'package:zulip/widgets/content.dart';
23+
import 'package:zulip/widgets/emoji.dart';
2124
import 'package:zulip/widgets/icons.dart';
2225
import 'package:zulip/widgets/message_list.dart';
2326
import 'package:share_plus_platform_interface/method_channel/method_channel_share.dart';
@@ -26,6 +29,7 @@ import '../api/fake_api.dart';
2629
import '../example_data.dart' as eg;
2730
import '../flutter_checks.dart';
2831
import '../model/binding.dart';
32+
import '../model/emoji_test.dart';
2933
import '../model/test_store.dart';
3034
import '../stdlib_checks.dart';
3135
import '../test_clipboard.dart';
@@ -99,46 +103,107 @@ void main() {
99103
connection.prepare(httpStatus: 400, json: fakeResponseJson);
100104
}
101105

102-
group('AddThumbsUpButton', () {
103-
Future<void> tapButton(WidgetTester tester) async {
104-
await tester.ensureVisible(find.byIcon(ZulipIcons.smile, skipOffstage: false));
105-
await tester.tap(find.byIcon(ZulipIcons.smile));
106-
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
107-
}
108106

109-
testWidgets('success', (tester) async {
110-
final message = eg.streamMessage();
111-
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
107+
group('ReactionButtons', () {
108+
group('popular emoji reactions;', () {
109+
testWidgets('ensure all are shown', (tester) async {
110+
final message = eg.streamMessage();
111+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
112112

113-
connection.prepare(json: {});
114-
await tapButton(tester);
115-
await tester.pump(Duration.zero);
113+
check(popularUnicodeEmojis).length.equals(6);
114+
115+
// Ensure there are only 6 buttons.
116+
final buttons = tester.widgetList<IconButton>(find.descendant(
117+
of: find.byType(ReactionButtons) ,
118+
matching: find.byType(IconButton)));
119+
check(buttons).length.equals(6);
120+
121+
// Ensure all are unicode emoji buttons.
122+
final emojis = tester.widgetList<UnicodeEmojiWidget>(find.descendant(
123+
of: find.ancestor(
124+
of: find.byType(IconButton),
125+
matching: find.byType(ReactionButtons)),
126+
matching: find.byType(UnicodeEmojiWidget)));
127+
check(emojis).length.equals(6);
128+
check(emojis).deepEquals(popularUnicodeEmojis.map<Condition<Object?>>((emoji) {
129+
return (it) => it.isA<UnicodeEmojiWidget>()
130+
..emojiDisplay.which((it) => it
131+
..emojiName.equals(emoji.emojiName)
132+
..emojiUnicode.equals(emoji.emojiUnicode)
133+
..emojiCode.equals(emoji.emojiCode));
134+
}));
135+
});
116136

117-
check(connection.lastRequest).isA<http.Request>()
118-
..method.equals('POST')
119-
..url.path.equals('/api/v1/messages/${message.id}/reactions')
120-
..bodyFields.deepEquals({
121-
'reaction_type': 'unicode_emoji',
122-
'emoji_code': '1f44d',
123-
'emoji_name': '+1',
124-
});
125-
});
137+
for (final popularEmoji in popularUnicodeEmojis) {
138+
Future<void> tapButton(WidgetTester tester, {required bool isSelected}) async {
139+
final finder = find.ancestor(
140+
of: find.text(popularEmoji.emojiUnicode),
141+
matching: find.byType(IconButton));
142+
143+
check(tester.widget<IconButton>(finder))
144+
.isSelected.equals(isSelected);
145+
await tester.tap(finder);
146+
}
147+
148+
testWidgets('${popularEmoji.emojiName} adding success', (tester) async {
149+
final message = eg.streamMessage();
150+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
151+
152+
connection.prepare(json: {});
153+
await tapButton(tester, isSelected: false);
154+
await tester.pump(Duration.zero);
155+
156+
check(connection.lastRequest).isA<http.Request>()
157+
..method.equals('POST')
158+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
159+
..bodyFields.deepEquals({
160+
'reaction_type': 'unicode_emoji',
161+
'emoji_code': popularEmoji.emojiCode,
162+
'emoji_name': popularEmoji.emojiName,
163+
});
164+
});
126165

127-
testWidgets('request has an error', (tester) async {
128-
final message = eg.streamMessage();
129-
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
166+
testWidgets('${popularEmoji.emojiName} removing success', (tester) async {
167+
final message = eg.streamMessage(
168+
reactions: [Reaction(
169+
emojiName: popularEmoji.emojiName,
170+
emojiCode: popularEmoji.emojiCode,
171+
reactionType: ReactionType.unicodeEmoji,
172+
userId: eg.selfAccount.userId)]
173+
);
174+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
175+
176+
connection.prepare(json: {});
177+
await tapButton(tester, isSelected: true);
178+
await tester.pump(Duration.zero);
179+
180+
check(connection.lastRequest).isA<http.Request>()
181+
..method.equals('DELETE')
182+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
183+
..bodyFields.deepEquals({
184+
'reaction_type': 'unicode_emoji',
185+
'emoji_code': popularEmoji.emojiCode,
186+
'emoji_name': popularEmoji.emojiName,
187+
});
188+
});
130189

131-
connection.prepare(httpStatus: 400, json: {
132-
'code': 'BAD_REQUEST',
133-
'msg': 'Invalid message(s)',
134-
'result': 'error',
135-
});
136-
await tapButton(tester);
137-
await tester.pump(Duration.zero); // error arrives; error dialog shows
190+
testWidgets('${popularEmoji.emojiName} request has an error', (tester) async {
191+
final message = eg.streamMessage();
192+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
138193

139-
await tester.tap(find.byWidget(checkErrorDialog(tester,
140-
expectedTitle: 'Adding reaction failed',
141-
expectedMessage: 'Invalid message(s)')));
194+
connection.prepare(httpStatus: 400, json: {
195+
'code': 'BAD_REQUEST',
196+
'msg': 'Invalid message(s)',
197+
'result': 'error',
198+
});
199+
await tapButton(tester, isSelected: false);
200+
await tester.pump(Duration.zero); // error arrives; error dialog shows
201+
202+
await tester.tap(find.byWidget(checkErrorDialog(tester,
203+
expectedTitle: 'Adding reaction failed',
204+
expectedMessage: 'Invalid message(s)')));
205+
});
206+
}
142207
});
143208
});
144209

@@ -700,3 +765,7 @@ void main() {
700765
});
701766
});
702767
}
768+
769+
extension UnicodeEmojiWidgetChecks on Subject<UnicodeEmojiWidget> {
770+
Subject<UnicodeEmojiDisplay> get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay');
771+
}

0 commit comments

Comments
 (0)