Skip to content

Commit 10953f1

Browse files
committed
action_sheet: Add "Quote and reply" button
Fixes: #116
1 parent e49f47e commit 10953f1

File tree

2 files changed

+207
-4
lines changed

2 files changed

+207
-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

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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/route/messages.dart';
5+
import 'package:zulip/model/compose.dart';
6+
import 'package:zulip/model/narrow.dart';
7+
import 'package:zulip/widgets/compose_box.dart';
8+
import 'package:zulip/widgets/content.dart';
9+
import 'package:zulip/widgets/message_list.dart';
10+
import 'package:zulip/widgets/store.dart';
11+
import '../api/fake_api.dart';
12+
13+
import '../example_data.dart' as eg;
14+
import '../model/binding.dart';
15+
import '../model/test_store.dart';
16+
17+
void main() {
18+
group('QuoteAndReplyButton', () {
19+
TestDataBinding.ensureInitialized();
20+
21+
testWidgets('happy path', (WidgetTester tester) async {
22+
addTearDown(TestDataBinding.instance.reset);
23+
24+
final sender = eg.user();
25+
final stream = eg.stream();
26+
final message = eg.streamMessage(sender: sender, stream: stream);
27+
await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot);
28+
final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id);
29+
store.addUser(sender);
30+
store.addStream(stream);
31+
final connection = store.connection as FakeApiConnection;
32+
33+
// prepare message list data
34+
connection.prepare(json: GetMessagesResult(
35+
anchor: message.id,
36+
foundNewest: true,
37+
foundOldest: true,
38+
foundAnchor: true,
39+
historyLimited: false,
40+
messages: [message],
41+
).toJson());
42+
43+
await tester.pumpWidget(
44+
MaterialApp(
45+
home: GlobalStoreWidget(
46+
child: PerAccountStoreWidget(
47+
accountId: eg.selfAccount.id,
48+
child: MessageListPage(
49+
narrow: StreamNarrow(message.streamId))))));
50+
51+
// global store, per-account store, and message list get loaded
52+
await tester.pumpAndSettle();
53+
54+
// request the message action sheet
55+
final messageContent = tester.widget<MessageContent>(
56+
find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id));
57+
await tester.longPress(find.byWidget(messageContent));
58+
await tester.pumpAndSettle(); // sheet appears onscreen
59+
60+
// Prepare fetch-raw-Markdown response
61+
// TODO: Message should really only differ from `message`
62+
// in its content / content_type, not in `id` or anything else.
63+
connection.prepare(json:
64+
GetMessageResult(message: eg.streamMessage(contentMarkdown: 'Hello world')).toJson());
65+
66+
// compose box exists because this is a stream narrow
67+
final composeBox = tester.widget<ComposeBox>(find.byType(ComposeBox));
68+
final composeBoxController = composeBox.controllerKey!.currentState!;
69+
final contentController = composeBoxController.contentController;
70+
71+
// the "Quote and reply" button, which exists because the compose box exists
72+
await tester.tap(find.byIcon(Icons.format_quote_outlined));
73+
74+
final expectedPlaceholder = quoteAndReplyPlaceholder(store, message: message);
75+
// expect newline from [ComposeContentController.insertPadded]
76+
String expectedText = '$expectedPlaceholder\n';
77+
check(contentController.value).equals(TextEditingValue(
78+
text: expectedText,
79+
// compose input not yet focused, so there is no selection
80+
selection: const TextSelection.collapsed(offset: -1)));
81+
82+
// message is fetched; compose box updates
83+
await tester.pumpAndSettle();
84+
85+
final expectedQuoteAndReplyText = quoteAndReply(store, message: message, rawContent: 'Hello world');
86+
// expect newline from [ComposeContentController.insertPadded]
87+
expectedText = '$expectedQuoteAndReplyText\n';
88+
check(contentController.value).equals(TextEditingValue(
89+
text: expectedText,
90+
// compose input focused, so there is a selection
91+
selection: TextSelection.collapsed(offset: expectedText.length)));
92+
});
93+
});
94+
}

0 commit comments

Comments
 (0)