Repository: zulip/zulip-flutter (language: dart)

Issue:
Simplified local echo, with retry
### Background

This is a new proposed spec for handling the same user need as the following previous issue:
* #720 

The point is to better handle the case where you type out a message and hit send, and it fails because you don't have working network at that moment. The current behavior is pretty unpleasant when it hits: the message you wrote is just gone.

Our previous plan, described in #720, didn't work out (https://github.com/zulip/zulip-flutter/issues/720#issuecomment-2529905664). I think ultimately the way to solve this will be to have local echo #576, showing the still-being-sent message in the message list, along with an outbox system #133 for retrying send. But doing local echo in full will be a substantial task, and an outbox system is tricky to get right.

So, for launch, here's a simplified approach.

### Spec

* We do a limited, placeholder, form of local echo: just show something like "(Sending message…)" where the message's content would go. (Or maybe the message source, as plain text.) This bypasses all the work of interpreting Zulip Markdown.

  To avoid this causing an annoying flicker whenever you actually do have a good connection to the server, we'll put this on a debounce timer: it only appears once it's been perhaps 300ms since the start of the send request, or sooner if the send request fails (as described below).

  (This part is anticipated in #576 as one of the interim options there.)

* Then if a message fails to send, we show on the local-echo placeholder an option that lets you recover it. (Similarly perhaps if it's been a while, like 10s, since trying to send and the request hasn't completed one way or another.)

  You might want to retry sending, or just copy the text to save elsewhere. To cover both options, it can take the text and just put it back in the compose box. (If there's already something there, the recovered message can go at the end, leaving the user the choice of what to do.) The placeholder in the message list then disappears.

  This is a bit like the sort of outbox system described in #133. It's simpler because there's no internal retry, which cuts out the `should-send` state and all its edges. An outbox-message starts at `sending`; goes to `sent` or `failed`; then gets removed.



### Design

We don't have an exact design for this in Figma. See the existing designs for editing a message:
https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=4010-6347&m=dev

See also discussion at [#mobile > handle failed send @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/handle.20failed.20send/near/2128883); and use `#mobile-design` for discussing design questions that arise.


### Implementation

The data structures for the outstanding send requests will live on the message store. Like with other data there, that means the data structures will be fields on `MessageStoreImpl`, exposed via methods on `MessageStore`, so that code elsewhere in the app invokes the methods directly on the `PerAccountStore`.

(The existing method `sendMessage` should move from `PerAccountStore` to `MessageStore`.)

For a state diagram of the behavior of each send request, see [#mobile > handle failed send @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/handle.20failed.20send/near/2128824).


### References
* Previous issue: #720
* Related issues (for more-expansive versions of the different parts of this plan):
  #576 
  #133
* [#mobile > handle failed send @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/handle.20failed.20send/near/2128777)



Discussion hints:
nan

Pull request: local echo (7/7): Support simplified version of local echo
Fixes #1441.

Stacked atop #1472.

(This branch can be used to preview the full implementation)

<details>

<summary> screenshots </summary>

| message sent | got message event |
| - | - |
| ![sending-success-before](https://github.com/user-attachments/assets/9682f14c-152c-44da-b571-cfa01e9a9849) | ![sending-success-after](https://github.com/user-attachments/assets/069cb811-d7aa-405a-97fb-9c0b173d0950) |

| message failed to send | message content tapped | recipient headers |
| - | - | - |
| ![send-failed](https://github.com/user-attachments/assets/cda06163-7ae5-42b6-b6ec-b7ef67a7b5a2) | ![sent-failed-interacted](https://github.com/user-attachments/assets/51840a33-57da-4322-8761-6ec785120eee) | ![recipient-headers](https://github.com/user-attachments/assets/987dc35a-4278-4ce6-9d34-d10510d1aacc) |

</details>



Test command: flutter test test/widgets/compose_box_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

Fix patch:
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 57bf1d0a5c..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';
@@ -1288,15 +1289,8 @@ 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;
-      //   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;
@@ -1388,7 +1382,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(
@@ -1746,10 +1739,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;
   }
 }
@@ -1848,6 +1841,16 @@ class ComposeBox extends StatefulWidget {
 abstract class ComposeBoxState extends State<ComposeBox> {
   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
@@ -1869,6 +1872,29 @@ class _ComposeBoxState extends State<ComposeBox> 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);
@@ -1949,7 +1975,8 @@ class _ComposeBoxState extends State<ComposeBox> 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 or failed outbox message to restore.
       // TODO in this case we don't want the error dialog caused by
       //   ZulipAction.fetchRawContentWithFeedback; suppress that
       return;
@@ -2087,11 +2114,6 @@ class _ComposeBoxState extends State<ComposeBox> 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));
   }
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);
+  }
+}


Test patch:
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<void> prepareMessageNotSent(WidgetTester tester, {
+      required Narrow narrow,
+      List<User> 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<StreamComposeBoxController>()
+        ..topic.text.equals('topic before restoring')
+        ..content.text.isNotNull().isEmpty();
+
+      await tester.tap(failedMessageFinder);
+      await tester.pump();
+      check(state).controller.isA<StreamComposeBoxController>()
+        ..topic.text.equals(topic)
+        ..content.text.equals(failedMessageContent)
+        ..contentFocusNode.hasFocus.isTrue();
+    });
+
+    Future<void> 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<void> sendMessageAndSucceed(WidgetTester tester, {
       Duration delay = Duration.zero,
     }) async {
@@ -1647,18 +1655,142 @@ void main() {
       await tester.pump(Duration.zero);
     }
 
+    Future<void> 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<void> dismissErrorDialog(WidgetTester tester) async {
+      await tester.tap(find.byWidget(
+        checkErrorDialog(tester, expectedTitle: 'Message not sent')));
+      await tester.pump(Duration(milliseconds: 250));
+    }
+
+    Future<void> checkTapRestoreMessage(WidgetTester tester) async {
+      final state = tester.state<ComposeBoxState>(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<void> 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<dynamic>? 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<MaterialAccountWidgetRoute>().page
+        .isA<MessageListPage>()
+        .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();
     });
   });