Skip to content

Commit 00dad6a

Browse files
committed
action_sheet: Add "Quote and reply" button
Fixes: zulip#116
1 parent a31f857 commit 00dad6a

File tree

1 file changed

+114
-2
lines changed

1 file changed

+114
-2
lines changed

lib/widgets/action_sheet.dart

+114-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
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 = MessageListPageState.composeBoxControllerOf(context) != null;
822
showDraggableScrollableModalBottomSheet(
923
context: context,
1024
builder: (BuildContext innerContext) {
1125
return Column(children: [
12-
ShareButton(message: message, bottomSheetContext: innerContext),
26+
ShareButton(message: message, bottomSheetContext: innerContext, messageListContext: context),
27+
if (isComposeBoxOffered) QuoteAndReplyButton(
28+
message: message,
29+
bottomSheetContext: innerContext,
30+
messageListContext: context,
31+
),
1332
]);
1433
});
1534
}
@@ -19,14 +38,17 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
1938
super.key,
2039
required this.message,
2140
required this.bottomSheetContext,
22-
}) : assert(bottomSheetContext.findAncestorWidgetOfExactType<BottomSheet>() != null);
41+
required this.messageListContext,
42+
}) : assert(bottomSheetContext.findAncestorWidgetOfExactType<BottomSheet>() != null),
43+
assert(messageListContext.findAncestorWidgetOfExactType<MessageListPage>() != null);
2344

2445
IconData get icon;
2546
String get label;
2647
VoidCallback get onPressed;
2748

2849
final Message message;
2950
final BuildContext bottomSheetContext;
51+
final BuildContext messageListContext;
3052

3153
@override
3254
Widget build(BuildContext context) {
@@ -42,6 +64,7 @@ class ShareButton extends MessageActionSheetMenuItemButton {
4264
super.key,
4365
required super.message,
4466
required super.bottomSheetContext,
67+
required super.messageListContext,
4568
});
4669

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

0 commit comments

Comments
 (0)