From 10953f1709ef90345c3c35569323295ff44a31df Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 14 Jun 2023 17:52:46 -0700 Subject: [PATCH] action_sheet: Add "Quote and reply" button Fixes: #116 --- lib/widgets/action_sheet.dart | 117 +++++++++++++++++++++++++++- test/widgets/action_sheet_test.dart | 94 ++++++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 test/widgets/action_sheet_test.dart diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 741f298dda..8c7890322f 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -1,30 +1,50 @@ import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; +import '../api/route/messages.dart'; +import 'compose_box.dart'; +import 'dialog.dart'; import 'draggable_scrollable_modal_bottom_sheet.dart'; +import 'message_list.dart'; +import 'store.dart'; +/// Show a sheet of actions you can take on a message in the message list. +/// +/// Must have a [MessageListPage] ancestor. void showMessageActionSheet({required BuildContext context, required Message message}) { + // The UI that's conditioned on this won't live-update during this appearance + // of the action sheet (we avoid calling composeBoxControllerOf in a build + // method; see its doc). But currently it will be constant through the life of + // any message list, so that's fine. + final isComposeBoxOffered = MessageListPage.composeBoxControllerOf(context) != null; showDraggableScrollableModalBottomSheet( context: context, builder: (BuildContext innerContext) { return Column(children: [ - ShareButton(message: message), + ShareButton(message: message, messageListContext: context), + if (isComposeBoxOffered) QuoteAndReplyButton( + message: message, + messageListContext: context, + ), ]); }); } abstract class MessageActionSheetMenuItemButton extends StatelessWidget { - const MessageActionSheetMenuItemButton({ + MessageActionSheetMenuItemButton({ super.key, required this.message, - }); + required this.messageListContext, + }) : assert(messageListContext.findAncestorWidgetOfExactType() != null); IconData get icon; String get label; void Function(BuildContext) get onPressed; final Message message; + final BuildContext messageListContext; @override Widget build(BuildContext context) { @@ -36,9 +56,10 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { } class ShareButton extends MessageActionSheetMenuItemButton { - const ShareButton({ + ShareButton({ super.key, required super.message, + required super.messageListContext, }); @override get icon => Icons.adaptive.share; @@ -65,3 +86,91 @@ class ShareButton extends MessageActionSheetMenuItemButton { await Share.shareWithResult(message.content); }; } + +class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { + QuoteAndReplyButton({ + super.key, + required super.message, + required super.messageListContext, + }); + + @override get icon => Icons.format_quote_outlined; + + @override get label => 'Quote and reply'; + + @override get onPressed => (BuildContext bottomSheetContext) async { + // Close the message action sheet. We'll show the request progress + // in the compose-box content input with a "[Quoting…]" placeholder. + Navigator.of(bottomSheetContext).pop(); + + // This will be null only if the compose box disappeared after the + // message action sheet opened, and before "Quote and reply" was pressed. + // Currently a compose box can't ever disappear, so this is impossible. + ComposeBoxController composeBoxController = + MessageListPage.composeBoxControllerOf(messageListContext)!; + final topicController = composeBoxController.topicController; + if ( + topicController != null + && topicController.textNormalized == kNoTopicTopic + && message is StreamMessage + ) { + topicController.value = TextEditingValue(text: message.subject); + } + final tag = composeBoxController.contentController + .registerQuoteAndReplyStart(PerAccountStoreWidget.of(messageListContext), + message: message, + ); + + Message? fetchedMessage; + String? errorMessage; + // TODO, supported by reusable code: + // - (?) Retry with backoff on plausibly transient errors. + // - If request(s) take(s) a long time, show snackbar with cancel + // button, like "Still working on quote-and-reply…". + // On final failure or success, auto-dismiss the snackbar. + try { + fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(messageListContext).connection, + messageId: message.id, + applyMarkdown: false, + ); + if (fetchedMessage == null) { + errorMessage = 'That message does not seem to exist.'; + } + } catch (e) { + switch (e) { + case ZulipApiException(): + errorMessage = e.message; + // TODO specific messages for common errors, like network errors + // (support with reusable code) + default: + errorMessage = 'Could not fetch message source.'; + } + } + + if (!messageListContext.mounted) return; + + if (fetchedMessage == null) { + assert(errorMessage != null); + // TODO(?) give no feedback on error conditions we expect to + // flag centrally in event polling, like invalid auth, + // user/realm deactivated. (Support with reusable code.) + await showErrorDialog(context: messageListContext, + title: 'Quotation failed', message: errorMessage); + } + + if (!messageListContext.mounted) return; + + // This will be null only if the compose box disappeared during the + // quotation request. Currently a compose box can't ever disappear, + // so this is impossible. + composeBoxController = MessageListPage.composeBoxControllerOf(messageListContext)!; + composeBoxController.contentController + .registerQuoteAndReplyEnd(PerAccountStoreWidget.of(messageListContext), tag, + message: message, + rawContent: fetchedMessage?.content, + ); + if (!composeBoxController.contentFocusNode.hasFocus) { + composeBoxController.contentFocusNode.requestFocus(); + } + }; +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart new file mode 100644 index 0000000000..630828dcb9 --- /dev/null +++ b/test/widgets/action_sheet_test.dart @@ -0,0 +1,94 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/store.dart'; +import '../api/fake_api.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; + +void main() { + group('QuoteAndReplyButton', () { + TestDataBinding.ensureInitialized(); + + testWidgets('happy path', (WidgetTester tester) async { + addTearDown(TestDataBinding.instance.reset); + + final sender = eg.user(); + final stream = eg.stream(); + final message = eg.streamMessage(sender: sender, stream: stream); + await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + store.addUser(sender); + store.addStream(stream); + final connection = store.connection as FakeApiConnection; + + // prepare message list data + connection.prepare(json: GetMessagesResult( + anchor: message.id, + foundNewest: true, + foundOldest: true, + foundAnchor: true, + historyLimited: false, + messages: [message], + ).toJson()); + + await tester.pumpWidget( + MaterialApp( + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: MessageListPage( + narrow: StreamNarrow(message.streamId)))))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + // request the message action sheet + final messageContent = tester.widget( + find.byWidgetPredicate((Widget widget) => widget is MessageContent && widget.message.id == message.id)); + await tester.longPress(find.byWidget(messageContent)); + await tester.pumpAndSettle(); // sheet appears onscreen + + // Prepare fetch-raw-Markdown response + // TODO: Message should really only differ from `message` + // in its content / content_type, not in `id` or anything else. + connection.prepare(json: + GetMessageResult(message: eg.streamMessage(contentMarkdown: 'Hello world')).toJson()); + + // compose box exists because this is a stream narrow + final composeBox = tester.widget(find.byType(ComposeBox)); + final composeBoxController = composeBox.controllerKey!.currentState!; + final contentController = composeBoxController.contentController; + + // the "Quote and reply" button, which exists because the compose box exists + await tester.tap(find.byIcon(Icons.format_quote_outlined)); + + final expectedPlaceholder = quoteAndReplyPlaceholder(store, message: message); + // expect newline from [ComposeContentController.insertPadded] + String expectedText = '$expectedPlaceholder\n'; + check(contentController.value).equals(TextEditingValue( + text: expectedText, + // compose input not yet focused, so there is no selection + selection: const TextSelection.collapsed(offset: -1))); + + // message is fetched; compose box updates + await tester.pumpAndSettle(); + + final expectedQuoteAndReplyText = quoteAndReply(store, message: message, rawContent: 'Hello world'); + // expect newline from [ComposeContentController.insertPadded] + expectedText = '$expectedQuoteAndReplyText\n'; + check(contentController.value).equals(TextEditingValue( + text: expectedText, + // compose input focused, so there is a selection + selection: TextSelection.collapsed(offset: expectedText.length))); + }); + }); +}