1
1
import 'package:flutter/material.dart' ;
2
2
import 'package:share_plus/share_plus.dart' ;
3
3
4
+ import '../api/exception.dart' ;
4
5
import '../api/model/model.dart' ;
6
+ import '../api/route/messages.dart' ;
7
+ import 'compose_box.dart' ;
8
+ import 'dialog.dart' ;
5
9
import 'draggable_scrollable_modal_bottom_sheet.dart' ;
10
+ import 'message_list.dart' ;
11
+ import 'store.dart' ;
6
12
13
+ /// Show a sheet of actions you can take on a message in the message list.
14
+ ///
15
+ /// Must have a [MessageListPage] ancestor.
7
16
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 ;
8
22
showDraggableScrollableModalBottomSheet (
9
23
context: context,
10
24
builder: (BuildContext innerContext) {
11
25
return Column (children: [
12
- ShareButton (message: message),
26
+ ShareButton (message: message, messageListContext: context),
27
+ if (isComposeBoxOffered) QuoteAndReplyButton (
28
+ message: message,
29
+ messageListContext: context,
30
+ ),
13
31
]);
14
32
});
15
33
}
16
34
17
35
abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
18
- const MessageActionSheetMenuItemButton ({
36
+ MessageActionSheetMenuItemButton ({
19
37
super .key,
20
38
required this .message,
21
- });
39
+ required this .messageListContext,
40
+ }) : assert (messageListContext.findAncestorWidgetOfExactType <MessageListPage >() != null );
22
41
23
42
IconData get icon;
24
43
String get label;
25
44
void Function (BuildContext ) get onPressed;
26
45
27
46
final Message message;
47
+ final BuildContext messageListContext;
28
48
29
49
@override
30
50
Widget build (BuildContext context) {
@@ -36,9 +56,10 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
36
56
}
37
57
38
58
class ShareButton extends MessageActionSheetMenuItemButton {
39
- const ShareButton ({
59
+ ShareButton ({
40
60
super .key,
41
61
required super .message,
62
+ required super .messageListContext,
42
63
});
43
64
44
65
@override get icon => Icons .adaptive.share;
@@ -65,3 +86,91 @@ class ShareButton extends MessageActionSheetMenuItemButton {
65
86
await Share .shareWithResult (message.content);
66
87
};
67
88
}
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
+ }
0 commit comments