Skip to content

Commit f9179ed

Browse files
committed
action_sheet: Add "Quote and reply" button
Fixes: zulip#116
1 parent e3137fa commit f9179ed

File tree

4 files changed

+358
-4
lines changed

4 files changed

+358
-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/flutter_checks.dart

+6
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
import 'dart:ui';
33

44
import 'package:checks/checks.dart';
5+
import 'package:flutter/foundation.dart';
56
import 'package:flutter/painting.dart';
67

8+
9+
extension ValueNotifierChecks<T> on Subject<ValueNotifier<T>> {
10+
Subject<T> get value => has((c) => c.value, 'value');
11+
}
12+
713
extension TextStyleChecks on Subject<TextStyle> {
814
Subject<bool> get inherit => has((t) => t.inherit, 'inherit');
915
Subject<List<FontVariation>?> get fontVariations => has((t) => t.fontVariations, 'fontVariations');

test/widgets/action_sheet_test.dart

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

0 commit comments

Comments
 (0)