Skip to content

Commit ffc6a82

Browse files
committed
action_sheet: Add "Quote and reply" button
Fixes: zulip#116
1 parent 45219dd commit ffc6a82

File tree

3 files changed

+360
-4
lines changed

3 files changed

+360
-4
lines changed

lib/widgets/action_sheet.dart

+113-4
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import 'package:flutter/material.dart';
22
import 'package:share_plus/share_plus.dart';
33

4+
import '../api/exception.dart';
45
import '../api/model/model.dart';
6+
import '../api/route/messages.dart';
7+
import 'compose_box.dart';
8+
import 'dialog.dart';
59
import 'draggable_scrollable_modal_bottom_sheet.dart';
10+
import 'message_list.dart';
11+
import 'store.dart';
612

13+
/// Show a sheet of actions you can take on a message in the message list.
14+
///
15+
/// Must have a [MessageListPage] ancestor.
716
void showMessageActionSheet({required BuildContext context, required Message message}) {
17+
// The UI that's conditioned on this won't live-update during this appearance
18+
// of the action sheet (we avoid calling composeBoxControllerOf in a build
19+
// method; see its doc). But currently it will be constant through the life of
20+
// any message list, so that's fine.
21+
final isComposeBoxOffered = MessageListPage.composeBoxControllerOf(context) != null;
822
showDraggableScrollableModalBottomSheet(
923
context: context,
1024
builder: (BuildContext innerContext) {
1125
return Column(children: [
12-
ShareButton(message: message),
26+
ShareButton(message: message, messageListContext: context),
27+
if (isComposeBoxOffered) QuoteAndReplyButton(
28+
message: message,
29+
messageListContext: context,
30+
),
1331
]);
1432
});
1533
}
1634

1735
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
18-
const MessageActionSheetMenuItemButton({
36+
MessageActionSheetMenuItemButton({
1937
super.key,
2038
required this.message,
21-
});
39+
required this.messageListContext,
40+
}) : assert(messageListContext.findAncestorWidgetOfExactType<MessageListPage>() != null);
2241

2342
IconData get icon;
2443
String get label;
2544
void Function(BuildContext) get onPressed;
2645

2746
final Message message;
47+
final BuildContext messageListContext;
2848

2949
@override
3050
Widget build(BuildContext context) {
@@ -36,9 +56,10 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
3656
}
3757

3858
class ShareButton extends MessageActionSheetMenuItemButton {
39-
const ShareButton({
59+
ShareButton({
4060
super.key,
4161
required super.message,
62+
required super.messageListContext,
4263
});
4364

4465
@override get icon => Icons.adaptive.share;
@@ -65,3 +86,91 @@ class ShareButton extends MessageActionSheetMenuItemButton {
6586
await Share.shareWithResult(message.content);
6687
};
6788
}
89+
90+
class QuoteAndReplyButton extends MessageActionSheetMenuItemButton {
91+
QuoteAndReplyButton({
92+
super.key,
93+
required super.message,
94+
required super.messageListContext,
95+
});
96+
97+
@override get icon => Icons.format_quote_outlined;
98+
99+
@override get label => 'Quote and reply';
100+
101+
@override get onPressed => (BuildContext bottomSheetContext) async {
102+
// Close the message action sheet. We'll show the request progress
103+
// in the compose-box content input with a "[Quoting…]" placeholder.
104+
Navigator.of(bottomSheetContext).pop();
105+
106+
// This will be null only if the compose box disappeared after the
107+
// message action sheet opened, and before "Quote and reply" was pressed.
108+
// Currently a compose box can't ever disappear, so this is impossible.
109+
ComposeBoxController composeBoxController =
110+
MessageListPage.composeBoxControllerOf(messageListContext)!;
111+
final topicController = composeBoxController.topicController;
112+
if (
113+
topicController != null
114+
&& topicController.textNormalized == kNoTopicTopic
115+
&& message is StreamMessage
116+
) {
117+
topicController.value = TextEditingValue(text: message.subject);
118+
}
119+
final tag = composeBoxController.contentController
120+
.registerQuoteAndReplyStart(PerAccountStoreWidget.of(messageListContext),
121+
message: message,
122+
);
123+
124+
Message? fetchedMessage;
125+
String? errorMessage;
126+
// TODO, supported by reusable code:
127+
// - (?) Retry with backoff on plausibly transient errors.
128+
// - If request(s) take(s) a long time, show snackbar with cancel
129+
// button, like "Still working on quote-and-reply…".
130+
// On final failure or success, auto-dismiss the snackbar.
131+
try {
132+
fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(messageListContext).connection,
133+
messageId: message.id,
134+
applyMarkdown: false,
135+
);
136+
if (fetchedMessage == null) {
137+
errorMessage = 'That message does not seem to exist.';
138+
}
139+
} catch (e) {
140+
switch (e) {
141+
case ZulipApiException():
142+
errorMessage = e.message;
143+
// TODO specific messages for common errors, like network errors
144+
// (support with reusable code)
145+
default:
146+
errorMessage = 'Could not fetch message source.';
147+
}
148+
}
149+
150+
if (!messageListContext.mounted) return;
151+
152+
if (fetchedMessage == null) {
153+
assert(errorMessage != null);
154+
// TODO(?) give no feedback on error conditions we expect to
155+
// flag centrally in event polling, like invalid auth,
156+
// user/realm deactivated. (Support with reusable code.)
157+
await showErrorDialog(context: messageListContext,
158+
title: 'Quotation failed', message: errorMessage);
159+
}
160+
161+
if (!messageListContext.mounted) return;
162+
163+
// This will be null only if the compose box disappeared during the
164+
// quotation request. Currently a compose box can't ever disappear,
165+
// so this is impossible.
166+
composeBoxController = MessageListPage.composeBoxControllerOf(messageListContext)!;
167+
composeBoxController.contentController
168+
.registerQuoteAndReplyEnd(PerAccountStoreWidget.of(messageListContext), tag,
169+
message: message,
170+
rawContent: fetchedMessage?.content,
171+
);
172+
if (!composeBoxController.contentFocusNode.hasFocus) {
173+
composeBoxController.contentFocusNode.requestFocus();
174+
}
175+
};
176+
}

test/widgets/action_sheet_test.dart

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/api/route/messages.dart';
6+
import 'package:zulip/model/compose.dart';
7+
import 'package:zulip/model/narrow.dart';
8+
import 'package:zulip/model/store.dart';
9+
import 'package:zulip/widgets/compose_box.dart';
10+
import 'package:zulip/widgets/content.dart';
11+
import 'package:zulip/widgets/message_list.dart';
12+
import 'package:zulip/widgets/store.dart';
13+
import '../api/fake_api.dart';
14+
15+
import '../example_data.dart' as eg;
16+
import '../model/binding.dart';
17+
import '../model/test_store.dart';
18+
import 'compose_box_checks.dart';
19+
import 'dialog_checks.dart';
20+
21+
void main() {
22+
group('QuoteAndReplyButton', () {
23+
TestDataBinding.ensureInitialized();
24+
25+
/// Simulates loading a [MessageListPage] and long-pressing on [message].
26+
Future<void> setupToMessageActionSheet(WidgetTester tester, {
27+
required Message message,
28+
required Narrow narrow,
29+
}) async {
30+
addTearDown(TestDataBinding.instance.reset);
31+
32+
await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot);
33+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
34+
store.addUser(eg.user(userId: message.senderId));
35+
if (message is StreamMessage) {
36+
store.addStream(eg.stream(streamId: message.streamId));
37+
}
38+
final connection = store.connection as FakeApiConnection;
39+
40+
// prepare message list data
41+
connection.prepare(json: GetMessagesResult(
42+
anchor: message.id,
43+
foundNewest: true,
44+
foundOldest: true,
45+
foundAnchor: true,
46+
historyLimited: false,
47+
messages: [message],
48+
).toJson());
49+
50+
await tester.pumpWidget(
51+
MaterialApp(
52+
home: GlobalStoreWidget(
53+
child: PerAccountStoreWidget(
54+
accountId: eg.selfAccount.id,
55+
child: MessageListPage(narrow: narrow)))));
56+
57+
// global store, per-account store, and message list get loaded
58+
await tester.pumpAndSettle();
59+
60+
// request the message action sheet
61+
final messageContent = tester.widget<MessageContent>(
62+
find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id));
63+
await tester.longPress(find.byWidget(messageContent));
64+
// sheet appears onscreen; default duration of bottom-sheet enter animation
65+
await tester.pump(const Duration(milliseconds: 250));
66+
}
67+
68+
ComposeBoxController? findComposeBoxController(WidgetTester tester) {
69+
return tester.widgetList<ComposeBox>(find.byType(ComposeBox))
70+
.single.controllerKey?.currentState;
71+
}
72+
73+
Widget? findQuoteAndReplyButton(WidgetTester tester) {
74+
return tester.widgetList(find.byIcon(Icons.format_quote_outlined)).singleOrNull;
75+
}
76+
77+
void prepareRawContentResponseSuccess(PerAccountStore store, {
78+
required Message message,
79+
required String rawContent,
80+
}) {
81+
// Prepare fetch-raw-Markdown response
82+
// TODO: Message should really only differ from `message`
83+
// in its content / content_type, not in `id` or anything else.
84+
(store.connection as FakeApiConnection).prepare(json:
85+
GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson());
86+
}
87+
88+
void prepareRawContentResponseError(PerAccountStore store) {
89+
final fakeResponseJson = {
90+
'code': 'BAD_REQUEST',
91+
'msg': 'Invalid message(s)',
92+
'result': 'error',
93+
};
94+
(store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson);
95+
}
96+
97+
/// Simulates tapping the quote-and-reply button in the message action sheet.
98+
///
99+
/// Checks that there is a quote-and-reply button.
100+
Future<void> tapQuoteAndReplyButton(WidgetTester tester) async {
101+
final quoteAndReplyButton = findQuoteAndReplyButton(tester);
102+
check(quoteAndReplyButton).isNotNull();
103+
await tester.tap(find.byWidget(quoteAndReplyButton!));
104+
}
105+
106+
void checkLoadingState(PerAccountStore store, ComposeContentController contentController, {
107+
required TextEditingValue valueBefore,
108+
required Message message,
109+
}) {
110+
check(contentController).value.equals((ComposeContentController()
111+
..value = valueBefore
112+
..insertPadded(quoteAndReplyPlaceholder(store, message: message))
113+
).value);
114+
check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress);
115+
}
116+
117+
void checkSuccessState(PerAccountStore store, ComposeContentController contentController, {
118+
required TextEditingValue valueBefore,
119+
required Message message,
120+
required String rawContent,
121+
}) {
122+
final c = ComposeContentController()
123+
..value = valueBefore
124+
..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent));
125+
if (!valueBefore.selection.isValid) {
126+
// (At the end of the process, we focus the input, which puts a cursor
127+
// at the end if there wasn't a cursor before.)
128+
c.selection = TextSelection.collapsed(offset: c.text.length);
129+
}
130+
check(contentController).value.equals(c.value);
131+
check(contentController).not(it()..validationErrors.contains(ContentValidationError.quoteAndReplyInProgress));
132+
}
133+
134+
testWidgets('in stream narrow', (WidgetTester tester) async {
135+
final message = eg.streamMessage();
136+
await setupToMessageActionSheet(tester, message: message, narrow: StreamNarrow(message.streamId));
137+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
138+
139+
final composeBoxController = findComposeBoxController(tester);
140+
check(composeBoxController).isNotNull(); // should exist; this is a stream narrow
141+
final contentController = composeBoxController!.contentController;
142+
143+
final valueBefore = contentController.value;
144+
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
145+
await tapQuoteAndReplyButton(tester); // should exist; this is a stream narrow
146+
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
147+
await tester.pump(Duration.zero); // message is fetched; compose box updates
148+
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
149+
checkSuccessState(store, contentController,
150+
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
151+
});
152+
153+
testWidgets('in topic narrow', (WidgetTester tester) async {
154+
final message = eg.streamMessage();
155+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
156+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
157+
158+
final composeBoxController = findComposeBoxController(tester);
159+
check(composeBoxController).isNotNull(); // should exist; this is a topic narrow
160+
final contentController = composeBoxController!.contentController;
161+
162+
final valueBefore = contentController.value;
163+
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
164+
await tapQuoteAndReplyButton(tester); // should exist; this is a topic narrow
165+
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
166+
await tester.pump(Duration.zero); // message is fetched; compose box updates
167+
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
168+
checkSuccessState(store, contentController,
169+
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
170+
});
171+
172+
testWidgets('in DM narrow', (WidgetTester tester) async {
173+
final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
174+
await setupToMessageActionSheet(tester,
175+
message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
176+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
177+
178+
final composeBoxController = findComposeBoxController(tester);
179+
check(composeBoxController).isNotNull(); // should exist; this is a DM narrow
180+
final contentController = composeBoxController!.contentController;
181+
182+
final valueBefore = contentController.value;
183+
prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world');
184+
await tapQuoteAndReplyButton(tester); // should exist; this is a DM narrow
185+
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
186+
await tester.pump(Duration.zero); // message is fetched; compose box updates
187+
check(composeBoxController.contentFocusNode.hasFocus).isTrue();
188+
checkSuccessState(store, contentController,
189+
valueBefore: valueBefore, message: message, rawContent: 'Hello world');
190+
});
191+
192+
testWidgets('request has an error', (WidgetTester tester) async {
193+
final message = eg.streamMessage();
194+
await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message));
195+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
196+
197+
final composeBoxController = findComposeBoxController(tester);
198+
check(composeBoxController).isNotNull(); // should exist; this is a topic narrow
199+
final contentController = composeBoxController!.contentController;
200+
201+
final valueBefore = contentController.value = TextEditingValue.empty;
202+
prepareRawContentResponseError(store);
203+
await tapQuoteAndReplyButton(tester); // should exist; this is a topic narrow
204+
checkLoadingState(store, contentController, valueBefore: valueBefore, message: message);
205+
await tester.pump(Duration.zero); // error arrives; error dialog shows
206+
207+
await tester.tap(find.byWidget(checkErrorDialog(tester,
208+
expectedTitle: 'Quotation failed',
209+
expectedMessage: 'That message does not seem to exist.',
210+
)));
211+
212+
check(contentController.value).equals(const TextEditingValue(
213+
// The placeholder was removed. (A newline from the placeholder's
214+
// insertPadded remains; I guess ideally we'd try to prevent that.)
215+
text: '\n',
216+
217+
// (At the end of the process, we focus the input.)
218+
selection: TextSelection.collapsed(offset: 1), //
219+
));
220+
});
221+
222+
testWidgets('not offered in AllMessagesNarrow (composing to reply is not yet supported)', (WidgetTester tester) async {
223+
final message = eg.streamMessage();
224+
await setupToMessageActionSheet(tester, message: message, narrow: const AllMessagesNarrow());
225+
check(findQuoteAndReplyButton(tester)).isNull();
226+
check(findComposeBoxController(tester)).isNull();
227+
});
228+
});
229+
}

0 commit comments

Comments
 (0)