Skip to content

Commit 3ee882d

Browse files
msglist: Add double-tap to toggle thumbs up reaction.
Add ability to double-tap messages to quickly add/remove 👍 reactions Fixes #969.
1 parent 449f326 commit 3ee882d

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed

lib/widgets/message_list.dart

+24
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
77

88
import '../api/model/model.dart';
99
import '../generated/l10n/zulip_localizations.dart';
10+
import '../model/emoji.dart';
1011
import '../model/message_list.dart';
1112
import '../model/narrow.dart';
1213
import '../model/store.dart';
@@ -1365,6 +1366,29 @@ class MessageWithPossibleSender extends StatelessWidget {
13651366

13661367
return GestureDetector(
13671368
behavior: HitTestBehavior.translucent,
1369+
onDoubleTap: () {
1370+
final store = PerAccountStoreWidget.of(context);
1371+
// First emoji in popular Candidates is thumbs up
1372+
final thumbsUpEmoji = EmojiStore.popularEmojiCandidates.toList()[0];
1373+
1374+
// Check if the user has already reacted with thumbs up
1375+
final isSelfVoted = message.reactions?.aggregated.any((reactionWithVotes) =>
1376+
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
1377+
&& reactionWithVotes.emojiCode == thumbsUpEmoji.emojiCode
1378+
&& reactionWithVotes.userIds.contains(store.selfUserId)) ?? false;
1379+
1380+
final zulipLocalizations = ZulipLocalizations.of(context);
1381+
1382+
// Add or remove reaction based on whether the user has already reacted with thumbs up
1383+
doAddOrRemoveReaction(
1384+
context: context,
1385+
doRemoveReaction: isSelfVoted,
1386+
messageId: message.id,
1387+
emoji: thumbsUpEmoji,
1388+
errorDialogTitle: isSelfVoted
1389+
? zulipLocalizations.errorReactionRemovingFailedTitle
1390+
: zulipLocalizations.errorReactionAddingFailedTitle);
1391+
},
13681392
onLongPress: () => showMessageActionSheet(context: context, message: message),
13691393
child: Padding(
13701394
padding: const EdgeInsets.symmetric(vertical: 4),

test/widgets/message_list_test.dart

+138
Original file line numberDiff line numberDiff line change
@@ -1344,4 +1344,142 @@ void main() {
13441344
..status.equals(AnimationStatus.dismissed);
13451345
});
13461346
});
1347+
1348+
group('double-tap gesture', () {
1349+
Future<void> setupMessageWithReactions(WidgetTester tester, {
1350+
required StreamMessage message,
1351+
required Narrow narrow,
1352+
List<Reaction>? reactions,
1353+
}) async {
1354+
addTearDown(testBinding.reset); // reset the test binding
1355+
assert(narrow.containsMessage(message)); // check that the narrow contains the message
1356+
1357+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); // add the self account
1358+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id); // get the per account store
1359+
await store.addUsers([
1360+
eg.selfUser,
1361+
eg.user(userId: message.senderId),
1362+
]); // add the self user and the message sender
1363+
final stream = eg.stream(streamId: message.streamId); // create the stream
1364+
await store.addStream(stream); // add the stream
1365+
await store.addSubscription(eg.subscription(stream)); // add the subscription
1366+
1367+
connection = store.connection as FakeApiConnection; // get the fake api connection
1368+
connection.prepare(json: eg.newestGetMessagesResult(
1369+
foundOldest: true, messages: [message]).toJson()); // prepare the response for the message
1370+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1371+
child: MessageListPage(initNarrow: narrow)),
1372+
); // pump the widget
1373+
1374+
await tester.pumpAndSettle();
1375+
}
1376+
1377+
testWidgets('add thumbs up reaction on double-tap', (tester) async {
1378+
final message = eg.streamMessage(); // create a message without any reactions
1379+
await setupMessageWithReactions(tester,
1380+
message: message,
1381+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1382+
1383+
connection.prepare(json: {}); // prepare the response for the reaction
1384+
await tester.pump(); // pump the widget to make the reaction visible
1385+
1386+
final messageContent = find.byType(MessageContent); // find the message content
1387+
await tester.tap(messageContent); // first tap
1388+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1389+
await tester.tap(messageContent); // second tap
1390+
await tester.pumpAndSettle(); // wait for the reaction to be added
1391+
1392+
check(connection.lastRequest).isA<http.Request>()
1393+
..method.equals('POST')
1394+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
1395+
..bodyFields.deepEquals({
1396+
'reaction_type': 'unicode_emoji',
1397+
'emoji_code': '1f44d', // thumbs up emoji code
1398+
'emoji_name': '+1',
1399+
}); // check the last request
1400+
});
1401+
1402+
testWidgets('remove thumbs up reaction on double-tap when already reacted', (tester) async {
1403+
final message = eg.streamMessage(reactions: [
1404+
Reaction(
1405+
emojiName: '+1',
1406+
emojiCode: '1f44d',
1407+
reactionType: ReactionType.unicodeEmoji,
1408+
userId: eg.selfAccount.userId)
1409+
]); // create a message with a thumbs up reaction
1410+
await setupMessageWithReactions(tester,
1411+
message: message,
1412+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1413+
1414+
connection.prepare(json: {}); // prepare the response for the reaction
1415+
await tester.pump(); // pump the widget to make the reaction visible
1416+
1417+
final messageContent = find.byType(MessageContent); // find the message content
1418+
await tester.tap(messageContent); // first tap
1419+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1420+
await tester.tap(messageContent); // second tap
1421+
await tester.pumpAndSettle(); // wait for the reaction to be removed
1422+
1423+
check(connection.lastRequest).isA<http.Request>()
1424+
..method.equals('DELETE')
1425+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
1426+
..bodyFields.deepEquals({
1427+
'reaction_type': 'unicode_emoji',
1428+
'emoji_code': '1f44d',
1429+
'emoji_name': '+1',
1430+
}); // check the last request
1431+
});
1432+
1433+
testWidgets('shows error dialog when adding reaction fails', (tester) async {
1434+
final message = eg.streamMessage();
1435+
await setupMessageWithReactions(tester,
1436+
message: message,
1437+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1438+
1439+
connection.prepare(httpStatus: 400, json: {
1440+
'code': 'BAD_REQUEST',
1441+
'msg': 'Invalid message(s)',
1442+
'result': 'error',
1443+
});
1444+
1445+
final messageContent = find.byType(MessageContent);
1446+
await tester.tap(messageContent); // first tap
1447+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1448+
await tester.tap(messageContent); // second tap
1449+
await tester.pumpAndSettle(); // wait for the error dialog to show
1450+
1451+
checkErrorDialog(tester,
1452+
expectedTitle: 'Adding reaction failed',
1453+
expectedMessage: 'Invalid message(s)'); // check the error dialog
1454+
});
1455+
1456+
testWidgets('shows error dialog when removing reaction fails', (tester) async {
1457+
final message = eg.streamMessage(reactions: [
1458+
Reaction(
1459+
emojiName: '+1',
1460+
emojiCode: '1f44d',
1461+
reactionType: ReactionType.unicodeEmoji,
1462+
userId: eg.selfAccount.userId)
1463+
]); // create a message with a thumbs up reaction
1464+
await setupMessageWithReactions(tester,
1465+
message: message,
1466+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1467+
1468+
connection.prepare(httpStatus: 400, json: {
1469+
'code': 'BAD_REQUEST',
1470+
'msg': 'Invalid message(s)',
1471+
'result': 'error',
1472+
}); // prepare the response for the reaction
1473+
1474+
final messageContent = find.byType(MessageContent); // find the message content
1475+
await tester.tap(messageContent); // first tap
1476+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1477+
await tester.tap(messageContent); // second tap
1478+
await tester.pumpAndSettle(); // wait for the error dialog to show
1479+
1480+
checkErrorDialog(tester,
1481+
expectedTitle: 'Removing reaction failed',
1482+
expectedMessage: 'Invalid message(s)'); // check the error dialog
1483+
});
1484+
});
13471485
}

0 commit comments

Comments
 (0)