Skip to content

Commit 9ca66c9

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 ae7939a commit 9ca66c9

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed

lib/widgets/message_list.dart

Lines changed: 24 additions & 0 deletions
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';
@@ -1351,6 +1352,29 @@ class MessageWithPossibleSender extends StatelessWidget {
13511352

13521353
return GestureDetector(
13531354
behavior: HitTestBehavior.translucent,
1355+
onDoubleTap: () {
1356+
final store = PerAccountStoreWidget.of(context);
1357+
// First emoji in popular Candidates is thumbs up
1358+
final thumbsUpEmoji = EmojiStore.popularEmojiCandidates.toList()[0];
1359+
1360+
// Check if the user has already reacted with thumbs up
1361+
final isSelfVoted = message.reactions?.aggregated.any((reactionWithVotes) =>
1362+
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
1363+
&& reactionWithVotes.emojiCode == thumbsUpEmoji.emojiCode
1364+
&& reactionWithVotes.userIds.contains(store.selfUserId)) ?? false;
1365+
1366+
final zulipLocalizations = ZulipLocalizations.of(context);
1367+
1368+
// Add or remove reaction based on whether the user has already reacted with thumbs up
1369+
doAddOrRemoveReaction(
1370+
context: context,
1371+
doRemoveReaction: isSelfVoted,
1372+
messageId: message.id,
1373+
emoji: thumbsUpEmoji,
1374+
errorDialogTitle: isSelfVoted
1375+
? zulipLocalizations.errorReactionRemovingFailedTitle
1376+
: zulipLocalizations.errorReactionAddingFailedTitle);
1377+
},
13541378
onLongPress: () => showMessageActionSheet(context: context, message: message),
13551379
child: Padding(
13561380
padding: const EdgeInsets.symmetric(vertical: 4),

test/widgets/message_list_test.dart

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,4 +1252,142 @@ void main() {
12521252
..status.equals(AnimationStatus.dismissed);
12531253
});
12541254
});
1255+
1256+
group('double-tap gesture', () {
1257+
Future<void> setupMessageWithReactions(WidgetTester tester, {
1258+
required StreamMessage message,
1259+
required Narrow narrow,
1260+
List<Reaction>? reactions,
1261+
}) async {
1262+
addTearDown(testBinding.reset); // reset the test binding
1263+
assert(narrow.containsMessage(message)); // check that the narrow contains the message
1264+
1265+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); // add the self account
1266+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id); // get the per account store
1267+
await store.addUsers([
1268+
eg.selfUser,
1269+
eg.user(userId: message.senderId),
1270+
]); // add the self user and the message sender
1271+
final stream = eg.stream(streamId: message.streamId); // create the stream
1272+
await store.addStream(stream); // add the stream
1273+
await store.addSubscription(eg.subscription(stream)); // add the subscription
1274+
1275+
connection = store.connection as FakeApiConnection; // get the fake api connection
1276+
connection.prepare(json: eg.newestGetMessagesResult(
1277+
foundOldest: true, messages: [message]).toJson()); // prepare the response for the message
1278+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
1279+
child: MessageListPage(initNarrow: narrow)),
1280+
); // pump the widget
1281+
1282+
await tester.pumpAndSettle();
1283+
}
1284+
1285+
testWidgets('add thumbs up reaction on double-tap', (tester) async {
1286+
final message = eg.streamMessage(); // create a message without any reactions
1287+
await setupMessageWithReactions(tester,
1288+
message: message,
1289+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1290+
1291+
connection.prepare(json: {}); // prepare the response for the reaction
1292+
await tester.pump(); // pump the widget to make the reaction visible
1293+
1294+
final messageContent = find.byType(MessageContent); // find the message content
1295+
await tester.tap(messageContent); // first tap
1296+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1297+
await tester.tap(messageContent); // second tap
1298+
await tester.pumpAndSettle(); // wait for the reaction to be added
1299+
1300+
check(connection.lastRequest).isA<http.Request>()
1301+
..method.equals('POST')
1302+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
1303+
..bodyFields.deepEquals({
1304+
'reaction_type': 'unicode_emoji',
1305+
'emoji_code': '1f44d', // thumbs up emoji code
1306+
'emoji_name': '+1',
1307+
}); // check the last request
1308+
});
1309+
1310+
testWidgets('remove thumbs up reaction on double-tap when already reacted', (tester) async {
1311+
final message = eg.streamMessage(reactions: [
1312+
Reaction(
1313+
emojiName: '+1',
1314+
emojiCode: '1f44d',
1315+
reactionType: ReactionType.unicodeEmoji,
1316+
userId: eg.selfAccount.userId)
1317+
]); // create a message with a thumbs up reaction
1318+
await setupMessageWithReactions(tester,
1319+
message: message,
1320+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1321+
1322+
connection.prepare(json: {}); // prepare the response for the reaction
1323+
await tester.pump(); // pump the widget to make the reaction visible
1324+
1325+
final messageContent = find.byType(MessageContent); // find the message content
1326+
await tester.tap(messageContent); // first tap
1327+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1328+
await tester.tap(messageContent); // second tap
1329+
await tester.pumpAndSettle(); // wait for the reaction to be removed
1330+
1331+
check(connection.lastRequest).isA<http.Request>()
1332+
..method.equals('DELETE')
1333+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
1334+
..bodyFields.deepEquals({
1335+
'reaction_type': 'unicode_emoji',
1336+
'emoji_code': '1f44d',
1337+
'emoji_name': '+1',
1338+
}); // check the last request
1339+
});
1340+
1341+
testWidgets('shows error dialog when adding reaction fails', (tester) async {
1342+
final message = eg.streamMessage();
1343+
await setupMessageWithReactions(tester,
1344+
message: message,
1345+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1346+
1347+
connection.prepare(httpStatus: 400, json: {
1348+
'code': 'BAD_REQUEST',
1349+
'msg': 'Invalid message(s)',
1350+
'result': 'error',
1351+
});
1352+
1353+
final messageContent = find.byType(MessageContent);
1354+
await tester.tap(messageContent); // first tap
1355+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1356+
await tester.tap(messageContent); // second tap
1357+
await tester.pumpAndSettle(); // wait for the error dialog to show
1358+
1359+
checkErrorDialog(tester,
1360+
expectedTitle: 'Adding reaction failed',
1361+
expectedMessage: 'Invalid message(s)'); // check the error dialog
1362+
});
1363+
1364+
testWidgets('shows error dialog when removing reaction fails', (tester) async {
1365+
final message = eg.streamMessage(reactions: [
1366+
Reaction(
1367+
emojiName: '+1',
1368+
emojiCode: '1f44d',
1369+
reactionType: ReactionType.unicodeEmoji,
1370+
userId: eg.selfAccount.userId)
1371+
]); // create a message with a thumbs up reaction
1372+
await setupMessageWithReactions(tester,
1373+
message: message,
1374+
narrow: TopicNarrow.ofMessage(message)); // setup the message and narrow
1375+
1376+
connection.prepare(httpStatus: 400, json: {
1377+
'code': 'BAD_REQUEST',
1378+
'msg': 'Invalid message(s)',
1379+
'result': 'error',
1380+
}); // prepare the response for the reaction
1381+
1382+
final messageContent = find.byType(MessageContent); // find the message content
1383+
await tester.tap(messageContent); // first tap
1384+
await tester.pump(const Duration(milliseconds: 50)); // wait for some time so that the double-tap is detected
1385+
await tester.tap(messageContent); // second tap
1386+
await tester.pumpAndSettle(); // wait for the error dialog to show
1387+
1388+
checkErrorDialog(tester,
1389+
expectedTitle: 'Removing reaction failed',
1390+
expectedMessage: 'Invalid message(s)'); // check the error dialog
1391+
});
1392+
});
12551393
}

0 commit comments

Comments
 (0)