diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index c80c5dd26a..5bb365cd81 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:image_picker/image_picker.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/compose.dart'; @@ -716,7 +717,7 @@ class _SendButtonState extends State<_SendButton> { || widget.contentController.hasValidationErrors.value; } - void _send() { + void _send() async { if (_hasValidationErrors) { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ @@ -735,9 +736,26 @@ class _SendButtonState extends State<_SendButton> { final store = PerAccountStoreWidget.of(context); final content = widget.contentController.textNormalized; - store.sendMessage(destination: widget.getDestination(), content: content); widget.contentController.clear(); + + try { + // TODO(#720) clear content input only on success response; + // while waiting, put input(s) and send button into a disabled + // "working on it" state (letting input text be selected for copying). + await store.sendMessage(destination: widget.getDestination(), content: content); + } on ApiRequestException catch (e) { + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: context, + title: zulipLocalizations.errorMessageNotSent, + message: message); + return; + } } @override diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 564682b164..8288239ab4 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1,18 +1,49 @@ import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/compose_box.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 '../stdlib_checks.dart'; +import 'dialog_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); + late PerAccountStore store; + late FakeApiConnection connection; + + Future> prepareComposeBox(WidgetTester tester, Narrow narrow) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + final controllerKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: ComposeBox(controllerKey: controllerKey, narrow: narrow))))); + await tester.pumpAndSettle(); + + return controllerKey; + } + group('ComposeContentController', () { group('insertPadded', () { // Like `parseMarkedText` in test/model/autocomplete_test.dart, @@ -116,25 +147,10 @@ void main() { }); group('ComposeBox textCapitalization', () { - late GlobalKey controllerKey; - - Future prepareComposeBox(WidgetTester tester, Narrow narrow) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - - controllerKey = GlobalKey(); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - home: GlobalStoreWidget( - child: PerAccountStoreWidget( - accountId: eg.selfAccount.id, - child: ComposeBox(controllerKey: controllerKey, narrow: narrow))))); - await tester.pumpAndSettle(); - } - - void checkComposeBoxTextFields(WidgetTester tester, {required bool expectTopicTextField}) { + void checkComposeBoxTextFields(WidgetTester tester, { + required GlobalKey controllerKey, + required bool expectTopicTextField, + }) { final composeBoxController = controllerKey.currentState!; final topicTextField = tester.widgetList(find.byWidgetPredicate( @@ -155,13 +171,71 @@ void main() { } testWidgets('_StreamComposeBox', (tester) async { - await prepareComposeBox(tester, StreamNarrow(eg.stream().streamId)); - checkComposeBoxTextFields(tester, expectTopicTextField: true); + final key = await prepareComposeBox(tester, + StreamNarrow(eg.stream().streamId)); + checkComposeBoxTextFields(tester, controllerKey: key, + expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { - await prepareComposeBox(tester, TopicNarrow.ofMessage(eg.streamMessage())); - checkComposeBoxTextFields(tester, expectTopicTextField: false); + final key = await prepareComposeBox(tester, + TopicNarrow.ofMessage(eg.streamMessage())); + checkComposeBoxTextFields(tester, controllerKey: key, + expectTopicTextField: false); + }); + }); + + group('message-send request response', () { + Future setupAndTapSend(WidgetTester tester, { + required void Function(int messageId) prepareResponse, + }) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await prepareComposeBox(tester, const TopicNarrow(123, 'some topic')); + + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + await tester.enterText(contentInputFinder, 'hello world'); + + prepareResponse(456); + await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': '123', + 'topic': 'some topic', + 'content': 'hello world', + 'read_by_sender': 'true', + }); + } + + testWidgets('success', (tester) async { + await setupAndTapSend(tester, prepareResponse: (int messageId) { + connection.prepare(json: SendMessageResult(id: messageId).toJson()); + }); + final errorDialogs = tester.widgetList(find.byType(AlertDialog)); + check(errorDialogs).isEmpty(); + }); + + testWidgets('ZulipApiException', (tester) async { + await setupAndTapSend(tester, prepareResponse: (message) { + connection.prepare( + httpStatus: 400, + json: { + 'result': 'error', + 'code': 'BAD_REQUEST', + 'msg': 'You do not have permission to initiate direct message conversations.', + }); + }); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMessageNotSent, + expectedMessage: zulipLocalizations.errorServerMessage( + 'You do not have permission to initiate direct message conversations.'), + ))); }); }); }