anchors 8/n: Let new messages accumulate in bottom sliver

Fixes #83. (As the "90% solution"; see https://github.com/zulip/zulip-flutter/issues/83#issuecomment-2917864412.)

This is the next round after #1468.

After this PR, not only does the last message from the initial fetch go into the bottom sliver, but it remains there even while new messages arrive as events, and those new messages go into the bottom sliver below it.

The main motivation for this change is that it brings us closer to having a `fetchNewer` method, and therefore to being able to have the message list start out in the middle of history, #82. Along the way it also gives us the "90% solution" described in #83, so that new messages don't cause everything on the screen to jump up when you're already scrolled up in history.

## Selected commit messages

#### f4b6e41fb msglist test: Ensure later errors get reported in full too

Otherwise, if several different test cases in this file fail due to
checks failing inside checkInvariants, then only the first one gets
reported in detail with the comparison and the stack trace.

#### 48983a347 msglist [nfc]: Move sliver boundary into the view-model

This will allow the model to maintain it over time as newer messages
arrive or get fetched.

#### e21f6012f msglist: Let new messages accumulate in bottom sliver

This is NFC for the behavior at initial fetch.  But thereafter, with
this change, messages never move between slivers, and new messages
go into the bottom sliver.

I believe the main user-visible consequence of this change is that if
the user is scrolled up in history and then a new message comes in,
the new message will no longer cause all the messages to shift upward.
This is the "90% solution" to #83.

On the other hand, if the user is scrolled all the way to the end,
then they still remain that way when a new message comes in --
there's specific logic to ensure that in MessageListScrollPosition,
and an existing test in test/widgets/message_list_test.dart verifies
it end to end.

The main motivation for this change is that it brings us closer to
having a `fetchNewer` method, and therefore to being able to have the
message list start out in the middle of history.

This change also allows us to revert a portion of fca651bf5, where
a test had had to be weakened slightly because messages began to
get moved between slivers.

Control scroll position on new and newly-fetched messages
Currently if you're looking at the message list and then a new message comes in, you'll see the messages on the screen jump. This is exactly the right thing (*) if you're scrolled to the bottom — the new message appears at the bottom, and the existing messages all move up to make room. But if you're scrolled further up, then we should keep steady on the screen the messages you're looking at.

Instead, the current behavior is that you see all the messages jump up.

Fundamentally what's happening is that we use a (variant of a) [ListView](https://api.flutter.dev/flutter/widgets/ListView-class.html), which models the contents as a sequence of items indexed 0, 1, 2, 3, etc.; and then we pass `reverse: true` so that item 0 is shown at the bottom, and interpret item index `i` as "the i'th message in the list, counting zero-based from the end". So if we're looking around item 37 (the message 37 slots from the end), and then a new message arrives, then as far as the ListView knows we should still be looking around item 37 — but that's now the message that was item 36 a moment ago.

A good 90% solution here would be to build the message list out of two list slivers facing back to back, rather than one list sliver facing upward. This is a standard Flutter solution for having a list that grows in both directions; see [example in the CustomScrollView doc](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html#widgets.CustomScrollView.2).

(The list sliver class in that example is `SliverList`, which corresponds to a basic `ListView`. We'd use our `SliverStickyHeaderList`, which corresponds to our `StickyHeaderListView`.)

(*) Well, possibly it'd be better to have some kind of sliding animation rather than a sudden jump. But that'd be its own issue. This issue is about the new state of things that would be the endpoint of any animation.

---

Notes on possible followups:
(**edit:** These were filed as #1536.)

That two-sliver solution would completely solve the problem for adding more messages (or removing messages) at the very top or bottom, which covers (a) `message` events and (b) fetching messages on scroll, #78. We'd still have some jumps on (c) deleting messages, or moving them away, when they're not the very oldest or latest; (d) moving messages into the middle of the list; (e) likely on some cases of editing messages so that their height changes; (f) similarly on a message going from no reactions to some reactions or back, or other changes that affect its displayed height.

For (c) and (d), I think the Flutter API has the hooks we'd need (we'd make our own implementation of `RenderSliverBoxChildManager`), but fundamentally it's a subtle problem; in particular we'd need a richer data structure for the list of messages than a `List` or `Queue`.

For (e) and (f), and for that matter as a possible alternative solution for (c) and (d) or even (a) and (b), I believe we could use a [ScrollController](https://api.flutter.dev/flutter/widgets/ScrollController-class.html). When something changes, we'd calculate what the right scroll position is in the new state of things, and call `jumpTo` to go there. Or perhaps we'd override [`ScrollController.createScrollPosition`](https://api.flutter.dev/flutter/widgets/ScrollController/createScrollPosition.html)? The example of `PageController`, mentioned in that doc, sounds potentially promising as a model.

This 90% solution:

> A good 90% solution here would be to build the message list out of two list slivers facing back to back, rather than one list sliver facing upward.

will happen as part of #82. So this issue tracks going further than that.

flutter test test/model/message_list_test.dart test/widgets/message_list_test.dart --reporter expanded 2>&1

diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart
index 2f8777d3ed..f2a45b78aa 100644
--- a/lib/model/message_list.dart
+++ b/lib/model/message_list.dart
@@ -70,11 +70,26 @@ mixin _MessageSequence {
   /// A sequence number for invalidating stale fetches.
   int generation = 0;

-  /// The messages.
+  /// The known messages in the list.
+  ///
+  /// This may or may not represent all the message history that
+  /// conceptually belongs in this message list.
+  /// That information is expressed in [fetched] and [haveOldest].
+  ///
+  /// See also [middleMessage], an index which divides this list
+  /// into a top slice and a bottom slice.
   ///
   /// See also [contents] and [items].
   final List<Message> messages = [];

+  /// An index into [messages] dividing it into a top slice and a bottom slice.
+  ///
+  /// The indices 0 to before [middleMessage] are the top slice of [messages],
+  /// and the indices from [middleMessage] to the end are the bottom slice.
+  ///
+  /// The corresponding item index is [middleItem].
+  int middleMessage = 0;
+
   /// Whether [messages] and [items] represent the results of a fetch.
   ///
   /// This allows the UI to distinguish "still working on fetching messages"
@@ -133,8 +148,23 @@ mixin _MessageSequence {
   /// This information is completely derived from [messages] and
   /// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
   /// It exists as an optimization, to memoize that computation.
+  ///
+  /// See also [middleItem], an index which divides this list
+  /// into a top slice and a bottom slice.
   final QueueList<MessageListItem> items = QueueList();

+  /// An index into [items] dividing it into a top slice and a bottom slice.
+  ///
+  /// The indices 0 to before [middleItem] are the top slice of [items],
+  /// and the indices from [middleItem] to the end are the bottom slice.
+  ///
+  /// The top and bottom slices of [items] correspond to
+  /// the top and bottom slices of [messages] respectively.
+  /// Either the bottom slices of both [items] and [messages] are empty,
+  /// or the first item in the bottom slice of [items] is a [MessageListMessageItem]
+  /// for the first message in the bottom slice of [messages].
+  int middleItem = 0;
+
   int _findMessageWithId(int messageId) {
     return binarySearchByKey(messages, messageId,
       (message, messageId) => message.id.compareTo(messageId));
@@ -202,6 +232,7 @@ mixin _MessageSequence {
     candidate++;
     assert(contents.length == messages.length);
     while (candidate < messages.length) {
+      if (candidate == middleMessage) middleMessage = target;
       if (test(messages[candidate])) {
         candidate++;
         continue;
@@ -210,6 +241,7 @@ mixin _MessageSequence {
       contents[target] = contents[candidate];
       target++; candidate++;
     }
+    if (candidate == middleMessage) middleMessage = target;
     messages.length = target;
     contents.length = target;
     assert(contents.length == messages.length);
@@ -232,6 +264,13 @@ mixin _MessageSequence {
     }
     if (messagesToRemoveById.isEmpty) return false;

+    if (middleMessage == messages.length) {
+      middleMessage -= messagesToRemoveById.length;
+    } else {
+      final middleMessageId = messages[middleMessage].id;
+      middleMessage -= messagesToRemoveById
+        .where((id) => id < middleMessageId).length;
+    }
     assert(contents.length == messages.length);
     messages.removeWhere((message) => messagesToRemoveById.contains(message.id));
     contents.removeWhere((content) => contentToRemove.contains(content));
@@ -246,11 +285,15 @@ mixin _MessageSequence {
     //   On a Pixel 5, a batch of 100 messages takes ~15-20ms in _insertAllMessages.
     //   (Before that, ~2-5ms in jsonDecode and 0ms in fromJson,
     //   so skip worrying about those steps.)
+    final oldLength = messages.length;
     assert(contents.length == messages.length);
     messages.insertAll(index, toInsert);
     contents.insertAll(index, toInsert.map(
       (message) => _parseMessageContent(message)));
     assert(contents.length == messages.length);
+    if (index <= middleMessage) {
+      middleMessage += messages.length - oldLength;
+    }
     _reprocessAll();
   }

@@ -258,6 +301,7 @@ mixin _MessageSequence {
   void _reset() {
     generation += 1;
     messages.clear();
+    middleMessage = 0;
     _fetched = false;
     _haveOldest = false;
     _fetchingOlder = false;
@@ -265,6 +309,7 @@ mixin _MessageSequence {
     _fetchOlderCooldownBackoffMachine = null;
     contents.clear();
     items.clear();
+    middleItem = 0;
   }

   /// Redo all computations from scratch, based on [messages].
@@ -304,6 +349,7 @@ mixin _MessageSequence {
         canShareSender = (prevMessageItem.message.senderId == message.senderId);
       }
     }
+    if (index == middleMessage) middleItem = items.length;
     items.add(MessageListMessageItem(message, content,
       showSender: !canShareSender, isLastInBlock: true));
   }
@@ -314,6 +360,7 @@ mixin _MessageSequence {
     for (var i = 0; i < messages.length; i++) {
       _processMessage(i);
     }
+    if (middleMessage == messages.length) middleItem = items.length;
   }
 }

@@ -453,13 +500,18 @@ class MessageListView with ChangeNotifier, _MessageSequence {
       allowEmptyTopicName: true,
     );
     if (this.generation > generation) return;
+
     _adjustNarrowForTopicPermalink(result.messages.firstOrNull);
+
     store.reconcileMessages(result.messages);
     store.recentSenders.handleMessages(result.messages); // TODO(#824)
+
+    // We'll make the bottom slice start at the last visible message, if any.
     for (final message in result.messages) {
-      if (_messageVisible(message)) {
-        _addMessage(message);
-      }
+      if (!_messageVisible(message)) continue;
+      middleMessage = messages.length;
+      _addMessage(message);
+      // Now [middleMessage] is the last message (the one just added).
     }
     _fetched = true;
     _haveOldest = result.foundOldest;
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 72f646cc65..dcd063a62a 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -573,10 +573,9 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
     // The list has two slivers: a top sliver growing upward,
     // and a bottom sliver growing downward.
     // Each sliver has some of the items from `model.items`.
-    const maxBottomItems = 1;
     final totalItems = model.items.length;
-    final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems;
-    final topItems = totalItems - bottomItems;
+    final topItems = model.middleItem;
+    final bottomItems = totalItems - topItems;

     // The top sliver has its child 0 as the item just before the
     // sliver boundary, child 1 as the item before that, and so on.

diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart
index bb85556ae9..ac41d771ec 100644
--- a/test/model/message_list_test.dart
+++ b/test/model/message_list_test.dart
@@ -1,6 +1,8 @@
 import 'dart:convert';

 import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
 import 'package:http/http.dart' as http;
 import 'package:test/scaffolding.dart';
 import 'package:zulip/api/backoff.dart';
@@ -27,6 +29,24 @@ const newestResult = eg.newestGetMessagesResult;
 const olderResult = eg.olderGetMessagesResult;

 void main() {
+  // Arrange for errors caught within the Flutter framework to be printed
+  // unconditionally, rather than throttled as they normally are in an app.
+  //
+  // When using `testWidgets` from flutter_test, this is done automatically;
+  // compare the [FlutterError.dumpErrorToConsole] call sites,
+  // and [FlutterError.onError=] and [debugPrint=] call sites, in flutter_test.
+  //
+  // This test file is unusual in needing this manual arrangement; it's needed
+  // because these aren't widget tests, and yet do have some failures arise as
+  // exceptions that get caught by the framework: namely, when [checkInvariants]
+  // throws from within an `addListener` callback.  Those exceptions get caught
+  // by [ChangeNotifier.notifyListeners] and reported there through
+  // [FlutterError.reportError].
+  debugPrint = debugPrintSynchronously;
+  FlutterError.onError = (details) {
+    FlutterError.dumpErrorToConsole(details, forceReport: true);
+  };
+
   // These variables are the common state operated on by each test.
   // Each test case calls [prepare] to initialize them.
   late Subscription subscription;
@@ -97,6 +117,14 @@ void main() {
       });
   }

+  void checkHasMessageIds(Iterable<int> messageIds) {
+    check(model.messages.map((m) => m.id)).deepEquals(messageIds);
+  }
+
+  void checkHasMessages(Iterable<Message> messages) {
+    checkHasMessageIds(messages.map((e) => e.id));
+  }
+
   group('fetchInitial', () {
     final someChannel = eg.stream();
     const someTopic = 'some topic';
@@ -420,10 +448,6 @@ void main() {
       await setVisibility(policy);
     }

-    void checkHasMessageIds(Iterable<int> messageIds) {
-      check(model.messages.map((m) => m.id)).deepEquals(messageIds);
-    }
-
     test('mute a visible topic', () async {
       await prepare(narrow: const CombinedFeedNarrow());
       await prepareMutes();
@@ -624,11 +648,11 @@ void main() {
       check(model).messages.length.equals(30);
       await store.handleEvent(eg.deleteMessageEvent(messagesToDelete));
       checkNotifiedOnce();
-      check(model.messages.map((message) => message.id)).deepEquals([
+      checkHasMessages([
         ...messages.sublist(0, 2),
         ...messages.sublist(5, 10),
         ...messages.sublist(15),
-      ].map((message) => message.id));
+      ]);
     });
   });

@@ -731,10 +755,6 @@ void main() {
     final stream = eg.stream();
     final otherStream = eg.stream();

-    void checkHasMessages(Iterable<Message> messages) {
-      check(model.messages.map((e) => e.id)).deepEquals(messages.map((e) => e.id));
-    }
-
     Future<void> prepareNarrow(Narrow narrow, List<Message>? messages) async {
       await prepare(narrow: narrow);
       for (final streamToAdd in [stream, otherStream]) {
@@ -1438,8 +1458,7 @@ void main() {
         eg.dmMessage(    id: 205, from: eg.otherUser, to: [eg.selfUser]),
       ]);
       final expected = <int>[];
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..addAll([201, 203, 205]));
+      checkHasMessageIds(expected..addAll([201, 203, 205]));

       // … and on fetchOlder…
       connection.prepare(json: olderResult(
@@ -1452,34 +1471,33 @@ void main() {
         ]).toJson());
       await model.fetchOlder();
       checkNotified(count: 2);
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..insertAll(0, [101, 103, 105]));
+      checkHasMessageIds(expected..insertAll(0, [101, 103, 105]));

       // … and on MessageEvent.
       await store.addMessage(
         eg.streamMessage(id: 301, stream: stream1, topic: 'A'));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));
+      checkHasMessageIds(expected..add(301));

       await store.addMessage(
         eg.streamMessage(id: 302, stream: stream1, topic: 'B'));
       checkNotNotified();
-      check(model.messages.map((m) => m.id)).deepEquals(expected);
+      checkHasMessageIds(expected);

       await store.addMessage(
         eg.streamMessage(id: 303, stream: stream2, topic: 'C'));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(303));
+      checkHasMessageIds(expected..add(303));

       await store.addMessage(
         eg.streamMessage(id: 304, stream: stream2, topic: 'D'));
       checkNotNotified();
-      check(model.messages.map((m) => m.id)).deepEquals(expected);
+      checkHasMessageIds(expected);

       await store.addMessage(
         eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser]));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(305));
+      checkHasMessageIds(expected..add(305));
     });

     test('in ChannelNarrow', () async {
@@ -1497,8 +1515,7 @@ void main() {
         eg.streamMessage(id: 203, stream: stream, topic: 'C'),
       ]);
       final expected = <int>[];
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..addAll([201, 202]));
+      checkHasMessageIds(expected..addAll([201, 202]));

       // … and on fetchOlder…
       connection.prepare(json: olderResult(
@@ -1509,24 +1526,23 @@ void main() {
         ]).toJson());
       await model.fetchOlder();
       checkNotified(count: 2);
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..insertAll(0, [101, 102]));
+      checkHasMessageIds(expected..insertAll(0, [101, 102]));

       // … and on MessageEvent.
       await store.addMessage(
         eg.streamMessage(id: 301, stream: stream, topic: 'A'));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));
+      checkHasMessageIds(expected..add(301));

       await store.addMessage(
         eg.streamMessage(id: 302, stream: stream, topic: 'B'));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(302));
+      checkHasMessageIds(expected..add(302));

       await store.addMessage(
         eg.streamMessage(id: 303, stream: stream, topic: 'C'));
       checkNotNotified();
-      check(model.messages.map((m) => m.id)).deepEquals(expected);
+      checkHasMessageIds(expected);
     });

     test('in TopicNarrow', () async {
@@ -1541,8 +1557,7 @@ void main() {
         eg.streamMessage(id: 201, stream: stream, topic: 'A'),
       ]);
       final expected = <int>[];
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..addAll([201]));
+      checkHasMessageIds(expected..addAll([201]));

       // … and on fetchOlder…
       connection.prepare(json: olderResult(
@@ -1551,14 +1566,13 @@ void main() {
         ]).toJson());
       await model.fetchOlder();
       checkNotified(count: 2);
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..insertAll(0, [101]));
+      checkHasMessageIds(expected..insertAll(0, [101]));

       // … and on MessageEvent.
       await store.addMessage(
         eg.streamMessage(id: 301, stream: stream, topic: 'A'));
       checkNotifiedOnce();
-      check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));
+      checkHasMessageIds(expected..add(301));
     });

     test('in MentionsNarrow', () async {
@@ -1581,23 +1595,21 @@ void main() {
       // Check filtering on fetchInitial…
       await prepareMessages(foundOldest: false, messages: getMessages(201));
       final expected = <int>[];
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..addAll([201, 202, 203]));
+      checkHasMessageIds(expected..addAll([201, 202, 203]));

       // … and on fetchOlder…
       connection.prepare(json: olderResult(
         anchor: 201, foundOldest: true, messages: getMessages(101)).toJson());
       await model.fetchOlder();
       checkNotified(count: 2);
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..insertAll(0, [101, 102, 103]));
+      checkHasMessageIds(expected..insertAll(0, [101, 102, 103]));

       // … and on MessageEvent.
       final messages = getMessages(301);
       for (var i = 0; i < 3; i += 1) {
         await store.addMessage(messages[i]);
         checkNotifiedOnce();
-        check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i));
+        checkHasMessageIds(expected..add(301 + i));
       }
     });

@@ -1619,24 +1631,230 @@ void main() {
       // Check filtering on fetchInitial…
       await prepareMessages(foundOldest: false, messages: getMessages(201));
       final expected = <int>[];
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..addAll([201, 202]));
+      checkHasMessageIds(expected..addAll([201, 202]));

       // … and on fetchOlder…
       connection.prepare(json: olderResult(
         anchor: 201, foundOldest: true, messages: getMessages(101)).toJson());
       await model.fetchOlder();
       checkNotified(count: 2);
-      check(model.messages.map((m) => m.id))
-        .deepEquals(expected..insertAll(0, [101, 102]));
+      checkHasMessageIds(expected..insertAll(0, [101, 102]));

       // … and on MessageEvent.
       final messages = getMessages(301);
       for (var i = 0; i < 2; i += 1) {
         await store.addMessage(messages[i]);
         checkNotifiedOnce();
-        check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i));
+        checkHasMessageIds(expected..add(301 + i));
+      }
+    });
+  });
+
+  group('middleMessage maintained', () {
+    // In [checkInvariants] we verify that messages don't move from the
+    // top to the bottom slice or vice versa.
+    // Most of these test cases rely on that for all the checks they need.
+
+    test('on fetchInitial empty', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      await prepareMessages(foundOldest: true, messages: []);
+      check(model)..messages.isEmpty()
+        ..middleMessage.equals(0);
+    });
+
+    test('on fetchInitial empty due to muting', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream, isMuted: true));
+      await prepareMessages(foundOldest: true, messages: [
+        eg.streamMessage(stream: stream),
+      ]);
+      check(model)..messages.isEmpty()
+        ..middleMessage.equals(0);
+    });
+
+    test('on fetchInitial not empty', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream1 = eg.stream();
+      final stream2 = eg.stream();
+      await store.addStreams([stream1, stream2]);
+      await store.addSubscription(eg.subscription(stream1));
+      await store.addSubscription(eg.subscription(stream2, isMuted: true));
+      final messages = [
+        eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2),
+        eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2),
+        eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2),
+        eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2),
+        eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2),
+      ];
+      await prepareMessages(foundOldest: true, messages: messages);
+      // The anchor message is the last visible message…
+      check(model)
+        ..messages.length.equals(5)
+        ..middleMessage.equals(model.messages.length - 1)
+        // … even though that's not the last message that was in the response.
+        ..messages[model.middleMessage].id
+            .equals(messages[messages.length - 2].id);
+    });
+
+    /// Like [prepareMessages], but arrange for the given top and bottom slices.
+    Future<void> prepareMessageSplit(List<Message> top, List<Message> bottom, {
+      bool foundOldest = true,
+    }) async {
+      assert(bottom.isNotEmpty); // could handle this too if necessary
+      await prepareMessages(foundOldest: foundOldest, messages: [
+        ...top,
+        bottom.first,
+      ]);
+      if (bottom.length > 1) {
+        await store.addMessages(bottom.skip(1));
+        checkNotifiedOnce();
       }
+      check(model)
+        ..messages.length.equals(top.length + bottom.length)
+        ..middleMessage.equals(top.length);
+    }
+
+    test('on fetchOlder', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit(foundOldest: false,
+        [eg.streamMessage(id: 100, stream: stream)],
+        [eg.streamMessage(id: 101, stream: stream)]);
+
+      connection.prepare(json: olderResult(anchor: 100, foundOldest: true,
+        messages: List.generate(5, (i) =>
+          eg.streamMessage(id: 95 + i, stream: stream))).toJson());
+      await model.fetchOlder();
+      checkNotified(count: 2);
+    });
+
+    test('on fetchOlder, from top empty', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit(foundOldest: false,
+        [], [eg.streamMessage(id: 100, stream: stream)]);
+
+      connection.prepare(json: olderResult(anchor: 100, foundOldest: true,
+        messages: List.generate(5, (i) =>
+          eg.streamMessage(id: 95 + i, stream: stream))).toJson());
+      await model.fetchOlder();
+      checkNotified(count: 2);
+      // The messages from fetchOlder should go in the top sliver, always.
+      check(model).middleMessage.equals(5);
+    });
+
+    test('on MessageEvent', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit(foundOldest: false,
+        [eg.streamMessage(stream: stream)],
+        [eg.streamMessage(stream: stream)]);
+
+      await store.addMessage(eg.streamMessage(stream: stream));
+      checkNotifiedOnce();
+    });
+
+    test('on messages muted, including anchor', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit([
+        eg.streamMessage(stream: stream, topic: 'foo'),
+        eg.streamMessage(stream: stream, topic: 'bar'),
+      ], [
+        eg.streamMessage(stream: stream, topic: 'bar'),
+        eg.streamMessage(stream: stream, topic: 'foo'),
+      ]);
+
+      await store.handleEvent(eg.userTopicEvent(
+        stream.streamId, 'bar', UserTopicVisibilityPolicy.muted));
+      checkNotifiedOnce();
+    });
+
+    test('on messages muted, not including anchor', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit([
+        eg.streamMessage(stream: stream, topic: 'foo'),
+        eg.streamMessage(stream: stream, topic: 'bar'),
+      ], [
+        eg.streamMessage(stream: stream, topic: 'foo'),
+      ]);
+
+      await store.handleEvent(eg.userTopicEvent(
+        stream.streamId, 'bar', UserTopicVisibilityPolicy.muted));
+      checkNotifiedOnce();
+    });
+
+    test('on messages muted, bottom empty', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await prepareMessageSplit([
+        eg.streamMessage(stream: stream, topic: 'foo'),
+        eg.streamMessage(stream: stream, topic: 'bar'),
+      ], [
+        eg.streamMessage(stream: stream, topic: 'third'),
+      ]);
+
+      await store.handleEvent(eg.deleteMessageEvent([
+        model.messages.last as StreamMessage]));
+      checkNotifiedOnce();
+      check(model).middleMessage.equals(model.messages.length);
+
+      await store.handleEvent(eg.userTopicEvent(
+        stream.streamId, 'bar', UserTopicVisibilityPolicy.muted));
+      checkNotifiedOnce();
+    });
+
+    test('on messages deleted', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      final messages = [
+        eg.streamMessage(id: 1, stream: stream),
+        eg.streamMessage(id: 2, stream: stream),
+        eg.streamMessage(id: 3, stream: stream),
+        eg.streamMessage(id: 4, stream: stream),
+      ];
+      await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2));
+
+      await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3)));
+      checkNotifiedOnce();
+    });
+
+    test('on messages deleted, bottom empty', () async {
+      await prepare(narrow: const CombinedFeedNarrow());
+      final stream = eg.stream();
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      final messages = [
+        eg.streamMessage(id: 1, stream: stream),
+        eg.streamMessage(id: 2, stream: stream),
+        eg.streamMessage(id: 3, stream: stream),
+        eg.streamMessage(id: 4, stream: stream),
+      ];
+      await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3));
+
+      await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3)));
+      checkNotifiedOnce();
+      check(model).middleMessage.equals(model.messages.length);
+
+      await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2)));
+      checkNotifiedOnce();
     });
   });

@@ -1913,6 +2131,10 @@ void main() {
   });
 }

+MessageListView? _lastModel;
+List<Message>? _lastMessages;
+int? _lastMiddleMessage;
+
 void checkInvariants(MessageListView model) {
   if (!model.fetched) {
     check(model)
@@ -1951,6 +2173,25 @@ void checkInvariants(MessageListView model) {
   check(isSortedWithoutDuplicates(model.messages.map((m) => m.id).toList()))
     .isTrue();

+  check(model).middleMessage
+    ..isGreaterOrEqual(0)
+    ..isLessOrEqual(model.messages.length);
+
+  if (identical(model, _lastModel)
+      && model.generation == _lastModel!.generation) {
+    // All messages that were present, and still are, should be on the same side
+    // of `middleMessage` (still top or bottom slice respectively) as they were.
+    _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage),
+      ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length),
+      because: 'messages moved from bottom slice to top slice');
+    _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!),
+      ListSlice(model.messages, model.middleMessage, model.messages.length),
+      because: 'messages moved from top slice to bottom slice');
+  }
+  _lastModel = model;
+  _lastMessages = model.messages.toList();
+  _lastMiddleMessage = model.middleMessage;
+
   check(model).contents.length.equals(model.messages.length);
   for (int i = 0; i < model.contents.length; i++) {
     final poll = model.messages[i].poll;
@@ -1988,6 +2229,27 @@ void checkInvariants(MessageListView model) {
         });
   }
   check(model.items).length.equals(i);
+
+  check(model).middleItem
+    ..isGreaterOrEqual(0)
+    ..isLessOrEqual(model.items.length);
+  if (model.middleItem == model.items.length) {
+    check(model.middleMessage).equals(model.messages.length);
+  } else {
+    check(model.items[model.middleItem]).isA<MessageListMessageItem>()
+      .message.identicalTo(model.messages[model.middleMessage]);
+  }
+}
+
+void _checkNoIntersection(List<Message> xs, List<Message> ys, {String? because}) {
+  // Both lists are sorted by ID.  As an optimization, bet on all or nearly all
+  // of the first list having smaller IDs than all or nearly all of the other.
+  if (xs.isEmpty || ys.isEmpty) return;
+  if (xs.last.id < ys.first.id) return;
+  final yCandidates = Set.of(ys.takeWhile((m) => m.id <= xs.last.id));
+  final intersection = xs.reversed.takeWhile((m) => ys.first.id <= m.id)
+    .where(yCandidates.contains);
+  check(intersection, because: because).isEmpty();
 }

 extension MessageListRecipientHeaderItemChecks on Subject<MessageListRecipientHeaderItem> {
@@ -2013,8 +2275,10 @@ extension MessageListViewChecks on Subject<MessageListView> {
   Subject<PerAccountStore> get store => has((x) => x.store, 'store');
   Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
   Subject<List<Message>> get messages => has((x) => x.messages, 'messages');
+  Subject<int> get middleMessage => has((x) => x.middleMessage, 'middleMessage');
   Subject<List<ZulipMessageContent>> get contents => has((x) => x.contents, 'contents');
   Subject<List<MessageListItem>> get items => has((x) => x.items, 'items');
+  Subject<int> get middleItem => has((x) => x.middleItem, 'middleItem');
   Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
   Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');
   Subject<bool> get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder');
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index 69a7204827..df05b4f0cc 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -462,23 +462,61 @@ void main() {
     // [MessageListScrollView], in scrolling_test.dart .

     testWidgets('sticks to end upon new message', (tester) async {
-      await setupMessageListPage(tester,
-        messages: List.generate(10, (_) => eg.streamMessage(content: '<p>a</p>')));
+      await setupMessageListPage(tester, messages: List.generate(10,
+        (i) => eg.streamMessage(content: '<p>message $i</p>')));
       final controller = findMessageListScrollController(tester)!;
+      final findMiddleMessage = find.text('message 5');

-      // Starts at end, and with room to scroll up.
-      check(controller.position)
-        ..extentAfter.equals(0)
-        ..extentBefore.isGreaterThan(0);
-      final oldPosition = controller.position.pixels;
+      // Started out scrolled to the bottom.
+      check(controller.position).extentAfter.equals(0);
+      final scrollPixels = controller.position.pixels;
+
+      // Note the position of some mid-screen message.
+      final messageRect = tester.getRect(findMiddleMessage);
+      check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600);

-      // On new message, position remains at end…
+      // When a new message arrives, the existing message moves up…
       await store.addMessage(eg.streamMessage(content: '<p>a</p><p>b</p>'));
       await tester.pump();
+      check(tester.getRect(findMiddleMessage))
+        ..top.isLessThan(messageRect.top)
+        ..height.isCloseTo(messageRect.height, Tolerance().distance);
+      // … because the position remains at the end…
       check(controller.position)
         ..extentAfter.equals(0)
         // … even though that means a bigger number now.
-        ..pixels.isGreaterThan(oldPosition);
+        ..pixels.isGreaterThan(scrollPixels);
+    });
+
+    testWidgets('preserves visible messages upon new message, when not at end', (tester) async {
+      await setupMessageListPage(tester, messages: List.generate(10,
+        (i) => eg.streamMessage(content: '<p>message $i</p>')));
+      final controller = findMessageListScrollController(tester)!;
+      final findMiddleMessage = find.text('message 5');
+
+      // Started at bottom.  Scroll up a bit.
+      check(controller.position).extentAfter.equals(0);
+      controller.position.jumpTo(controller.position.pixels - 100);
+      await tester.pump();
+      check(controller.position).extentAfter.equals(100);
+      final scrollPixels = controller.position.pixels;
+
+      // Note the position of some mid-screen message.
+      final messageRect = tester.getRect(findMiddleMessage);
+      check(messageRect)..top.isGreaterThan(0)..bottom.isLessThan(600);
+
+      // When a new message arrives, the existing message doesn't shift…
+      await store.addMessage(eg.streamMessage(content: '<p>a</p><p>b</p>'));
+      await tester.pump();
+      check(tester.getRect(findMiddleMessage)).equals(messageRect);
+      // … because the scroll position value remained the same…
+      check(controller.position)
+        ..pixels.equals(scrollPixels)
+        // … even though there's now more content off screen below.
+        // (This last check relies on the fact that the old extentAfter is small,
+        // less than cacheExtent, so that the new content is only barely offscreen,
+        // it gets built, and the new extentAfter reflects it.)
+        ..extentAfter.isGreaterThan(100);
     });
   });

@@ -1594,15 +1632,8 @@ void main() {
       // as the number of items changes in MessageList. See
       // `findChildIndexCallback` passed into [SliverStickyHeaderList]
       // at [_MessageListState._buildListView].
-
-      // TODO(#82): Cut paddingMessage.  It's there to paper over a glitch:
-      //   the _UnreadMarker animation *does* get interrupted in the case where
-      //   the message gets pushed from one sliver to the other.  See:
-      //     https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779
-      //   That case will no longer exist when #82 is complete.
       final message = eg.streamMessage(flags: []);
-      final paddingMessage = eg.streamMessage();
-      await setupMessageListPage(tester, messages: [message, paddingMessage]);
+      await setupMessageListPage(tester, messages: [message]);
       check(getAnimation(tester, message.id))
         ..value.equals(1.0)
         ..status.equals(AnimationStatus.dismissed);
@@ -1626,11 +1657,10 @@ void main() {
         ..status.equals(AnimationStatus.forward);

       // introduce new message
-      check(find.byType(MessageItem)).findsExactly(2);
       final newMessage = eg.streamMessage(flags:[MessageFlag.read]);
       await store.addMessage(newMessage);
       await tester.pump(); // process handleEvent
-      check(find.byType(MessageItem)).findsExactly(3);
+      check(find.byType(MessageItem)).findsExactly(2);
       check(getAnimation(tester, message.id))
         ..value.isGreaterThan(0.0)
         ..value.isLessThan(1.0)


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>

Simplified local echo, with retry

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)

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

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);
+  }
+}

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();
     });
   });


Fixes #407

Changes made:
- Replaced the hardcoded app bundle ID with `packageInfo.packageName`.
- Added a new test that sets a specific appId via packageInfo and confirms it’s used in a notification request.

The first change caused the "token initially unknown" test in `store_test.dart` to fail due to the added asynchronous call to `ZulipBinding.instance.packageInfo`, which delayed the completion of `registerToken`.

To resolve this, a `flushMicrotasks` was added after `await startFuture` in the test to ensure `_registerNotificationToken` completes before the test checks for the request.

notif: Use live value for app ID on registering APNs token
In my draft for #321 I have a TODO like this:
```dart
      case TargetPlatform.iOS:
        const appBundleId = 'com.zulip.flutter'; // TODO find actual value live
        await registerApnsToken(connection, token: token, appid: appBundleId);
```

This will be just fine as long as the app ID is indeed `com.zulip.flutter`. That means it will basically be fine right through the beta period, until we start rolling this app out as the new version of `org.zulip.Zulip` (the app ID currently occupied by the zulip-mobile RN app).

The fix is easy, locally:
```dart
        final appBundleId = (await PackageInfo.fromPlatform()).packageName;
```

The one wrinkle is that that version breaks tests, because it's directly calling a plugin. To fix it, we'll want to indirect `PackageInfo.fromPlatform` through a method on `ZulipBinding`, like we've done for other plugins. This still isn't hard, but given the impending beta, I'm deferring it for now.

My approach to solve the issue:

1. Update ZulipBinding to provide package info: Add a method in ZulipBinding to return the package name. This method will wrap the call to PackageInfo.fromPlatform() and make it test-friendly.

2. Override the method in TestZulipBinding: In the TestZulipBinding, override getAppBundleId to return a mock or predefined value for testing.

3. Refactor the code to use getAppBundleId: Replace direct calls to PackageInfo.fromPlatform() with the new getAppBundleId method from ZulipBinding. Like this (final appBundleId = await ZulipBinding.instance.getAppBundleId();).

4. Write a test for the above logic.
I have started working on the issue and would like to claim it.
I will be making changes to the Zulip binding class and its corresponding test class. Adding packageInfo wrapper for bundleID.
Plus will be adding test for the same.

flutter test test/api/core_test.dart test/model/store_test.dart --reporter expanded 2>&1

diff --git a/lib/model/binding.dart b/lib/model/binding.dart
index 94435631ac..198e0ae119 100644
--- a/lib/model/binding.dart
+++ b/lib/model/binding.dart
@@ -310,10 +310,12 @@ class LinuxDeviceInfo implements BaseDeviceInfo {
 class PackageInfo {
   final String version;
   final String buildNumber;
+  final String packageName;

   const PackageInfo({
     required this.version,
     required this.buildNumber,
+    required this.packageName,
   });
 }

@@ -427,6 +429,7 @@ class LiveZulipBinding extends ZulipBinding {
       _syncPackageInfo = PackageInfo(
         version: info.version,
         buildNumber: info.buildNumber,
+        packageName: info.packageName,
       );
     } catch (e, st) {
       assert(debugLog('Failed to prefetch package info: $e\n$st')); // TODO(log)
diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart
index 738bb98a2e..d60469ff30 100644
--- a/lib/notifications/receive.dart
+++ b/lib/notifications/receive.dart
@@ -149,8 +149,10 @@ class NotificationService {
         await addFcmToken(connection, token: token);

       case TargetPlatform.iOS:
-        const appBundleId = 'com.zulip.flutter'; // TODO(#407) find actual value live
-        await addApnsToken(connection, token: token, appid: appBundleId);
+        final packageInfo = await ZulipBinding.instance.packageInfo;
+        await addApnsToken(connection,
+          token: token,
+          appid: packageInfo!.packageName);

       case TargetPlatform.linux:
       case TargetPlatform.macOS:

diff --git a/test/api/core_test.dart b/test/api/core_test.dart
index 297dcfc441..e2c6e72ecb 100644
--- a/test/api/core_test.dart
+++ b/test/api/core_test.dart
@@ -460,7 +460,7 @@ void main() {
       });
     }

-    const packageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
+    final packageInfo = eg.packageInfo(version: '0.0.1', buildNumber: '1');

     const testCases = [
       ('ZulipFlutter/0.0.1+1 (Android 14)',             AndroidDeviceInfo(release: '14', sdkInt: 34),                      ),
diff --git a/test/example_data.dart b/test/example_data.dart
index f31337d303..fc3acfc5a4 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -10,6 +10,7 @@ import 'package:zulip/api/model/submessage.dart';
 import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/api/route/realm.dart';
 import 'package:zulip/api/route/channels.dart';
+import 'package:zulip/model/binding.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/settings.dart';
@@ -994,3 +995,15 @@ UpdateMachine updateMachine({
   return UpdateMachine.fromInitialSnapshot(
     store: store, initialSnapshot: initialSnapshot);
 }
+
+PackageInfo packageInfo({
+  String? version,
+  String? buildNumber,
+  String? packageName,
+}) {
+  return PackageInfo(
+    version: version ?? '1.0.0',
+    buildNumber: buildNumber ?? '1',
+    packageName: packageName ?? 'com.example.app',
+  );
+}
diff --git a/test/model/binding.dart b/test/model/binding.dart
index ced2a4d4b3..31f5738ddf 100644
--- a/test/model/binding.dart
+++ b/test/model/binding.dart
@@ -260,7 +260,7 @@ class TestZulipBinding extends ZulipBinding {

   /// The value that `ZulipBinding.instance.packageInfo` should return.
   PackageInfo packageInfoResult = _defaultPackageInfo;
-  static const _defaultPackageInfo = PackageInfo(version: '0.0.1', buildNumber: '1');
+  static final _defaultPackageInfo = eg.packageInfo();

   void _resetPackageInfo() {
     packageInfoResult = _defaultPackageInfo;
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index 1dfbb51273..ef0a7a72be 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -1291,6 +1291,7 @@ void main() {
       addTearDown(testBinding.reset);
       testBinding.firebaseMessagingInitialToken = '012abc';
       addTearDown(NotificationService.debugReset);
+      testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter');
       await NotificationService.instance.start();

       // On store startup, send the token.
@@ -1318,6 +1319,7 @@ void main() {
       addTearDown(testBinding.reset);
       testBinding.firebaseMessagingInitialToken = '012abc';
       addTearDown(NotificationService.debugReset);
+      testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter');
       final startFuture = NotificationService.instance.start();

       // TODO this test is a bit brittle in its interaction with asynchrony;
@@ -1336,6 +1338,7 @@ void main() {
       // When the token later appears, send it.
       connection.prepare(json: {});
       await startFuture;
+      async.flushMicrotasks();
       if (defaultTargetPlatform == TargetPlatform.android) {
         checkLastRequestFcm(token: '012abc');
       } else {
@@ -1350,6 +1353,22 @@ void main() {
         checkLastRequestFcm(token: '456def');
       }
     }));
+
+    test('on iOS, use provided app ID from packageInfo', () => awaitFakeAsync((async) async {
+      final origTargetPlatform = debugDefaultTargetPlatformOverride;
+      addTearDown(() => debugDefaultTargetPlatformOverride = origTargetPlatform);
+      debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+      addTearDown(testBinding.reset);
+      testBinding.firebaseMessagingInitialToken = '012abc';
+      testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.example.test');
+      addTearDown(NotificationService.debugReset);
+      await NotificationService.instance.start();
+
+      prepareStore();
+      connection.prepare(json: {});
+      await updateMachine.registerNotificationToken();
+      checkLastRequestApns(token: '012abc', appid: 'com.example.test');
+    }));
   });

   group('ZulipVersionData', () {