@@ -2,14 +2,31 @@ import 'package:flutter/material.dart';
2
2
import 'package:share_plus/share_plus.dart' ;
3
3
4
4
import '../api/model/model.dart' ;
5
+ import '../api/route/messages.dart' ;
6
+ import 'dialog.dart' ;
5
7
import 'draggable_scrollable_modal_bottom_sheet.dart' ;
8
+ import 'message_list.dart' ;
9
+ import 'store.dart' ;
6
10
11
+ /// Show a sheet of actions you can take on a message in the message list.
12
+ ///
13
+ /// Must have a [MessageListPage] ancestor.
7
14
void showMessageActionSheet ({required BuildContext context, required Message message}) {
15
+ // The UI that's conditioned on this won't live-update during this appearance
16
+ // of the action sheet (we avoid calling composeBoxControllerOf in a build
17
+ // method; see its doc). But currently it will be constant through the life of
18
+ // any message list, so that's fine.
19
+ final isComposeBoxOffered = MessageListPageState .composeBoxControllerOf (context) != null ;
8
20
showDraggableScrollableModalBottomSheet (
9
21
context: context,
10
22
builder: (BuildContext innerContext) {
11
23
return Column (children: [
12
- ShareButton (message: message, bottomSheetContext: innerContext),
24
+ ShareButton (message: message, bottomSheetContext: innerContext, messageListContext: context),
25
+ if (isComposeBoxOffered) QuoteAndReplyButton (
26
+ message: message,
27
+ bottomSheetContext: innerContext,
28
+ messageListContext: context,
29
+ ),
13
30
]);
14
31
});
15
32
}
@@ -19,14 +36,17 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget {
19
36
super .key,
20
37
required this .message,
21
38
required this .bottomSheetContext,
22
- }) : assert (bottomSheetContext.findAncestorWidgetOfExactType <BottomSheet >() != null );
39
+ required this .messageListContext,
40
+ }) : assert (bottomSheetContext.findAncestorWidgetOfExactType <BottomSheet >() != null ),
41
+ assert (messageListContext.findAncestorWidgetOfExactType <MessageListPage >() != null );
23
42
24
43
IconData get icon;
25
44
String get label;
26
45
VoidCallback get onPressed;
27
46
28
47
final Message message;
29
48
final BuildContext bottomSheetContext;
49
+ final BuildContext messageListContext;
30
50
31
51
@override
32
52
Widget build (BuildContext context) {
@@ -42,6 +62,7 @@ class ShareButton extends MessageActionSheetMenuItemButton {
42
62
super .key,
43
63
required super .message,
44
64
required super .bottomSheetContext,
65
+ required super .messageListContext,
45
66
});
46
67
47
68
@override IconData get icon => Icons .adaptive.share;
@@ -68,3 +89,85 @@ class ShareButton extends MessageActionSheetMenuItemButton {
68
89
await Share .shareWithResult (message.content);
69
90
};
70
91
}
92
+
93
+ class MessageNotFoundException implements Exception {}
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;
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
+ final 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
+ // TODO, supported by reusable code:
131
+ // - (?) Retry with backoff on plausibly transient errors.
132
+ // - If request(s) take(s) a long time, show snackbar with cancel
133
+ // button, like "Still working on quote-and-reply…".
134
+ // On final failure or success, auto-dismiss the snackbar.
135
+ try {
136
+ fetchedMessage = await getMessageCompat (PerAccountStoreWidget .of (messageListContext).connection,
137
+ messageId: message.id,
138
+ applyMarkdown: false ,
139
+ );
140
+ if (fetchedMessage == null ) {
141
+ throw MessageNotFoundException ();
142
+ }
143
+ } catch (e) {
144
+ if (! bottomSheetContext.mounted) return ;
145
+ String ? errorMessage;
146
+ if (e is MessageNotFoundException ) {
147
+ errorMessage = 'Message not found.' ;
148
+ }
149
+ // TODO specific messages for common errors, like network errors
150
+ // (support with reusable code)
151
+ // TODO(?) give no feedback on error conditions we expect to
152
+ // flag centrally in event polling, like invalid auth,
153
+ // user/realm deactivated. (Support with reusable code.)
154
+ showErrorDialog (context: bottomSheetContext,
155
+ title: 'Quotation failed' , message: errorMessage);
156
+ } finally {
157
+ if (messageListContext.mounted) {
158
+ // This will be null only if the compose box disappeared during the
159
+ // quotation request. Currently a compose box can't ever disappear,
160
+ // so this is impossible.
161
+ final composeBoxController = MessageListPageState .composeBoxControllerOf (messageListContext);
162
+ composeBoxController! .contentController
163
+ .registerQuoteAndReplyEnd (PerAccountStoreWidget .of (messageListContext), tag,
164
+ message: message,
165
+ rawContent: fetchedMessage? .content,
166
+ );
167
+ if (! composeBoxController.contentFocusNode.hasFocus) {
168
+ composeBoxController.contentFocusNode.requestFocus ();
169
+ }
170
+ }
171
+ }
172
+ };
173
+ }
0 commit comments