Skip to content

Commit a3d0070

Browse files
committed
msglist: Support adding a thumbs-up reaction
Fixes: #125
1 parent 10ea330 commit a3d0070

File tree

2 files changed

+106
-0
lines changed

2 files changed

+106
-0
lines changed

lib/widgets/action_sheet.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,27 @@ import 'store.dart';
1717
///
1818
/// Must have a [MessageListPage] ancestor.
1919
void showMessageActionSheet({required BuildContext context, required Message message}) {
20+
final store = PerAccountStoreWidget.of(context);
21+
2022
// The UI that's conditioned on this won't live-update during this appearance
2123
// of the action sheet (we avoid calling composeBoxControllerOf in a build
2224
// method; see its doc). But currently it will be constant through the life of
2325
// any message list, so that's fine.
2426
final isComposeBoxOffered = MessageListPage.composeBoxControllerOf(context) != null;
27+
28+
final selfUserId = store.account.userId;
29+
final hasThumbsUpReactionVote = message.reactions
30+
?.aggregated.any((reactionWithVotes) =>
31+
reactionWithVotes.reactionType == ReactionType.unicodeEmoji
32+
&& reactionWithVotes.emojiCode == '1f44d'
33+
&& reactionWithVotes.userIds.contains(selfUserId))
34+
?? false;
35+
2536
showDraggableScrollableModalBottomSheet(
2637
context: context,
2738
builder: (BuildContext _) {
2839
return Column(children: [
40+
if (!hasThumbsUpReactionVote) AddThumbsUpButton(message: message, messageListContext: context),
2941
ShareButton(message: message, messageListContext: context),
3042
if (isComposeBoxOffered) QuoteAndReplyButton(
3143
message: message,
@@ -60,6 +72,49 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
6072
}
6173
}
6274

75+
// This button is very temporary, to complete #125 before we have a way to
76+
// choose an arbitrary reaction (#388). So, skipping i18n.
77+
class AddThumbsUpButton extends MessageActionSheetMenuItemButton {
78+
AddThumbsUpButton({
79+
super.key,
80+
required super.message,
81+
required super.messageListContext,
82+
});
83+
84+
@override get icon => Icons.add_reaction_outlined;
85+
86+
@override
87+
String label(ZulipLocalizations zulipLocalizations) {
88+
return 'React with 👍'; // TODO(i18n) skip translation for now
89+
}
90+
91+
@override get onPressed => (BuildContext context) async {
92+
Navigator.of(context).pop();
93+
String? errorMessage;
94+
try {
95+
await addReaction(PerAccountStoreWidget.of(messageListContext).connection,
96+
messageId: message.id,
97+
reactionType: ReactionType.unicodeEmoji,
98+
emojiCode: '1f44d',
99+
emojiName: '+1',
100+
);
101+
} catch (e) {
102+
if (!messageListContext.mounted) return;
103+
104+
switch (e) {
105+
case ZulipApiException():
106+
errorMessage = e.message;
107+
// TODO specific messages for common errors, like network errors
108+
// (support with reusable code)
109+
default:
110+
}
111+
112+
await showErrorDialog(context: context,
113+
title: 'Adding reaction failed', message: errorMessage);
114+
}
115+
};
116+
}
117+
63118
class ShareButton extends MessageActionSheetMenuItemButton {
64119
ShareButton({
65120
super.key,

test/widgets/action_sheet_test.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
33
import 'package:flutter/services.dart';
44
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
55
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:http/http.dart' as http;
67
import 'package:zulip/api/model/model.dart';
78
import 'package:zulip/api/route/messages.dart';
89
import 'package:zulip/model/compose.dart';
@@ -19,6 +20,7 @@ import '../example_data.dart' as eg;
1920
import '../flutter_checks.dart';
2021
import '../model/binding.dart';
2122
import '../model/test_store.dart';
23+
import '../stdlib_checks.dart';
2224
import '../test_clipboard.dart';
2325
import '../test_share_plus.dart';
2426
import 'compose_box_checks.dart';
@@ -91,6 +93,54 @@ void main() {
9193
(store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson);
9294
}
9395

96+
group('AddThumbsUpButton', () {
97+
Future<void> tapButton(WidgetTester tester) async {
98+
await tester.ensureVisible(find.byIcon(Icons.add_reaction_outlined, skipOffstage: false));
99+
await tester.tap(find.byIcon(Icons.add_reaction_outlined));
100+
await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
101+
}
102+
103+
testWidgets('success', (WidgetTester tester) async {
104+
final message = eg.streamMessage();
105+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
106+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
107+
108+
final connection = store.connection as FakeApiConnection;
109+
connection.prepare(json: {});
110+
await tapButton(tester);
111+
await tester.pump(Duration.zero);
112+
113+
check(connection.lastRequest).isA<http.Request>()
114+
..method.equals('POST')
115+
..url.path.equals('/api/v1/messages/${message.id}/reactions')
116+
..bodyFields.deepEquals({
117+
'reaction_type': 'unicode_emoji',
118+
'emoji_code': '1f44d',
119+
'emoji_name': '+1',
120+
});
121+
});
122+
123+
testWidgets('request has an error', (WidgetTester tester) async {
124+
final message = eg.streamMessage();
125+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
126+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
127+
128+
final connection = store.connection as FakeApiConnection;
129+
130+
connection.prepare(httpStatus: 400, json: {
131+
'code': 'BAD_REQUEST',
132+
'msg': 'Invalid message(s)',
133+
'result': 'error',
134+
});
135+
await tapButton(tester);
136+
await tester.pump(Duration.zero); // error arrives; error dialog shows
137+
138+
await tester.tap(find.byWidget(checkErrorDialog(tester,
139+
expectedTitle: 'Adding reaction failed',
140+
expectedMessage: 'Invalid message(s)')));
141+
});
142+
});
143+
94144
group('ShareButton', () {
95145
// Tests should call this.
96146
MockSharePlus setupMockSharePlus() {
@@ -169,6 +219,7 @@ void main() {
169219
///
170220
/// Checks that there is a quote-and-reply button.
171221
Future<void> tapQuoteAndReplyButton(WidgetTester tester) async {
222+
await tester.ensureVisible(find.byIcon(Icons.format_quote_outlined, skipOffstage: false));
172223
final quoteAndReplyButton = findQuoteAndReplyButton(tester);
173224
check(quoteAndReplyButton).isNotNull();
174225
await tester.tap(find.byWidget(quoteAndReplyButton!));

0 commit comments

Comments
 (0)