diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 28daad427e..a40ed4995a 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 _) { 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/flutter_checks.dart b/test/flutter_checks.dart index 46cbe75b73..998281e508 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -2,8 +2,14 @@ import 'dart:ui'; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; + +extension ValueNotifierChecks on Subject> { + Subject get value => has((c) => c.value, 'value'); +} + extension TextStyleChecks on Subject { Subject get inherit => has((t) => t.inherit, 'inherit'); Subject?> get fontVariations => has((t) => t.fontVariations, 'fontVariations'); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart new file mode 100644 index 0000000000..bb3b92a352 --- /dev/null +++ b/test/widgets/action_sheet_test.dart @@ -0,0 +1,223 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.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 '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import 'compose_box_checks.dart'; +import 'dialog_checks.dart'; + +/// Simulates loading a [MessageListPage] and long-pressing on [message]. +Future setupToMessageActionSheet(WidgetTester tester, { + required Message message, + required Narrow narrow, +}) async { + addTearDown(TestDataBinding.instance.reset); + + await TestDataBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + store.addUser(eg.user(userId: message.senderId)); + if (message is StreamMessage) { + store.addStream(eg.stream(streamId: message.streamId)); + } + 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: narrow))))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + // request the message action sheet + await tester.longPress(find.byType(MessageContent)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); +} + +void main() { + TestDataBinding.ensureInitialized(); + + group('QuoteAndReplyButton', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.widget(find.byType(ComposeBox)) + .controllerKey?.currentState; + } + + Widget? findQuoteAndReplyButton(WidgetTester tester) { + return tester.widgetList(find.byIcon(Icons.format_quote_outlined)).singleOrNull; + } + + void prepareRawContentResponseSuccess(PerAccountStore store, { + required Message message, + required String rawContent, + }) { + // Prepare fetch-raw-Markdown response + // TODO: Message should really only differ from `message` + // in its content / content_type, not in `id` or anything else. + (store.connection as FakeApiConnection).prepare(json: + GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson()); + } + + void prepareRawContentResponseError(PerAccountStore store) { + final fakeResponseJson = { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }; + (store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson); + } + + /// Simulates tapping the quote-and-reply button in the message action sheet. + /// + /// Checks that there is a quote-and-reply button. + Future tapQuoteAndReplyButton(WidgetTester tester) async { + final quoteAndReplyButton = findQuoteAndReplyButton(tester); + check(quoteAndReplyButton).isNotNull(); + await tester.tap(find.byWidget(quoteAndReplyButton!)); + } + + void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + }) { + check(contentController).value.equals((ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReplyPlaceholder(store, message: message)) + ).value); + check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); + } + + void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + required String rawContent, + }) { + final builder = ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); + if (!valueBefore.selection.isValid) { + // (At the end of the process, we focus the input, which puts a cursor + // at text's end, if there was no cursor at the time.) + builder.selection = TextSelection.collapsed(offset: builder.text.length); + } + check(contentController).value.equals(builder.value); + check(contentController).not(it()..validationErrors.contains(ContentValidationError.quoteAndReplyInProgress)); + } + + testWidgets('in stream narrow', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: StreamNarrow(message.streamId)); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.contentController; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + testWidgets('in topic narrow', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.contentController; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + testWidgets('in DM narrow', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.contentController; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + testWidgets('request has an error', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await TestDataBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.contentController; + + final valueBefore = contentController.value = TextEditingValue.empty; + prepareRawContentResponseError(store); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Quotation failed', + expectedMessage: 'That message does not seem to exist.', + ))); + + check(contentController.value).equals(const TextEditingValue( + // The placeholder was removed. (A newline from the placeholder's + // insertPadded remains; I guess ideally we'd try to prevent that.) + text: '\n', + + // (At the end of the process, we focus the input.) + selection: TextSelection.collapsed(offset: 1), // + )); + }); + + testWidgets('not offered in AllMessagesNarrow (composing to reply is not yet supported)', (WidgetTester tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: const AllMessagesNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); + }); + }); +} diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart new file mode 100644 index 0000000000..03aabbe8ee --- /dev/null +++ b/test/widgets/compose_box_checks.dart @@ -0,0 +1,16 @@ +import 'package:checks/checks.dart'; +import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/widgets/compose_box.dart'; + +extension ComposeContentControllerChecks on Subject { + Subject> get validationErrors => has((c) => c.validationErrors, 'validationErrors'); +} + +extension AutocompleteIntentChecks on Subject { + Subject get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart'); + Subject get query => has((i) => i.query, 'query'); +} + +extension UserMentionAutocompleteResultChecks on Subject { + Subject get userId => has((r) => r.userId, 'userId'); +}