From fabd42f4c550b85db5f4ed12b850535901042543 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 17:28:24 -0700 Subject: [PATCH 1/4] compose: Remove a redundant TypingNotifier.stoppedComposing call Issue #720 is superseded by #1441, in which we'll still clear the compose box when the send button is tapped. (We'll still preserve the composing progress in case the send fails, but we'll do so in an OutboxMessage instead of within the compose input in a disabled state.) --- lib/widgets/compose_box.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 57bf1d0a5c..e17edfdcdd 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1288,10 +1288,6 @@ class _SendButtonState extends State<_SendButton> { final content = controller.content.textNormalized; controller.content.clear(); - // The following `stoppedComposing` call is currently redundant, - // because clearing input sends a "typing stopped" notice. - // It will be necessary once we resolve #720. - store.typingNotifier.stoppedComposing(); try { // TODO(#720) clear content input only on success response; From a6709660b61b899115c166fd51ee062d0b559244 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 18:51:30 -0700 Subject: [PATCH 2/4] compose [nfc]: Remove obsoleted TODO(#720)s Issue #720 is superseded by #1441, and these don't apply... I guess with the exception of a note on how a generic "x" button could be laid out, so we leave that. --- lib/widgets/compose_box.dart | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index e17edfdcdd..bcd65be0ba 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1290,9 +1290,6 @@ class _SendButtonState extends State<_SendButton> { controller.content.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; @@ -1384,7 +1381,6 @@ class _ComposeBoxContainer extends StatelessWidget { border: Border(top: BorderSide(color: designVariables.borderBar)), boxShadow: ComposeBoxTheme.of(context).boxShadow, ), - // TODO(#720) try a Stack for the overlaid linear progress indicator child: Material( color: designVariables.composeBoxBg, child: Column( @@ -1742,10 +1738,10 @@ class _ErrorBanner extends _Banner { @override Widget? buildTrailing(context) { - // TODO(#720) "x" button goes here. - // 24px square with 8px touchable padding in all directions? - // and `bool get padEnd => false`; see Figma: - // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev + // An "x" button can go here. + // 24px square with 8px touchable padding in all directions? + // and `bool get padEnd => false`; see Figma: + // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4031-17029&m=dev return null; } } @@ -2083,11 +2079,6 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM } } - // TODO(#720) dismissable message-send error, maybe something like: - // if (controller.sendMessageError.value != null) { - // errorBanner = _ErrorBanner(label: - // ZulipLocalizations.of(context).errorSendMessageTimeout); - // } return ComposeBoxInheritedWidget.fromComposeBoxState(this, child: _ComposeBoxContainer(body: body, banner: banner)); } From 2003b6369c072121e8eded18aa702115b6000ec6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 29 May 2025 19:18:22 -0700 Subject: [PATCH 3/4] compose [nfc]: Expand a comment to include an edge case --- lib/widgets/compose_box.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index bcd65be0ba..9880a4b14f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1941,7 +1941,8 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM // TODO timeout this request? if (!mounted) return; if (!identical(controller, emptyEditController)) { - // user tapped Cancel during the fetch-raw-content request + // During the fetch-raw-content request, the user tapped Cancel + // or tapped a failed message edit to restore. // TODO in this case we don't want the error dialog caused by // ZulipAction.fetchRawContentWithFeedback; suppress that return; From 7e2910882e4081f93b9594b807c9967a35cfa12c Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 1 Apr 2025 17:44:47 -0400 Subject: [PATCH 4/4] msglist: Support retrieving failed outbox message content Different from the Figma design, the bottom padding below the progress bar is changed from 0.5px to 2px, as discussed here: https://github.com/zulip/zulip-flutter/pull/1453#discussion_r2103709974 Fixes: #1441 Co-authored-by: Chris Bobbe --- assets/l10n/app_en.arb | 6 +- assets/l10n/app_pl.arb | 4 - assets/l10n/app_ru.arb | 4 - lib/generated/l10n/zulip_localizations.dart | 6 +- .../l10n/zulip_localizations_ar.dart | 4 +- .../l10n/zulip_localizations_de.dart | 4 +- .../l10n/zulip_localizations_en.dart | 4 +- .../l10n/zulip_localizations_ja.dart | 4 +- .../l10n/zulip_localizations_nb.dart | 4 +- .../l10n/zulip_localizations_pl.dart | 4 +- .../l10n/zulip_localizations_ru.dart | 4 +- .../l10n/zulip_localizations_sk.dart | 4 +- .../l10n/zulip_localizations_uk.dart | 4 +- .../l10n/zulip_localizations_zh.dart | 4 +- lib/model/message.dart | 5 +- lib/widgets/compose_box.dart | 36 +++- lib/widgets/message_list.dart | 109 +++++++++++- test/widgets/compose_box_test.dart | 159 ++++++++++++++++++ test/widgets/message_list_test.dart | 134 ++++++++++++++- 19 files changed, 457 insertions(+), 46 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 1f070feb19..0d3f273d16 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -385,9 +385,9 @@ "@discardDraftForEditConfirmationDialogMessage": { "description": "Message for a confirmation dialog for discarding message text that was typed into the compose box, when editing a message." }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "When you restore a message not sent, the content that was previously in the compose box is discarded.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." + "discardDraftForOutboxConfirmationDialogMessage": "When you restore an unsent message, the content that was previously in the compose box is discarded.", + "@discardDraftForOutboxConfirmationDialogMessage": { + "description": "Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box." }, "discardDraftConfirmationDialogConfirmButton": "Discard", "@discardDraftConfirmationDialogConfirmButton": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 0569169d4c..982ca98be4 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1113,10 +1113,6 @@ "@messageNotSentLabel": { "description": "Text on a message in the message list saying that a send message request failed. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)" }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." - }, "errorNotificationOpenAccountNotFound": "Nie odnaleziono konta powiązanego z tym powiadomieniem.", "@errorNotificationOpenAccountNotFound": { "description": "Error message when the account associated with the notification could not be found" diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index b752df8dab..eb79229d04 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1105,10 +1105,6 @@ "@newDmFabButtonLabel": { "description": "Label for the floating action button (FAB) that opens the new DM sheet." }, - "discardDraftForMessageNotSentConfirmationDialogMessage": "При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.", - "@discardDraftForMessageNotSentConfirmationDialogMessage": { - "description": "Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box." - }, "newDmSheetScreenTitle": "Новое ЛС", "@newDmSheetScreenTitle": { "description": "Title displayed at the top of the new DM screen." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 28f4eee3ba..68d47c3787 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -655,11 +655,11 @@ abstract class ZulipLocalizations { /// **'When you edit a message, the content that was previously in the compose box is discarded.'** String get discardDraftForEditConfirmationDialogMessage; - /// Message for a confirmation dialog when restoring a message not sent, for discarding message text that was typed into the compose box. + /// Message for a confirmation dialog when restoring an outbox message, for discarding message text that was typed into the compose box. /// /// In en, this message translates to: - /// **'When you restore a message not sent, the content that was previously in the compose box is discarded.'** - String get discardDraftForMessageNotSentConfirmationDialogMessage; + /// **'When you restore an unsent message, the content that was previously in the compose box is discarded.'** + String get discardDraftForOutboxConfirmationDialogMessage; /// Label for the 'Discard' button on a confirmation dialog for discarding message text that was typed into the compose box. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 104cc16822..2910711c42 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 0abb3c2e55..a01b813f0f 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsDe extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index d5201bad84..9f41726924 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 69a2e97816..7d800ac7a8 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 66198f6a40..5d6c814002 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index b0189c01fc..efa03e9f48 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -333,8 +333,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { 'Miej na uwadze, że przechodząc do zmiany wiadomości wyczyścisz okno nowej wiadomości.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'Odzyskanie wiadomości, która nie została wysłana, skutkuje wyczyszczeniem zawartości pola dodania wpisu.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Odrzuć'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 063b3c01e4..9d7b09ded9 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -334,8 +334,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations { 'При изменении сообщения текст из поля для редактирования удаляется.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'При восстановлении неотправленного сообщения текст в поле ввода текста будет утрачен.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Сбросить'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index ec7a8f36e4..51aace2d53 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index af57ca0f86..97b5e26af1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -334,8 +334,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 3b425dcea1..e72db65ad7 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -325,8 +325,8 @@ class ZulipLocalizationsZh extends ZulipLocalizations { 'When you edit a message, the content that was previously in the compose box is discarded.'; @override - String get discardDraftForMessageNotSentConfirmationDialogMessage => - 'When you restore a message not sent, the content that was previously in the compose box is discarded.'; + String get discardDraftForOutboxConfirmationDialogMessage => + 'When you restore an unsent message, the content that was previously in the compose box is discarded.'; @override String get discardDraftConfirmationDialogConfirmButton => 'Discard'; diff --git a/lib/model/message.dart b/lib/model/message.dart index e8cfa6e6e1..1dfe421368 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -881,9 +881,8 @@ mixin _OutboxMessageStore on PerAccountStoreBase { void _handleMessageEventOutbox(MessageEvent event) { if (event.localMessageId != null) { final localMessageId = int.parse(event.localMessageId!, radix: 10); - // The outbox message can be missing if the user removes it (to be - // implemented in #1441) before the event arrives. - // Nothing to do in that case. + // The outbox message can be missing if the user removes it before the + // event arrives. Nothing to do in that case. _outboxMessages.remove(localMessageId); _outboxMessageDebounceTimers.remove(localMessageId)?.cancel(); _outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 9880a4b14f..a53d628a2c 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -13,6 +13,7 @@ import '../api/route/messages.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/binding.dart'; import '../model/compose.dart'; +import '../model/message.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'actions.dart'; @@ -1840,6 +1841,16 @@ class ComposeBox extends StatefulWidget { abstract class ComposeBoxState extends State { ComposeBoxController get controller; + /// Fills the compose box with the content of an [OutboxMessage] + /// for a failed [sendMessage] request. + /// + /// If there is already text in the compose box, gives a confirmation dialog + /// to confirm that it is OK to discard that text. + /// + /// [localMessageId], as in [OutboxMessage.localMessageId], must be present + /// in the message store. + void restoreMessageNotSent(int localMessageId); + /// Switch the compose box to editing mode. /// /// If there is already text in the compose box, gives a confirmation dialog @@ -1861,6 +1872,29 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM @override ComposeBoxController get controller => _controller!; ComposeBoxController? _controller; + @override + void restoreMessageNotSent(int localMessageId) async { + final zulipLocalizations = ZulipLocalizations.of(context); + + final abort = await _abortBecauseContentInputNotEmpty( + dialogMessage: zulipLocalizations.discardDraftForOutboxConfirmationDialogMessage); + if (abort || !mounted) return; + + final store = PerAccountStoreWidget.of(context); + final outboxMessage = store.takeOutboxMessage(localMessageId); + setState(() { + _setNewController(store); + final controller = this.controller; + controller + ..content.value = TextEditingValue(text: outboxMessage.contentMarkdown) + ..contentFocusNode.requestFocus(); + if (controller is StreamComposeBoxController) { + controller.topic.setTopic( + (outboxMessage.conversation as StreamConversation).topic); + } + }); + } + @override void startEditInteraction(int messageId) async { final zulipLocalizations = ZulipLocalizations.of(context); @@ -1942,7 +1976,7 @@ class _ComposeBoxState extends State with PerAccountStoreAwareStateM if (!mounted) return; if (!identical(controller, emptyEditController)) { // During the fetch-raw-content request, the user tapped Cancel - // or tapped a failed message edit to restore. + // or tapped a failed message edit or failed outbox message to restore. // TODO in this case we don't want the error dialog caused by // ZulipAction.fetchRawContentWithFeedback; suppress that return; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index b49e64a474..a34a25bb37 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart' hide TextDirection; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/message.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; @@ -1748,19 +1749,113 @@ class OutboxMessageWithPossibleSender extends StatelessWidget { @override Widget build(BuildContext context) { final message = item.message; + final localMessageId = message.localMessageId; + + // This is adapted from [MessageContent]. + // TODO(#576): Offer InheritedMessage ancestor once we are ready + // to support local echoing images and lightbox. + Widget content = DefaultTextStyle( + style: ContentTheme.of(context).textStylePlainParagraph, + child: BlockContentList(nodes: item.content.nodes)); + + switch (message.state) { + case OutboxMessageState.hidden: + throw StateError('Hidden OutboxMessage messages should not appear in message lists'); + case OutboxMessageState.waiting: + break; + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + // TODO(#576): When we support rendered-content local echo, + // use IgnorePointer along with this faded appearance, + // like we do for the failed-message-edit state + content = _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Opacity(opacity: 0.6, child: content)); + } + return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.only(top: 4), child: Column(children: [ if (item.showSender) _SenderRow(message: message, showTimestamp: false), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - // This is adapted from [MessageContent]. - // TODO(#576): Offer InheritedMessage ancestor once we are ready - // to support local echoing images and lightbox. - child: DefaultTextStyle( - style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: item.content.nodes))), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + _OutboxMessageStatusRow( + localMessageId: localMessageId, outboxMessageState: message.state), + ])), ])); } } + +class _OutboxMessageStatusRow extends StatelessWidget { + const _OutboxMessageStatusRow({ + required this.localMessageId, + required this.outboxMessageState, + }); + + final int localMessageId; + final OutboxMessageState outboxMessageState; + + @override + Widget build(BuildContext context) { + switch (outboxMessageState) { + case OutboxMessageState.hidden: + assert(false, + 'Hidden OutboxMessage messages should not appear in message lists'); + return SizedBox.shrink(); + + case OutboxMessageState.waiting: + final designVariables = DesignVariables.of(context); + return Padding( + padding: const EdgeInsetsGeometry.only(bottom: 2), + child: LinearProgressIndicator( + minHeight: 2, + color: designVariables.foreground.withFadedAlpha(0.5), + backgroundColor: designVariables.foreground.withFadedAlpha(0.2))); + + case OutboxMessageState.failed: + case OutboxMessageState.waitPeriodExpired: + final designVariables = DesignVariables.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _RestoreOutboxMessageGestureDetector( + localMessageId: localMessageId, + child: Text( + zulipLocalizations.messageNotSentLabel, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.btnLabelAttLowIntDanger, + fontSize: 12, + height: 12 / 12, + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))))); + } + } +} + +class _RestoreOutboxMessageGestureDetector extends StatelessWidget { + const _RestoreOutboxMessageGestureDetector({ + required this.localMessageId, + required this.child, + }); + + final int localMessageId; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final composeBoxState = MessageListPage.ancestorOf(context).composeBoxState; + // TODO(#1518) allow restore-outbox-message from any message-list page + if (composeBoxState == null) return; + composeBoxState.restoreMessageNotSent(localMessageId); + }, + child: child); + } +} diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index c25b00793e..70f0913316 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1530,6 +1530,165 @@ void main() { } } + group('restoreMessageNotSent', () { + final channel = eg.stream(); + final topic = 'topic'; + final topicNarrow = eg.topicNarrow(channel.streamId, topic); + + final failedMessageContent = 'failed message'; + final failedMessageFinder = find.widgetWithText( + OutboxMessageWithPossibleSender, failedMessageContent, skipOffstage: true); + + Future prepareMessageNotSent(WidgetTester tester, { + required Narrow narrow, + List otherUsers = const [], + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], otherUsers: otherUsers); + + if (narrow is ChannelNarrow) { + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + await enterTopic(tester, narrow: narrow, topic: topic); + } + await enterContent(tester, failedMessageContent); + connection.prepare(httpException: SocketException('error')); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + check(state).controller.content.text.equals(''); + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Message not sent'))); + await tester.pump(); + check(failedMessageFinder).findsOne(); + } + + testWidgets('restore content in DM narrow', (tester) async { + final dmNarrow = DmNarrow.withUser( + eg.otherUser.userId, selfUserId: eg.selfUser.userId); + await prepareMessageNotSent(tester, narrow: dmNarrow, otherUsers: [eg.otherUser]); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content in topic narrow', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('restore content and topic in channel narrow', (tester) async { + final channelNarrow = ChannelNarrow(channel.streamId); + await prepareMessageNotSent(tester, narrow: channelNarrow); + + await tester.enterText(topicInputFinder, 'topic before restoring'); + check(state).controller.isA() + ..topic.text.equals('topic before restoring') + ..content.text.isNotNull().isEmpty(); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.isA() + ..topic.text.equals(topic) + ..content.text.equals(failedMessageContent) + ..contentFocusNode.hasFocus.isTrue(); + }); + + Future expectAndHandleDiscardForMessageNotSentConfirmation( + WidgetTester tester, { + required bool shouldContinue, + }) { + return expectAndHandleDiscardConfirmation(tester, + expectedMessage: 'When you restore an unsent message, the content that was previously in the compose box is discarded.', + shouldContinue: shouldContinue); + } + + testWidgets('interrupting new-message compose: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting new-message compose: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + await enterContent(tester, 'composing something'); + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('composing something'); + }); + + testWidgets('interrupting message edit: proceed through confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: true); + await tester.pump(); + check(state).controller.content.text.equals(failedMessageContent); + }); + + testWidgets('interrupting message edit: cancel confirmation dialog', (tester) async { + await prepareMessageNotSent(tester, narrow: topicNarrow); + + final messageToEdit = eg.streamMessage( + sender: eg.selfUser, stream: channel, topic: topic, + content: 'message to edit'); + await store.addMessage(messageToEdit); + await tester.pump(); + + await startEditInteractionFromActionSheet(tester, messageId: messageToEdit.id, + originalRawContent: 'message to edit', + delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 250)); // bottom-sheet animation + + await tester.tap(failedMessageFinder); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + + await expectAndHandleDiscardForMessageNotSentConfirmation(tester, + shouldContinue: false); + await tester.pump(); + check(state).controller.content.text.equals('message to edit'); + }); + }); + group('edit message', () { final channel = eg.stream(); final topic = 'topic'; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 01e40cf7cf..8ead103d68 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; @@ -1638,6 +1639,13 @@ void main() { Finder outboxMessageFinder = find.widgetWithText( OutboxMessageWithPossibleSender, content, skipOffstage: true); + Finder messageNotSentFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.text('MESSAGE NOT SENT')).hitTestable(); + Finder loadingIndicatorFinder = find.descendant( + of: find.byType(OutboxMessageWithPossibleSender), + matching: find.byType(LinearProgressIndicator)).hitTestable(); + Future sendMessageAndSucceed(WidgetTester tester, { Duration delay = Duration.zero, }) async { @@ -1647,18 +1655,142 @@ void main() { await tester.pump(Duration.zero); } + Future sendMessageAndFail(WidgetTester tester, { + Duration delay = Duration.zero, + }) async { + connection.prepare(httpException: SocketException('error'), delay: delay); + await tester.enterText(contentInputFinder, content); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(Duration.zero); + } + + Future dismissErrorDialog(WidgetTester tester) async { + await tester.tap(find.byWidget( + checkErrorDialog(tester, expectedTitle: 'Message not sent'))); + await tester.pump(Duration(milliseconds: 250)); + } + + Future checkTapRestoreMessage(WidgetTester tester) async { + final state = tester.state(find.byType(ComposeBox)); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + check(messageNotSentFinder).findsOne(); + check(state).controller.content.text.isNotNull().isEmpty(); + + // Tap the message. This should put its content back into the compose box + // and remove it. + await tester.tap(outboxMessageFinder); + await tester.pump(); + check(store.outboxMessages).isEmpty(); + check(outboxMessageFinder).findsNothing(); + check(state).controller.content.text.equals(content); + } + + Future checkTapNotRestoreMessage(WidgetTester tester) async { + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + + // the message should ignore the pointer event + await tester.tap(outboxMessageFinder, warnIfMissed: false); + await tester.pump(); + check(store.outboxMessages).values.single; + check(outboxMessageFinder).findsOne(); + } + // State transitions are tested more thoroughly in // test/model/message_test.dart . - testWidgets('hidden -> waiting, outbox message appear', (tester) async { + testWidgets('hidden -> waiting', (tester) async { await setupMessageListPage(tester, narrow: topicNarrow, streams: [stream], messages: []); + await sendMessageAndSucceed(tester); check(outboxMessageFinder).findsNothing(); await tester.pump(kLocalEchoDebounceDuration); check(outboxMessageFinder).findsOne(); + check(loadingIndicatorFinder).findsOne(); + // The outbox message is still in waiting state; + // tapping does not restore it. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + // Send a message and fail. Dismiss the error dialog as it pops up. + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + }); + + testWidgets('hidden -> failed, tapping does nothing if compose box is not offered', (tester) async { + Route? lastPoppedRoute; + final navObserver = TestNavigatorObserver() + ..onPopped = (route, prevRoute) => lastPoppedRoute = route; + + final messages = [eg.streamMessage( + stream: stream, topic: topic, content: content)]; + await setupMessageListPage(tester, + narrow: const CombinedFeedNarrow(), + streams: [stream], subscriptions: [eg.subscription(stream)], + navObservers: [navObserver], + messages: messages); + + // Navigate to a message list page in a topic narrow, + // which has a compose box. + connection.prepare(json: + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + await tester.tap(find.widgetWithText(RecipientHeader, topic)); + await tester.pump(); // handle tap + await tester.pump(); // wait for navigation + check(contentInputFinder).findsOne(); + + await sendMessageAndFail(tester); + await dismissErrorDialog(tester); + // Navigate back to the message list page without a compose box, + // where the failed to send message should be visible. + + await tester.pageBack(); + check(lastPoppedRoute) + .isA().page + .isA() + .initNarrow.equals(TopicNarrow(stream.streamId, eg.t(topic))); + await tester.pump(); // handle tap + await tester.pump((lastPoppedRoute as TransitionRoute).reverseTransitionDuration); + check(contentInputFinder).findsNothing(); + check(messageNotSentFinder).findsOne(); + + // Tap the failed to send message. + // This should not remove it from the message list. + await checkTapNotRestoreMessage(tester); + }); + + testWidgets('waiting -> waitPeriodExpired, tap to restore message', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, streams: [stream], + messages: []); + await sendMessageAndFail(tester, + delay: kSendMessageOfferRestoreWaitPeriod + const Duration(seconds: 1)); + await tester.pump(kSendMessageOfferRestoreWaitPeriod); + final localMessageId = store.outboxMessages.keys.single; + check(messageNotSentFinder).findsOne(); + + await checkTapRestoreMessage(tester); + + // While `localMessageId` is no longer in store, there should be no error + // when a message event refers to it. + await store.handleEvent(eg.messageEvent( + eg.streamMessage(stream: stream, topic: 'topic'), + localMessageId: localMessageId)); + + // The [sendMessage] request fails; there is no outbox message affected. + await tester.pump(Duration(seconds: 1)); + check(messageNotSentFinder).findsNothing(); }); });