model: Add Unreads model, for tracking unread-message counts

Fixes #253.

Contains my current revision of #299.

This is still in progress, but thought I'd check in with what I have so far, and see if it's going in the right direction. 🙂

Track unread counts

Track unread counts
We want to show how many unread messages the user has in each stream and conversation, how many unread @-mentions they have, and how many unreads they have total, each in various places in the UI.

The data to power this begins with the `unread_msgs` data structure in the initial data — search for "unread_msgs:" in https://zulip.com/api/register-queue . It then needs to be updated on a variety of events: new message, messages marked read or unread, messages moved or edited (affecting @-mention status), message deleted, and some others.

In zulip-mobile, this is tracked as `state.unread`. See [src/unread/unreadModel.js](https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/unread/unreadModel.js) and [src/unread/unreadSelectors.js](https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/unread/unreadSelectors.js). The handling there of counts for DMs and @-mentions (and the overall total) is legacy and isn't to be imitated, but the handling for streams and stream conversations — typically the bulk of the data — was overhauled more recently and much of that should translate well.

Then this data should appear in the UI:
* in the list of recent DM conversations #119
* in the inbox view #117
* in the stream list #187
* on a button to navigate to the @-mentions narrow #250
* probably in the between-organization navigation
* perhaps other places

For any of those pieces of UI that we've already implemented when we take up this issue, we can add the data to those parts of the UI as part of this issue. For the rest, we can include this data when we first implement those parts of the UI.

Tentatively milestoning this as "alpha" because it's important for daily use to know if there are unreads at a glance; feel free to change.
> Then this data should appear in the UI:
>
> * in the list of recent DM conversations [ui: Add list of recent DM conversations #119](https://github.com/zulip/zulip-flutter/issues/119)

I've forked this part off as a followup issue:
* #328

So we can close this issue when we complete the model, and use that one to track the remaining task of adding it to the UI there.

flutter test test/api/model/events_test.dart test/api/model/initial_snapshot_test.dart test/model/algorithms_test.dart test/model/unreads_test.dart --reporter expanded 2>&1

diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart
index 86ba97f27f..241aa70a41 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -438,8 +438,15 @@ class DeleteMessageEvent extends Event {
     this.topic,
   });

-  factory DeleteMessageEvent.fromJson(Map<String, dynamic> json) =>
-    _$DeleteMessageEventFromJson(json);
+  factory DeleteMessageEvent.fromJson(Map<String, dynamic> json) {
+    final result = _$DeleteMessageEventFromJson(json);
+    // Crunchy-shell validation
+    if (result.messageType == MessageType.stream) {
+      result.streamId as int;
+      result.topic as String;
+    }
+    return result;
+  }

   @override
   Map<String, dynamic> toJson() => _$DeleteMessageEventToJson(this);
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 30e49c0cee..8d7f983453 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -1,6 +1,7 @@
 import 'package:flutter/foundation.dart';
 import 'package:json_annotation/json_annotation.dart';

+import '../../model/algorithms.dart';
 import 'model.dart';

 part 'initial_snapshot.g.dart';
@@ -227,10 +228,13 @@ class UnreadMessagesSnapshot {

   final List<UnreadStreamSnapshot> streams;
   final List<UnreadHuddleSnapshot> huddles;
+
+  // Unlike other lists of message IDs here, [mentions] is *not* sorted.
   final List<int> mentions;
+
   final bool oldUnreadsMissing;

-  UnreadMessagesSnapshot({
+  const UnreadMessagesSnapshot({
     required this.count,
     required this.dms,
     required this.streams,
@@ -260,7 +264,7 @@ class UnreadDmSnapshot {
   UnreadDmSnapshot({
     required this.otherUserId,
     required this.unreadMessageIds,
-  });
+  }) : assert(isSortedWithoutDuplicates(unreadMessageIds));

   factory UnreadDmSnapshot.fromJson(Map<String, dynamic> json) =>
     _$UnreadDmSnapshotFromJson(json);
@@ -279,7 +283,7 @@ class UnreadStreamSnapshot {
     required this.topic,
     required this.streamId,
     required this.unreadMessageIds,
-  });
+  }) : assert(isSortedWithoutDuplicates(unreadMessageIds));

   factory UnreadStreamSnapshot.fromJson(Map<String, dynamic> json) =>
     _$UnreadStreamSnapshotFromJson(json);
@@ -296,7 +300,7 @@ class UnreadHuddleSnapshot {
   UnreadHuddleSnapshot({
     required this.userIdsString,
     required this.unreadMessageIds,
-  });
+  }) : assert(isSortedWithoutDuplicates(unreadMessageIds));

   factory UnreadHuddleSnapshot.fromJson(Map<String, dynamic> json) =>
     _$UnreadHuddleSnapshotFromJson(json);
diff --git a/lib/model/algorithms.dart b/lib/model/algorithms.dart
index 37ef1cc986..7c03a88d80 100644
--- a/lib/model/algorithms.dart
+++ b/lib/model/algorithms.dart
@@ -1,4 +1,6 @@

+import 'package:collection/collection.dart';
+
 /// Returns the index in [sortedList] of an element matching the given [key],
 /// if there is one.
 ///
@@ -41,3 +43,76 @@ int binarySearchByKey<E, K>(
   }
   return -1;
 }
+
+bool isSortedWithoutDuplicates(List<int> items) {
+  final length = items.length;
+  if (length == 0) {
+    return true;
+  }
+  int lastItem = items[0];
+  for (int i = 1; i < length; i++) {
+    final item = items[i];
+    if (item <= lastItem) {
+      return false;
+    }
+    lastItem = item;
+  }
+  return true;
+}
+
+/// The union of sets, represented as sorted lists.
+///
+/// The inputs must be sorted (by `<`) and without duplicates (by `==`).
+///
+/// The output will contain all the elements found in either input, again
+/// sorted and without duplicates.
+// When implementing this, it was convenient to have it return a [QueueList].
+// We can make it more general if needed:
+//   https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20unreads.20model/near/1647754
+QueueList<int> setUnion(Iterable<int> xs, Iterable<int> ys) {
+  // This will overshoot by the number of elements that occur in both lists.
+  // That may make this optimization less effective, but it will not cause
+  // incorrectness.
+  final capacity = xs is List && ys is List // [List]s should have efficient `.length`
+    ? xs.length + ys.length
+    : null;
+  final result = QueueList<int>(capacity);
+
+  final iterX = xs.iterator;
+  final iterY = ys.iterator;
+  late bool xHasElement;
+  void moveX() => xHasElement = iterX.moveNext();
+  late bool yHasElement;
+  void moveY() => yHasElement = iterY.moveNext();
+
+  moveX();
+  moveY();
+  while (true) {
+    if (!xHasElement || !yHasElement) {
+      break;
+    }
+
+    int x = iterX.current;
+    int y = iterY.current;
+    if (x < y) {
+      result.add(x);
+      moveX();
+    } else if (x != y) {
+      result.add(y);
+      moveY();
+    } else { // x == y
+      result.add(x);
+      moveX();
+      moveY();
+    }
+  }
+  while (xHasElement) {
+    result.add(iterX.current);
+    moveX();
+  }
+  while (yHasElement) {
+    result.add(iterY.current);
+    moveY();
+  }
+  return result;
+}
diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart
index fb29a5f151..014fa3c216 100644
--- a/lib/model/narrow.dart
+++ b/lib/model/narrow.dart
@@ -1,8 +1,10 @@

+import '../api/model/events.dart';
 import '../api/model/initial_snapshot.dart';
 import '../api/model/model.dart';
 import '../api/model/narrow.dart';
 import '../api/route/messages.dart';
+import 'algorithms.dart';

 /// A Zulip narrow.
 sealed class Narrow {
@@ -119,22 +121,6 @@ class TopicNarrow extends Narrow implements SendableNarrow {
   int get hashCode => Object.hash('TopicNarrow', streamId, topic);
 }

-bool _isSortedWithoutDuplicates(List<int> items) {
-  final length = items.length;
-  if (length == 0) {
-    return true;
-  }
-  int lastItem = items[0];
-  for (int i = 1; i < length; i++) {
-    final item = items[i];
-    if (item <= lastItem) {
-      return false;
-    }
-    lastItem = item;
-  }
-  return true;
-}
-
 /// The narrow for a direct-message conversation.
 // Zulip has many ways of representing a DM conversation; for example code
 // handling many of them, see zulip-mobile:src/utils/recipient.js .
@@ -142,39 +128,56 @@ bool _isSortedWithoutDuplicates(List<int> items) {
 // as we turn out to need them.
 class DmNarrow extends Narrow implements SendableNarrow {
   DmNarrow({required this.allRecipientIds, required int selfUserId})
-    : assert(_isSortedWithoutDuplicates(allRecipientIds)),
+    : assert(isSortedWithoutDuplicates(allRecipientIds)),
       assert(allRecipientIds.contains(selfUserId)),
       _selfUserId = selfUserId;

-  factory DmNarrow.ofMessage(DmMessage message, {required int selfUserId}) {
+  factory DmNarrow.withUser(int userId, {required int selfUserId}) {
     return DmNarrow(
-      allRecipientIds: List.unmodifiable(message.allRecipientIds),
+      allRecipientIds: {userId, selfUserId}.toList()..sort(),
       selfUserId: selfUserId,
     );
   }

-  /// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations].
-  factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) {
+  factory DmNarrow.withUsers(List<int> userIds, {required int selfUserId}) {
     return DmNarrow(
-      allRecipientIds: [...conversation.userIds, selfUserId]..sort(),
+      allRecipientIds: {...userIds, selfUserId}.toList()..sort(),
       selfUserId: selfUserId,
     );
   }

-  factory DmNarrow.withUser(int userId, {required int selfUserId}) {
+  factory DmNarrow.ofMessage(DmMessage message, {required int selfUserId}) {
     return DmNarrow(
-      allRecipientIds: {userId, selfUserId}.toList()..sort(),
+      allRecipientIds: List.unmodifiable(message.allRecipientIds),
       selfUserId: selfUserId,
     );
   }

-  factory DmNarrow.withUsers(List<int> userIds, {required int selfUserId}) {
+  /// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations].
+  factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) {
     return DmNarrow(
-      allRecipientIds: {...userIds, selfUserId}.toList()..sort(),
+      allRecipientIds: [...conversation.userIds, selfUserId]..sort(),
       selfUserId: selfUserId,
     );
   }

+  /// A [DmNarrow] from an [UnreadHuddleSnapshot].
+  factory DmNarrow.ofUnreadHuddleSnapshot(UnreadHuddleSnapshot snapshot, {required int selfUserId}) {
+    final userIds = snapshot.userIdsString.split(',').map((id) => int.parse(id));
+    return DmNarrow(selfUserId: selfUserId,
+      // (already sorted; see API doc)
+      allRecipientIds: userIds.toList(growable: false));
+  }
+
+  factory DmNarrow.ofUpdateMessageFlagsMessageDetail(
+    UpdateMessageFlagsMessageDetail detail, {
+    required int selfUserId,
+  }) {
+    assert(detail.type == MessageType.private);
+    return DmNarrow(selfUserId: selfUserId,
+      allRecipientIds: [...detail.userIds!, selfUserId]..sort());
+  }
+
   /// The user IDs of everyone in the conversation, sorted.
   ///
   /// Each message in the conversation is sent by one of these users
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 0e45555615..28898fc8fd 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -17,6 +17,7 @@ import 'autocomplete.dart';
 import 'database.dart';
 import 'message_list.dart';
 import 'recent_dm_conversations.dart';
+import 'unreads.dart';

 export 'package:drift/drift.dart' show Value;
 export 'database.dart' show Account, AccountsCompanion;
@@ -154,6 +155,7 @@ class PerAccountStore extends ChangeNotifier {
        realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts,
        customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields),
        userSettings = initialSnapshot.userSettings,
+       unreads = Unreads(initial: initialSnapshot.unreadMsgs, selfUserId: account.userId),
        users = Map.fromEntries(
          initialSnapshot.realmUsers
          .followedBy(initialSnapshot.realmNonActiveUsers)
@@ -181,6 +183,7 @@ class PerAccountStore extends ChangeNotifier {

   // Data attached to the self-account on the realm.
   final UserSettings? userSettings; // TODO(server-5)
+  final Unreads unreads;

   // Users and data about them.
   final Map<int, User> users;
@@ -306,19 +309,23 @@ class PerAccountStore extends ChangeNotifier {
       for (final view in _messageListViews) {
         view.maybeAddMessage(event.message);
       }
+      unreads.handleMessageEvent(event);
     } else if (event is UpdateMessageEvent) {
       assert(debugLog("server event: update_message ${event.messageId}"));
       for (final view in _messageListViews) {
         view.maybeUpdateMessage(event);
       }
+      unreads.handleUpdateMessageEvent(event);
     } else if (event is DeleteMessageEvent) {
       assert(debugLog("server event: delete_message ${event.messageIds}"));
-      // TODO handle
+      // TODO handle in message lists
+      unreads.handleDeleteMessageEvent(event);
     } else if (event is UpdateMessageFlagsEvent) {
       assert(debugLog("server event: update_message_flags/${event.op} ${event.flag.toJson()}"));
       for (final view in _messageListViews) {
         view.maybeUpdateMessageFlags(event);
       }
+      unreads.handleUpdateMessageFlagsEvent(event);
     } else if (event is ReactionEvent) {
       assert(debugLog("server event: reaction/${event.op}"));
       for (final view in _messageListViews) {
diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart
new file mode 100644
index 0000000000..5d4cd1471b
--- /dev/null
+++ b/lib/model/unreads.dart
@@ -0,0 +1,399 @@
+import 'dart:core';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+
+import '../api/model/initial_snapshot.dart';
+import '../api/model/model.dart';
+import '../api/model/events.dart';
+import '../log.dart';
+import 'algorithms.dart';
+import 'narrow.dart';
+
+/// The view-model for unread messages.
+///
+/// Implemented to track actual unread state as faithfully as possible
+/// given incomplete information in [UnreadMessagesSnapshot].
+/// Callers should do their own filtering based on other state, like muting,
+/// as desired.
+///
+/// In each component of this model ([streams], [dms], [mentions]),
+/// if a message is not represented, its status is either read
+/// or unknown to the component. In all components,
+/// a message's status will be unknown if, at /register time,
+/// it was very old by the server's reckoning. See [oldUnreadsMissing].
+/// It may also be unknown for other reasons that differ by component; see each.
+///
+/// Messages in unsubscribed streams, and messages sent by muted users,
+/// are generally deemed read by the server and shouldn't be expected to appear.
+/// They may still appear temporarily when the server hasn't finished processing
+/// the message's transition to the muted or unsubscribed-stream state;
+/// the mark-as-read is done asynchronously and comes with a mark-as-read event:
+///   https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/unreads.3A.20messages.20from.20muted.20users.3F/near/1660912
+/// For that reason, consumers of this model may wish to filter out messages in
+/// unsubscribed streams and messages sent by muted users.
+// TODO handle moved messages
+// TODO When [oldUnreadsMissing], if you load a message list with very old unreads,
+//   sync to those unreads, because the user has shown an interest in them.
+// TODO When loading a message list with stream messages, check all the stream
+//   messages and refresh [mentions] (see [mentions] dartdoc).
+class Unreads extends ChangeNotifier {
+  factory Unreads({required UnreadMessagesSnapshot initial, required selfUserId}) {
+    final streams = <int, Map<String, QueueList<int>>>{};
+    final dms = <DmNarrow, QueueList<int>>{};
+    final mentions = Set.of(initial.mentions);
+
+    for (final unreadStreamSnapshot in initial.streams) {
+      final streamId = unreadStreamSnapshot.streamId;
+      final topic = unreadStreamSnapshot.topic;
+      (streams[streamId] ??= {})[topic] = QueueList.from(unreadStreamSnapshot.unreadMessageIds);
+    }
+
+    for (final unreadDmSnapshot in initial.dms) {
+      final otherUserId = unreadDmSnapshot.otherUserId;
+      final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId);
+      dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds);
+    }
+
+    for (final unreadHuddleSnapshot in initial.huddles) {
+      final narrow = DmNarrow.ofUnreadHuddleSnapshot(unreadHuddleSnapshot, selfUserId: selfUserId);
+      dms[narrow] = QueueList.from(unreadHuddleSnapshot.unreadMessageIds);
+    }
+
+    return Unreads._(
+      streams: streams,
+      dms: dms,
+      mentions: mentions,
+      oldUnreadsMissing: initial.oldUnreadsMissing,
+      selfUserId: selfUserId,
+    );
+  }
+
+  Unreads._({
+    required this.streams,
+    required this.dms,
+    required this.mentions,
+    required this.oldUnreadsMissing,
+    required this.selfUserId,
+  });
+
+  // TODO excluded for now; would need to handle nuances around muting etc.
+  // int count;
+
+  /// Unread stream messages, as: stream ID → topic → message ID.
+  final Map<int, Map<String, QueueList<int>>> streams;
+
+  /// Unread DM messages, as: DM narrow → message ID.
+  final Map<DmNarrow, QueueList<int>> dms;
+
+  /// Unread messages with the self-user @-mentioned, directly or by wildcard.
+  ///
+  /// At initialization, if a message is:
+  ///   1) muted because of the user's stream- and topic-level choices [1], and
+  ///   2) wildcard mentioned but not directly mentioned
+  /// then it will be absent, and its unread state will be unknown to [mentions]
+  /// because the message is absent in [UnreadMessagesSnapshot]:
+  ///   https://chat.zulip.org/#narrow/stream/378-api-design/topic/register.3A.20maintaining.20.60unread_msgs.2Ementions.60.20correctly/near/1649584
+  /// If its state is actually unread, [mentions] recovers that knowledge when:
+  ///   a) the message is edited at all ([UpdateMessageEvent]),
+  ///      assuming it still has a direct or wildcard mention after the edit, or
+  ///   b) the message gains a direct @-mention ([UpdateMessageFlagsEvent]), or
+  ///   c) TODO unimplemented: the user loads the message in the message list
+  /// But otherwise, assume its unread state remains unknown to [mentions].
+  ///
+  /// [1] This item applies verbatim at Server 8.0+. For older servers, the
+  ///     item would say "in a muted stream" because the "unmute topic"
+  ///     feature was not considered:
+  ///       https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/register.3A.20.60unread_msgs.2Ementions.60/near/1645622
+  // If a message's unread state is unknown, it's likely the user doesn't
+  // care about it anyway -- it's really old, or it's in a muted conversation.
+  // Still, good to recover the knowledge when possible. In the rare case
+  // that a user shows they are interested, like by unmuting or loading messages
+  // in the message list, it's important to display as much known state as we can.
+  //
+  // TODO(server-8) Remove [1].
+  final Set<int> mentions;
+
+  /// Whether the model is missing data on old unread messages.
+  ///
+  /// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing].
+  /// Is set to false when the user clears out all unreads at once.
+  bool oldUnreadsMissing;
+
+  final int selfUserId;
+
+  void handleMessageEvent(MessageEvent event) {
+    final message = event.message;
+    if (message.flags.contains(MessageFlag.read)) {
+      return;
+    }
+
+    switch (message) {
+      case StreamMessage():
+        _addLastInStreamTopic(message.id, message.streamId, message.subject);
+      case DmMessage():
+        final narrow = DmNarrow.ofMessage(message, selfUserId: selfUserId);
+        _addLastInDm(message.id, narrow);
+    }
+    if (
+      message.flags.contains(MessageFlag.mentioned)
+      || message.flags.contains(MessageFlag.wildcardMentioned)
+    ) {
+      mentions.add(message.id);
+    }
+    notifyListeners();
+  }
+
+  void handleUpdateMessageEvent(UpdateMessageEvent event) {
+    final messageId = event.messageId;
+
+    // This event might signal mentions being added or removed in the
+    // [messageId] message when its content is edited; so, handle that.
+    // (As of writing, we don't expect such changes to be signaled by
+    // an [UpdateMessageFlagsEvent].)
+    final bool isMentioned = event.flags.any(
+      (f) => f == MessageFlag.mentioned || f == MessageFlag.wildcardMentioned,
+    );
+
+    // We assume this event can't signal a change in a message's 'read' flag.
+    // TODO can it actually though, when it's about messages being moved into an
+    //   unsubscribed stream?
+    //   https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957
+    final bool isRead = event.flags.contains(MessageFlag.read);
+    assert(() {
+      if (!oldUnreadsMissing && !event.messageIds.every((messageId) {
+        final isUnreadLocally = _slowIsPresentInDms(messageId) || _slowIsPresentInStreams(messageId);
+        return isUnreadLocally == !isRead;
+      })) {
+        // If this happens, then either:
+        // - the server and client have been out of sync about a message's
+        //   unread state since before this event, or
+        // - this event was unexpectedly used to announce a change in a
+        //   message's 'read' flag.
+        debugLog('Unreads warning: got surprising UpdateMessageEvent');
+      }
+      return true;
+    }());
+
+    bool madeAnyUpdate = false;
+
+    switch ((isRead, isMentioned)) {
+      case (true,  _    ):
+        // A mention (even if new with this event) makes no difference
+        // for a message that's already read.
+        break;
+      case (false, false):
+        madeAnyUpdate |= mentions.remove(messageId);
+      case (false, true ):
+        madeAnyUpdate |= mentions.add(messageId);
+    }
+
+    // (Moved messages will be handled here;
+    // the TODO for that is just above the class declaration.)
+
+    if (madeAnyUpdate) {
+      notifyListeners();
+    }
+  }
+
+  void handleDeleteMessageEvent(DeleteMessageEvent event) {
+    mentions.removeAll(event.messageIds);
+    final messageIdsSet = Set.of(event.messageIds);
+    switch (event.messageType) {
+      case MessageType.stream:
+        final streamId = event.streamId!;
+        final topic = event.topic!;
+        _removeAllInStreamTopic(messageIdsSet, streamId, topic);
+      case MessageType.private:
+        _slowRemoveAllInDms(messageIdsSet);
+    }
+
+    // TODO skip notifyListeners if unchanged?
+    notifyListeners();
+  }
+
+  void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) {
+    switch (event.flag) {
+      case MessageFlag.starred:
+      case MessageFlag.collapsed:
+      case MessageFlag.hasAlertWord:
+      case MessageFlag.historical:
+      case MessageFlag.unknown:
+        // These are irrelevant.
+        return;
+
+      case MessageFlag.mentioned:
+      case MessageFlag.wildcardMentioned:
+        // Empirically, we don't seem to get these events when a message is edited
+        // to add/remove an @-mention, even though @-mention state is represented
+        // as flags. Instead, we just get the [UpdateMessageEvent], and that
+        // contains the new set of flags, which we'll use to update [mentions].
+        // (See our handling of [UpdateMessageEvent].)
+        //
+        // Handle the event anyway, using the meaning on the tin.
+        // It might be used in a valid case we haven't thought of yet.
+
+        // TODO skip notifyListeners if unchanged?
+        switch (event) {
+          case UpdateMessageFlagsAddEvent():
+            mentions.addAll(
+              event.messages.where(
+                (messageId) => _slowIsPresentInStreams(messageId) || _slowIsPresentInDms(messageId),
+              ),
+            );
+
+          case UpdateMessageFlagsRemoveEvent():
+            mentions.removeAll(event.messages);
+        }
+
+      case MessageFlag.read:
+        switch (event) {
+          case UpdateMessageFlagsAddEvent():
+            if (event.all) {
+              streams.clear();
+              dms.clear();
+              mentions.clear();
+              oldUnreadsMissing = false;
+            } else {
+              final messageIdsSet = Set.of(event.messages);
+              mentions.removeAll(messageIdsSet);
+              _slowRemoveAllInStreams(messageIdsSet);
+              _slowRemoveAllInDms(messageIdsSet);
+            }
+          case UpdateMessageFlagsRemoveEvent():
+            final newlyUnreadInStreams = <int, Map<String, QueueList<int>>>{};
+            final newlyUnreadInDms = <DmNarrow, QueueList<int>>{};
+            for (final messageId in event.messages) {
+              final detail = event.messageDetails![messageId];
+              if (detail == null) { // TODO(log) if on Zulip 6.0+
+                // Happens as a bug in some cases before fixed in Zulip 6.0:
+                //   https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467
+                // TODO(server-6) remove Zulip 6.0 comment
+                continue;
+              }
+              if (detail.mentioned == true) {
+                mentions.add(messageId);
+              }
+              switch (detail.type) {
+                case MessageType.stream:
+                  final topics = (newlyUnreadInStreams[detail.streamId!] ??= {});
+                  final messageIds = (topics[detail.topic!] ??= QueueList());
+                  messageIds.add(messageId);
+                case MessageType.private:
+                  final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId,
+                    detail);
+                  (newlyUnreadInDms[narrow] ??= QueueList())
+                    .add(messageId);
+              }
+            }
+            newlyUnreadInStreams.forEach((incomingStreamId, incomingTopics) {
+              incomingTopics.forEach((incomingTopic, incomingMessageIds) {
+                _addAllInStreamTopic(incomingMessageIds..sort(), incomingStreamId, incomingTopic);
+              });
+            });
+            newlyUnreadInDms.forEach((incomingDmNarrow, incomingMessageIds) {
+              _addAllInDm(incomingMessageIds..sort(), incomingDmNarrow);
+            });
+        }
+    }
+    notifyListeners();
+  }
+
+  // TODO use efficient lookups
+  bool _slowIsPresentInStreams(int messageId) {
+    return streams.values.any(
+      (topics) => topics.values.any(
+        (messageIds) => messageIds.contains(messageId),
+      ),
+    );
+  }
+
+  void _addLastInStreamTopic(int messageId, int streamId, String topic) {
+    ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId);
+  }
+
+  // [messageIds] must be sorted ascending and without duplicates.
+  void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) {
+    final topics = streams[streamId] ??= {};
+    topics.update(topic,
+      ifAbsent: () => messageIds,
+      // setUnion dedupes existing and incoming unread IDs,
+      // so we tolerate zulip/zulip#22164, fixed in 6.0
+      // TODO(server-6) remove 6.0 comment
+      (existing) => setUnion(existing, messageIds),
+    );
+  }
+
+  // TODO use efficient model lookups
+  void _slowRemoveAllInStreams(Set<int> idsToRemove) {
+    final newlyEmptyStreams = [];
+    streams.forEach((streamId, topics) {
+      final newlyEmptyTopics = [];
+      topics.forEach((topic, messageIds) {
+        messageIds.removeWhere((id) => idsToRemove.contains(id));
+        if (messageIds.isEmpty) {
+          newlyEmptyTopics.add(topic);
+        }
+      });
+      for (final topic in newlyEmptyTopics) {
+        topics.remove(topic);
+      }
+      if (topics.isEmpty) {
+        newlyEmptyStreams.add(streamId);
+      }
+    });
+    for (final streamId in newlyEmptyStreams) {
+      streams.remove(streamId);
+    }
+  }
+
+  void _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, String topic) {
+    final topics = streams[streamId];
+    if (topics == null) return;
+    final messageIds = topics[topic];
+    if (messageIds == null) return;
+
+    // ([QueueList] doesn't have a `removeAll`)
+    messageIds.removeWhere((id) => incomingMessageIds.contains(id));
+    if (messageIds.isEmpty) {
+      topics.remove(topic);
+      if (topics.isEmpty) {
+        streams.remove(streamId);
+      }
+    }
+  }
+
+  // TODO use efficient model lookups
+  bool _slowIsPresentInDms(int messageId) {
+    return dms.values.any((ids) => ids.contains(messageId));
+  }
+
+  void _addLastInDm(int messageId, DmNarrow narrow) {
+    (dms[narrow] ??= QueueList()).addLast(messageId);
+  }
+
+  // [messageIds] must be sorted ascending and without duplicates.
+  void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) {
+    dms.update(dmNarrow,
+      ifAbsent: () => messageIds,
+      // setUnion dedupes existing and incoming unread IDs,
+      // so we tolerate zulip/zulip#22164, fixed in 6.0
+      // TODO(server-6) remove 6.0 comment
+      (existing) => setUnion(existing, messageIds),
+    );
+  }
+
+  // TODO use efficient model lookups
+  void _slowRemoveAllInDms(Set<int> idsToRemove) {
+    final newlyEmptyDms = [];
+    dms.forEach((dmNarrow, messageIds) {
+      messageIds.removeWhere((id) => idsToRemove.contains(id));
+      if (messageIds.isEmpty) {
+        newlyEmptyDms.add(dmNarrow);
+      }
+    });
+    for (final dmNarrow in newlyEmptyDms) {
+      dms.remove(dmNarrow);
+    }
+  }
+}

diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart
index eb05fb95b2..2fe7153065 100644
--- a/test/api/model/events_test.dart
+++ b/test/api/model/events_test.dart
@@ -43,6 +43,38 @@ void main() {
     check(mkEvent([MessageFlag.read])).message.flags.deepEquals([MessageFlag.read]);
   });

+  test('delete_message: require streamId and topic for stream messages', () {
+    check(() => DeleteMessageEvent.fromJson({
+      'id': 1,
+      'type': 'delete_message',
+      'message_ids': [1, 2, 3],
+      'message_type': 'private',
+    })).returnsNormally();
+
+    final baseJsonStream = {
+      'id': 1,
+      'type': 'delete_message',
+      'message_ids': [1, 2, 3],
+      'message_type': 'stream',
+    };
+
+    check(() => DeleteMessageEvent.fromJson({
+      ...baseJsonStream
+    })).throws();
+
+    check(() => DeleteMessageEvent.fromJson({
+      ...baseJsonStream, 'stream_id': 1, 'topic': 'some topic',
+    })).returnsNormally();
+
+    check(() => DeleteMessageEvent.fromJson({
+      ...baseJsonStream, 'stream_id': 1,
+    })).throws();
+
+    check(() => DeleteMessageEvent.fromJson({
+      ...baseJsonStream, 'topic': 'some topic',
+    })).throws();
+  });
+
   test('update_message_flags/remove: require messageDetails in mark-as-unread', () {
     final baseJson = {
       'id': 1,
diff --git a/test/api/model/initial_snapshot_test.dart b/test/api/model/initial_snapshot_test.dart
new file mode 100644
index 0000000000..d89dfaec43
--- /dev/null
+++ b/test/api/model/initial_snapshot_test.dart
@@ -0,0 +1,43 @@
+import 'package:checks/checks.dart';
+import 'package:test/scaffolding.dart';
+import 'package:zulip/api/model/initial_snapshot.dart';
+
+void main() {
+  test('UnreadDmSnapshot: require sorted unreadMessageIds', () {
+    check(() => UnreadDmSnapshot.fromJson({
+      'other_user_id': 1,
+      'unread_message_ids': [1, 2, 3],
+    })).returnsNormally();
+
+    check(() => UnreadDmSnapshot.fromJson({
+      'other_user_id': 1,
+      'unread_message_ids': [11, 2, 3],
+    })).throws<AssertionError>();
+  });
+
+  test('UnreadStreamSnapshot: require sorted unreadMessageIds', () {
+    check(() => UnreadStreamSnapshot.fromJson({
+      'topic': 'a',
+      'stream_id': 1,
+      'unread_message_ids': [1, 2, 3],
+    })).returnsNormally();
+
+    check(() => UnreadStreamSnapshot.fromJson({
+      'topic': 'a',
+      'stream_id': 1,
+      'unread_message_ids': [11, 2, 3],
+    })).throws<AssertionError>();
+  });
+
+  test('UnreadHuddleSnapshot: require sorted unreadMessageIds', () {
+    check(() => UnreadHuddleSnapshot.fromJson({
+      'user_ids_string': '1,2',
+      'unread_message_ids': [1, 2, 3],
+    })).returnsNormally();
+
+    check(() => UnreadHuddleSnapshot.fromJson({
+      'user_ids_string': '1,2',
+      'unread_message_ids': [11, 2, 3],
+    })).throws<AssertionError>();
+  });
+}
diff --git a/test/model/algorithms_test.dart b/test/model/algorithms_test.dart
index ad6d4b3ed2..b6def6393e 100644
--- a/test/model/algorithms_test.dart
+++ b/test/model/algorithms_test.dart
@@ -36,4 +36,23 @@ void main() {
       check(search(7)).equals(-1);
     });
   });
+
+  group('setUnion', () {
+    for (final (String desc, Iterable<int> xs, Iterable<int> ys) in [
+      ('empty', [], []),
+      ('nonempty, empty', [1, 2], []),
+      ('empty, nonempty', [], [1, 2]),
+      ('in order', [1, 2], [3, 4]),
+      ('reversed', [3, 4], [1, 2]),
+      ('interleaved', [1, 3], [2, 4]),
+      ('all dupes', [1, 2], [1, 2]),
+      ('some dupes', [1, 2], [2, 3]),
+      ('comparison is numeric, not lexicographic', [11], [2]),
+    ]) {
+      test(desc, () {
+        final expected = Set.of(xs.followedBy(ys)).toList()..sort();
+        check(setUnion(xs, ys)).deepEquals(expected);
+      });
+    }
+  });
 }
diff --git a/test/model/unreads_checks.dart b/test/model/unreads_checks.dart
new file mode 100644
index 0000000000..836e497b2b
--- /dev/null
+++ b/test/model/unreads_checks.dart
@@ -0,0 +1,11 @@
+import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/unreads.dart';
+
+extension UnreadsChecks on Subject<Unreads> {
+  Subject<Map<int, Map<String, QueueList<int>>>> get streams => has((u) => u.streams, 'streams');
+  Subject<Map<DmNarrow, QueueList<int>>> get dms => has((u) => u.dms, 'dms');
+  Subject<Set<int>> get mentions => has((u) => u.mentions, 'mentions');
+  Subject<bool> get oldUnreadsMissing => has((u) => u.oldUnreadsMissing, 'oldUnreadsMissing');
+}
diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart
new file mode 100644
index 0000000000..3d60170b3f
--- /dev/null
+++ b/test/model/unreads_test.dart
@@ -0,0 +1,908 @@
+import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
+import 'package:test/scaffolding.dart';
+import 'package:zulip/api/model/events.dart';
+import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/unreads.dart';
+
+import '../example_data.dart' as eg;
+import 'unreads_checks.dart';
+
+void main() {
+  // These variables are the common state operated on by each test.
+  // Each test case calls [prepare] to initialize them.
+  late Unreads model;
+  late int notifiedCount;
+
+  void checkNotified({required int count}) {
+    check(notifiedCount).equals(count);
+    notifiedCount = 0;
+  }
+  void checkNotNotified() => checkNotified(count: 0);
+  void checkNotifiedOnce() => checkNotified(count: 1);
+
+  /// Initialize [model] and the rest of the test state.
+  void prepare({
+    UnreadMessagesSnapshot initial = const UnreadMessagesSnapshot(
+      count: 0,
+      streams: [],
+      dms: [],
+      huddles: [],
+      mentions: [],
+      oldUnreadsMissing: false,
+    ),
+  }) {
+    notifiedCount = 0;
+    model = Unreads(initial: initial, selfUserId: eg.selfUser.userId)
+      ..addListener(() {
+        notifiedCount++;
+      });
+    checkNotNotified();
+  }
+
+  void fillWithMessages(Iterable<Message> messages) {
+    for (final message in messages) {
+      model.handleMessageEvent(MessageEvent(id: 0, message: message));
+    }
+    notifiedCount = 0;
+  }
+
+  void checkMatchesMessages(Iterable<Message> messages) {
+    assert(Set.of(messages.map((m) => m.id)).length == messages.length,
+      'checkMatchesMessages: duplicate messages in test input');
+
+    final Map<int, Map<String, QueueList<int>>> expectedStreams = {};
+    final Map<DmNarrow, QueueList<int>> expectedDms = {};
+    final Set<int> expectedMentions = {};
+    for (final message in messages) {
+      if (message.flags.contains(MessageFlag.read)) {
+        continue;
+      }
+      switch (message) {
+        case StreamMessage():
+          final perTopic = expectedStreams[message.streamId] ??= {};
+          final messageIds = perTopic[message.subject] ??= QueueList();
+          messageIds.add(message.id);
+        case DmMessage():
+          final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId);
+          final messageIds = expectedDms[narrow] ??= QueueList();
+          messageIds.add(message.id);
+      }
+      if (
+        message.flags.contains(MessageFlag.mentioned)
+        || message.flags.contains(MessageFlag.wildcardMentioned)
+      ) {
+        expectedMentions.add(message.id);
+      }
+    }
+    for (final perTopic in expectedStreams.values) {
+      for (final messageIds in perTopic.values) {
+        messageIds.sort();
+      }
+    }
+    for (final messageIds in expectedDms.values) {
+      messageIds.sort();
+    }
+
+    check(model)
+      ..streams.deepEquals(expectedStreams)
+      ..dms.deepEquals(expectedDms)
+      ..mentions.unorderedEquals(expectedMentions);
+  }
+
+  group('constructor', () {
+    test('empty', () {
+      prepare();
+      checkMatchesMessages([]);
+    });
+
+    test('not empty', () {
+      final stream1 = eg.stream(streamId: 1);
+      final stream2 = eg.stream(streamId: 2);
+
+      final user1 = eg.user(userId: 1);
+      final user2 = eg.user(userId: 2);
+      final user3 = eg.user(userId: 3);
+
+      prepare(initial: UnreadMessagesSnapshot(
+        count: 0,
+        streams: [
+          UnreadStreamSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]),
+          UnreadStreamSnapshot(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]),
+          UnreadStreamSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]),
+          UnreadStreamSnapshot(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]),
+        ],
+        dms: [
+          UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]),
+          UnreadDmSnapshot(otherUserId: 2, unreadMessageIds: [11, 12]),
+        ],
+        huddles: [
+          UnreadHuddleSnapshot(userIdsString: '1,2,${eg.selfUser.userId}', unreadMessageIds: [13, 14]),
+          UnreadHuddleSnapshot(userIdsString: '2,3,${eg.selfUser.userId}', unreadMessageIds: [15, 16]),
+        ],
+        mentions: [6, 12, 16],
+        oldUnreadsMissing: false,
+      ));
+      checkMatchesMessages([
+        eg.streamMessage(id: 1, stream: stream1, topic: 'a', flags: []),
+        eg.streamMessage(id: 2, stream: stream1, topic: 'a', flags: []),
+        eg.streamMessage(id: 3, stream: stream1, topic: 'b', flags: []),
+        eg.streamMessage(id: 4, stream: stream1, topic: 'b', flags: []),
+        eg.streamMessage(id: 5, stream: stream2, topic: 'b', flags: []),
+        eg.streamMessage(id: 6, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]),
+        eg.streamMessage(id: 7, stream: stream2, topic: 'c', flags: []),
+        eg.streamMessage(id: 8, stream: stream2, topic: 'c', flags: []),
+        eg.dmMessage(id: 9,  from: user1, to: [eg.selfUser], flags: []),
+        eg.dmMessage(id: 10, from: user1, to: [eg.selfUser], flags: []),
+        eg.dmMessage(id: 11, from: user2, to: [eg.selfUser], flags: []),
+        eg.dmMessage(id: 12, from: user2, to: [eg.selfUser], flags: [MessageFlag.mentioned]),
+        eg.dmMessage(id: 13, from: user1, to: [user2, eg.selfUser], flags: []),
+        eg.dmMessage(id: 14, from: user1, to: [user2, eg.selfUser], flags: []),
+        eg.dmMessage(id: 15, from: user2, to: [user3, eg.selfUser], flags: []),
+        eg.dmMessage(id: 16, from: user2, to: [user3, eg.selfUser], flags: [MessageFlag.wildcardMentioned]),
+      ]);
+    });
+  });
+
+  group('handleMessageEvent', () {
+    for (final (isUnread, isStream, isDirectMentioned, isWildcardMentioned) in [
+      (true,  true,  true,  true ),
+      (true,  true,  true,  false),
+      (true,  true,  false, true ),
+      (true,  true,  false, false),
+      (true,  false, true,  true ),
+      (true,  false, true,  false),
+      (true,  false, false, true ),
+      (true,  false, false, false),
+      (false, true,  true,  true ),
+      (false, true,  true,  false),
+      (false, true,  false, true ),
+      (false, true,  false, false),
+      (false, false, true,  true ),
+      (false, false, true,  false),
+      (false, false, false, true ),
+      (false, false, false, false),
+    ]) {
+      final description = [
+        isUnread ? 'unread' : 'read',
+        isStream ? 'stream' : 'dm',
+        isDirectMentioned ? 'direct mentioned' : 'not direct mentioned',
+        isWildcardMentioned ? 'wildcard mentioned' : 'not wildcard mentioned',
+      ].join(' / ');
+      test(description, () {
+        prepare();
+        final flags = [
+          if (!isUnread)           MessageFlag.read,
+          if (isDirectMentioned)   MessageFlag.mentioned,
+          if (isWildcardMentioned) MessageFlag.wildcardMentioned,
+        ];
+        final message = isStream
+          ? eg.streamMessage(flags: flags)
+          : eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: flags);
+        model.handleMessageEvent(MessageEvent(id: 0, message: message));
+        if (isUnread) {
+          checkNotifiedOnce();
+        }
+        checkMatchesMessages([message]);
+      });
+    }
+
+    group('stream messages', () {
+      group('new unread follows existing unread', () {
+        final stream1 = eg.stream(streamId: 1);
+        final stream2 = eg.stream(streamId: 2);
+        for (final (oldStream, newStream, oldTopic, newTopic) in [
+          (stream1, stream1, 'a', 'a'),
+          (stream1, stream1, 'a', 'b'),
+          (stream1, stream2, 'a', 'a'),
+          (stream1, stream2, 'a', 'b'),
+        ]) {
+          final description = [
+            oldStream.streamId == newStream.streamId ? 'same stream' : 'different stream',
+            oldTopic == newTopic ? 'same topic' : 'different topic',
+          ].join(' / ');
+          test(description, () {
+            final oldMessage = eg.streamMessage(id: 1, stream: oldStream, topic: oldTopic, flags: []);
+            final newMessage = eg.streamMessage(id: 2, stream: newStream, topic: newTopic, flags: []);
+
+            prepare();
+            fillWithMessages([oldMessage]);
+            model.handleMessageEvent(MessageEvent(id: 0, message: newMessage));
+            checkNotifiedOnce();
+            checkMatchesMessages([oldMessage, newMessage]);
+          });
+        }
+      });
+    });
+
+    group('DM messages', () {
+      final variousDms = [
+        ('self 1:1', eg.selfUser,  <User>[]),
+        ('1:1',      eg.otherUser, <User>[eg.selfUser]),
+        ('1:1',      eg.thirdUser, <User>[eg.selfUser]),
+        ('group',    eg.otherUser, <User>[eg.selfUser, eg.thirdUser]),
+        ('group',    eg.otherUser, <User>[eg.selfUser, eg.user(userId: 456)]),
+      ];
+
+      group('DM narrow subtypes', () {
+        for (final (desc, from, to) in variousDms) {
+          test(desc, () {
+            final message = eg.dmMessage(from: from, to: to, flags: []);
+
+            prepare();
+            model.handleMessageEvent(MessageEvent(id: 0, message: message));
+            checkNotifiedOnce();
+            checkMatchesMessages([message]);
+          });
+        }
+      });
+
+      group('new unread follows existing unread', () {
+        for (final (oldDesc, oldFrom, oldTo) in variousDms) {
+          final oldMessage = eg.dmMessage(id: 1, from: oldFrom, to: oldTo, flags: []);
+          final oldNarrow = DmNarrow.ofMessage(oldMessage, selfUserId: eg.selfUser.userId);
+          for (final (newDesc, newFrom, newTo) in variousDms) {
+            final newMessage = eg.dmMessage(id: 2, from: newFrom, to: newTo, flags: []);
+            final newNarrow = DmNarrow.ofMessage(newMessage, selfUserId: eg.selfUser.userId);
+
+            test('existing in $oldDesc narrow; new in ${oldNarrow == newNarrow ? 'same narrow' : 'different narrow ($newDesc)'}', () {
+              prepare();
+              fillWithMessages([oldMessage]);
+              model.handleMessageEvent(MessageEvent(id: 0, message: newMessage));
+              checkNotifiedOnce();
+              checkMatchesMessages([oldMessage, newMessage]);
+            });
+          }
+        }
+      });
+    });
+  });
+
+  group('handleUpdateMessageEvent', () {
+    group('mentions', () {
+      for (final isKnownToModel in [true, false]) {
+        for (final isRead in [false, true]) {
+          final baseFlags = [if (isRead) MessageFlag.read];
+          for (final (messageDesc, message) in [
+            ('stream', eg.streamMessage(flags: baseFlags)),
+            ('1:1 dm', eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: baseFlags)),
+          ]) {
+            test('${isRead ? 'read' : 'unread'} $messageDesc message${isKnownToModel ? '' : ' (but read state unknown to model)'}', () {
+              prepare();
+              fillWithMessages([
+                if (isKnownToModel) message,
+              ]);
+
+              for (final List<MessageFlag> newFlags in [
+                [...baseFlags, MessageFlag.mentioned],
+                [...baseFlags, MessageFlag.mentioned, MessageFlag.wildcardMentioned],
+                [...baseFlags, MessageFlag.wildcardMentioned],
+                [...baseFlags, ],
+                [...baseFlags, MessageFlag.wildcardMentioned],
+              ]) {
+                assert(newFlags.contains(MessageFlag.read) == isRead);
+                if (!isKnownToModel) {
+                  check(because: "no crash if message is in model's blindspots",
+                    () => model.handleUpdateMessageEvent(
+                      UpdateMessageEvent(id: 0, messageId: message.id, messageIds: [], flags: newFlags),
+                    )).returnsNormally();
+                  // Rarely, this event will be about an unread that's unknown
+                  // to the model, or at least one of the model's components;
+                  // see e.g. [oldUnreadsMissing]. When that happens, I think
+                  // we won't usually have necessary data (in the event or model)
+                  // to make the unread appear in [streams] or [dms]
+                  // if it wasn't there before. However, for stream messages,
+                  // we may have that needed data if this event happens to
+                  // signal that the message was moved.
+                  //
+                  // If the message in this event is unread and mentioned,
+                  // I *think* the model will cause it to correctly appear in
+                  // [mentions], as of 2023-10.
+                  //
+                  // TODO in any case, confirm behavior and run appropriate
+                  //   model checks here.
+                  notifiedCount = 0;
+                  continue;
+                }
+                model.handleUpdateMessageEvent(
+                  UpdateMessageEvent(id: 0, messageId: message.id, messageIds: [], flags: newFlags),
+                );
+
+                if (
+                  isRead
+                  || (
+                    // TODO make less verbose
+                    (message.flags.contains(MessageFlag.mentioned) || message.flags.contains(MessageFlag.wildcardMentioned))
+                      == (newFlags.contains(MessageFlag.mentioned) || newFlags.contains(MessageFlag.wildcardMentioned))
+                  )
+                ) {
+                  checkNotNotified();
+                } else {
+                  checkNotifiedOnce();
+                }
+                // It would be more realistic to set [message]'s content too,
+                // but the implementation doesn't depend on that.
+                message.flags = newFlags;
+                checkMatchesMessages(isKnownToModel ? [message] : []);
+              }
+            });
+          }
+        }
+      }
+    });
+  });
+
+
+  group('handleDeleteMessageEvent', () {
+    final stream1 = eg.stream(streamId: 1);
+    final stream2 = eg.stream(streamId: 2);
+
+    final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [], flags: []);
+    final message2 = eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser], flags: []);
+    final message3 = eg.dmMessage(id: 3, from: eg.thirdUser, to: [eg.selfUser], flags: []);
+    final message4 = eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []);
+    final message5 = eg.dmMessage(id: 5, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: []);
+
+    final message6 = eg.dmMessage(id: 6, from: eg.selfUser, to: [], flags: [MessageFlag.mentioned]);
+    final message7 = eg.dmMessage(id: 7, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]);
+    final message8 = eg.dmMessage(id: 8, from: eg.thirdUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]);
+    final message9 = eg.dmMessage(id: 9, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: [MessageFlag.mentioned]);
+    final message10 = eg.dmMessage(id: 10, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: [MessageFlag.mentioned]);
+
+    final message11 = eg.streamMessage(id: 11, stream: stream1, topic: 'a', flags: []);
+    final message12 = eg.streamMessage(id: 12, stream: stream1, topic: 'a', flags: [MessageFlag.mentioned]);
+    final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: []);
+    final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]);
+
+    final messages = [
+      message1, message2, message3, message4, message5,
+      message6, message7, message8, message9, message10,
+      message11, message12, message13, message14,
+    ];
+
+    test('single deletes of unreads', () {
+      prepare();
+      fillWithMessages(messages);
+
+      final expectedRemainingMessages = Set.of(messages);
+      for (final message in messages) {
+        final event = switch (message) {
+          StreamMessage() => DeleteMessageEvent(
+            id: 0,
+            messageType: MessageType.stream,
+            messageIds: [message.id],
+            streamId: message.streamId,
+            topic: message.subject,
+          ),
+          DmMessage() => DeleteMessageEvent(id: 0, messageType: MessageType.private, messageIds: [message.id]),
+        };
+        model.handleDeleteMessageEvent(event);
+        checkNotifiedOnce();
+        checkMatchesMessages(expectedRemainingMessages..remove(message));
+      }
+    });
+
+    test('bulk deletes of unreads', () {
+      prepare();
+      fillWithMessages(messages);
+
+      final expectedRemainingMessages = Set.of(messages);
+
+      model.handleDeleteMessageEvent(DeleteMessageEvent(
+        id: 0,
+        messageIds: [11, 12],
+        messageType: MessageType.stream,
+        streamId: stream1.streamId,
+        topic: 'a',
+      ));
+      checkNotifiedOnce();
+      checkMatchesMessages(expectedRemainingMessages..removeAll([message11, message12]));
+      model.handleDeleteMessageEvent(DeleteMessageEvent(
+        id: 0,
+        messageIds: [13, 14],
+        messageType: MessageType.stream,
+        streamId: stream2.streamId,
+        topic: 'b',
+      ));
+      checkNotifiedOnce();
+      checkMatchesMessages(expectedRemainingMessages..removeAll([message13, message14]));
+      model.handleDeleteMessageEvent(DeleteMessageEvent(
+        id: 0,
+        messageIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
+        messageType: MessageType.private,
+      ));
+      checkNotifiedOnce();
+      checkMatchesMessages([]);
+    });
+
+    test('delete read (or unknown) stream message', () {
+      final message = eg.streamMessage(flags: [MessageFlag.read]);
+
+      prepare();
+      // Equivalently, could do: fillWithMessages([message]);
+      model.handleDeleteMessageEvent(DeleteMessageEvent(
+        id: 0,
+        messageIds: [message.id],
+        messageType: MessageType.stream,
+        streamId: message.streamId,
+        topic: message.subject,
+      ));
+      // TODO improve implementation; then:
+      //   checkNotNotified();
+      // For now, at least check callers aren't notified *more* than once:
+      checkNotifiedOnce();
+      checkMatchesMessages([message]);
+    });
+
+    test('delete read (or unknown) DM message', () {
+      final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.read]);
+
+      prepare();
+      // Equivalently, could do: fillWithMessages([message]);
+      model.handleDeleteMessageEvent(DeleteMessageEvent(
+        id: 0,
+        messageIds: [message.id],
+        messageType: MessageType.private,
+      ));
+      // TODO improve implementation; then:
+      //   checkNotNotified();
+      // For now, at least check callers aren't notified *more* than once:
+      checkNotifiedOnce();
+      checkMatchesMessages([message]);
+    });
+  });
+
+  group('handleUpdateMessageFlagsEvent', () {
+    final irrelevantFlags = MessageFlag.values.where((flag) =>
+      switch (flag) {
+        MessageFlag.starred => true,
+        MessageFlag.collapsed => true,
+        MessageFlag.hasAlertWord => true,
+        MessageFlag.historical => true,
+        MessageFlag.unknown => true,
+        MessageFlag.mentioned => false,
+        MessageFlag.wildcardMentioned => false,
+        MessageFlag.read => false,
+      });
+
+    for (final (isRead, isMentioned) in [
+      (true,  true ),
+      (true,  false),
+      (false, true ),
+      (false, false),
+    ]) {
+      // When isRead is true, the message won't appear in the model.
+      // That case is indistinguishable from an unread that's unknown to
+      // the model, so we get coverage for that case too.
+      test('remove irrelevant flags; ${isRead ? 'read' : 'unread'} / ${isMentioned ? 'mentioned' : 'not mentioned'}', () {
+        final message = eg.streamMessage(flags: [
+          ...irrelevantFlags,
+          if (isRead) MessageFlag.read,
+          if (isMentioned) MessageFlag.mentioned,
+        ]);
+        prepare();
+        fillWithMessages([message]);
+        for (final flag in irrelevantFlags) {
+          model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsRemoveEvent(
+            id: 0,
+            flag: flag,
+            messages: [message.id],
+            messageDetails: null,
+          ));
+          checkNotNotified();
+          message.flags.remove(flag);
+          checkMatchesMessages([message]);
+        }
+      });
+    }
+
+    for (final (isRead, isMentioned) in [
+      (true,  true ),
+      (true,  false),
+      (false, true ),
+      (false, false),
+    ]) {
+      // When isRead is true, the message won't appear in the model.
+      // That case is indistinguishable from an unread that's unknown to
+      // the model, so we get coverage for that case too.
+      test('add irrelevant flags; ${isRead ? 'read' : 'unread'} / ${isMentioned ? 'mentioned' : 'not mentioned'}', () {
+        final message = eg.streamMessage(flags: [
+          if (isRead) MessageFlag.read,
+          if (isMentioned) MessageFlag.mentioned,
+        ]);
+        prepare();
+        fillWithMessages([message]);
+        for (final flag in irrelevantFlags) {
+          model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+            id: 0,
+            flag: flag,
+            messages: [message.id],
+            all: false,
+          ));
+          checkNotNotified();
+          message.flags.add(flag);
+          checkMatchesMessages([message]);
+        }
+      });
+    }
+
+    for (final mentionFlag in [MessageFlag.mentioned, MessageFlag.wildcardMentioned]) {
+      // For a read message in this test, the message won't appear in the model.
+      // That case is indistinguishable from an unread that's unknown to
+      // the model, so we get coverage for that case too.
+      test('add flag: ${mentionFlag.name}', () {
+        final messages = [
+          eg.streamMessage(id: 1, flags: []),
+          eg.streamMessage(id: 2, flags: [MessageFlag.read]),
+          eg.dmMessage(id: 3, from: eg.otherUser, to: [eg.selfUser], flags: []),
+          eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.read]),
+        ];
+
+        prepare();
+        fillWithMessages(messages);
+        for (final message in messages) {
+          model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+            id: 0,
+            flag: mentionFlag,
+            messages: [message.id],
+            all: false,
+          ));
+          if (!message.flags.contains(MessageFlag.read)) {
+            checkNotifiedOnce();
+          } else {
+            // TODO improve implementation; then:
+            //   checkNotNotified();
+            // For now, at least check callers aren't notified *more* than once:
+            checkNotifiedOnce();
+          }
+          message.flags.add(mentionFlag);
+          checkMatchesMessages(messages);
+        }
+      });
+
+      // TODO test adding wildcard mention when direct mention is already present
+      //   and vice versa (implementation should skip notifyListeners;
+      //   test should checkNotNotified)
+
+      // For a read message in this test, the message won't appear in the model.
+      // That case is indistinguishable from an unread that's unknown to
+      // the model, so we get coverage for that case too.
+      test('remove flag: ${mentionFlag.name}', () {
+        final messages = [
+          eg.streamMessage(id: 1, flags: [mentionFlag]),
+          eg.streamMessage(id: 2, flags: [mentionFlag, MessageFlag.read]),
+          eg.dmMessage(id: 3, from: eg.otherUser, to: [eg.selfUser], flags: [mentionFlag]),
+          eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser], flags: [mentionFlag, MessageFlag.read]),
+        ];
+
+        prepare();
+        fillWithMessages(messages);
+        for (final message in messages) {
+          model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsRemoveEvent(
+            id: 0,
+            flag: mentionFlag,
+            messages: [message.id],
+            messageDetails: null,
+          ));
+          if (!message.flags.contains(MessageFlag.read)) {
+            checkNotifiedOnce();
+          } else {
+            // TODO improve implementation; then:
+            //   checkNotNotified();
+            // For now, at least check callers aren't notified *more* than once:
+            checkNotifiedOnce();
+          }
+          message.flags.remove(mentionFlag);
+          checkMatchesMessages(messages);
+        }
+      });
+    }
+
+    // TODO test removing just direct or wildcard mention when both are present
+    //   (implementation should skip notifyListeners;
+    //   test should checkNotNotified)
+
+    test('mark all as read', () {
+      final message1 = eg.streamMessage(id: 1, flags: []);
+      final message2 = eg.streamMessage(id: 2, flags: [MessageFlag.mentioned]);
+      final message3 = eg.dmMessage(id: 3, from: eg.otherUser, to: [eg.selfUser], flags: []);
+      final message4 = eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.wildcardMentioned]);
+      final messages = [message1, message2, message3, message4];
+
+      prepare();
+      fillWithMessages([message1, message2, message3, message4]);
+
+      // Might as well test with oldUnreadsMissing: true.
+      //
+      // We didn't fill the model with 50k unreads, so this is questionably
+      // realistic… but the 50k cap isn't actually API-guaranteed, and this is
+      // plausibly realistic for a hypothetical server that decides based on
+      // message age rather than the 50k cap.
+      model.oldUnreadsMissing = true;
+
+      model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+        id: 0,
+        flag: MessageFlag.read,
+        messages: [],
+        all: true,
+      ));
+      checkNotifiedOnce();
+      for (final message in messages) {
+        message.flags.add(MessageFlag.read);
+      }
+      checkMatchesMessages(messages);
+      check(model).oldUnreadsMissing.isFalse();
+    });
+
+    group('mark some as read', () {
+      test('usual cases', () {
+        final stream1 = eg.stream(streamId: 1);
+        final stream2 = eg.stream(streamId: 2);
+
+        final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [], flags: []);
+        final message2 = eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser], flags: []);
+        final message3 = eg.dmMessage(id: 3, from: eg.thirdUser, to: [eg.selfUser], flags: []);
+        final message4 = eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []);
+        final message5 = eg.dmMessage(id: 5, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: []);
+
+        final message6 = eg.dmMessage(id: 6, from: eg.selfUser, to: [], flags: [MessageFlag.mentioned]);
+        final message7 = eg.dmMessage(id: 7, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]);
+        final message8 = eg.dmMessage(id: 8, from: eg.thirdUser, to: [eg.selfUser], flags: [MessageFlag.mentioned]);
+        final message9 = eg.dmMessage(id: 9, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: [MessageFlag.mentioned]);
+        final message10 = eg.dmMessage(id: 10, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: [MessageFlag.mentioned]);
+
+        final message11 = eg.streamMessage(id: 11, stream: stream1, topic: 'a', flags: []);
+        final message12 = eg.streamMessage(id: 12, stream: stream1, topic: 'a', flags: [MessageFlag.mentioned]);
+        final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: []);
+        final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned]);
+
+        final messages = [
+          message1, message2, message3, message4, message5,
+          message6, message7, message8, message9, message10,
+          message11, message12, message13, message14,
+        ];
+
+        prepare();
+        fillWithMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+          id: 0,
+          flag: MessageFlag.read,
+          messages: [message1.id],
+          all: false,
+        ));
+        checkNotifiedOnce();
+        message1.flags.add(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+          id: 0,
+          flag: MessageFlag.read,
+          messages: [message14.id],
+          all: false,
+        ));
+        checkNotifiedOnce();
+        message14.flags.add(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+          id: 0,
+          flag: MessageFlag.read,
+          messages: [message5.id, message6.id],
+          all: false,
+        ));
+        checkNotifiedOnce();
+        message5.flags.add(MessageFlag.read);
+        message6.flags.add(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+          id: 0,
+          flag: MessageFlag.read,
+          messages: [
+            message2.id, message3.id, message3.id, message4.id,
+            message7.id, message8.id, message9.id, message10.id,
+            message11.id, message12.id, message13.id,
+          ],
+          all: false,
+        ));
+        checkNotifiedOnce();
+        message2.flags.add(MessageFlag.read);
+        message3.flags.add(MessageFlag.read);
+        message4.flags.add(MessageFlag.read);
+        message7.flags.add(MessageFlag.read);
+        message8.flags.add(MessageFlag.read);
+        message9.flags.add(MessageFlag.read);
+        message10.flags.add(MessageFlag.read);
+        message11.flags.add(MessageFlag.read);
+        message12.flags.add(MessageFlag.read);
+        message13.flags.add(MessageFlag.read);
+        checkMatchesMessages(messages);
+      });
+
+      test('on unreads that are unknown to the model', () {
+        final stream = eg.stream();
+        final message1 = eg.streamMessage(id: 1, flags: [], stream: stream, topic: 'a');
+        final message2 = eg.dmMessage(id: 2, flags: [], from: eg.otherUser, to: [eg.selfUser]);
+        final message3 = eg.streamMessage(id: 3, flags: [], stream: stream, topic: 'a');
+        final message4 = eg.dmMessage(id: 4, flags: [], from: eg.otherUser, to: [eg.selfUser]);
+
+        prepare();
+        fillWithMessages([message3, message4]);
+
+        check(() => model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsAddEvent(
+          id: 0,
+          flag: MessageFlag.read,
+          messages: [message1.id, message2.id, message3.id, message4.id],
+          all: false,
+        ))).returnsNormally();
+        checkNotifiedOnce();
+        message3.flags.add(MessageFlag.read);
+        message4.flags.add(MessageFlag.read);
+        checkMatchesMessages([message3, message4]);
+      });
+    });
+
+    group('mark as unread', () {
+      mkEvent(messages) => eg.updateMessageFlagsRemoveEvent(MessageFlag.read, messages);
+
+      test('usual cases', () {
+        final stream1 = eg.stream(streamId: 1);
+        final stream2 = eg.stream(streamId: 2);
+
+        final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [], flags: [MessageFlag.read]);
+        final message2 = eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.read]);
+        final message3 = eg.dmMessage(id: 3, from: eg.thirdUser, to: [eg.selfUser], flags: [MessageFlag.read]);
+        final message4 = eg.dmMessage(id: 4, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: [MessageFlag.read]);
+        final message5 = eg.dmMessage(id: 5, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: [MessageFlag.read]);
+
+        final message6 = eg.dmMessage(id: 6, from: eg.selfUser, to: [], flags: [MessageFlag.mentioned, MessageFlag.read]);
+        final message7 = eg.dmMessage(id: 7, from: eg.otherUser, to: [eg.selfUser], flags: [MessageFlag.mentioned, MessageFlag.read]);
+        final message8 = eg.dmMessage(id: 8, from: eg.thirdUser, to: [eg.selfUser], flags: [MessageFlag.mentioned, MessageFlag.read]);
+        final message9 = eg.dmMessage(id: 9, from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: [MessageFlag.mentioned, MessageFlag.read]);
+        final message10 = eg.dmMessage(id: 10, from: eg.otherUser, to: [eg.selfUser, eg.user(userId: 456)], flags: [MessageFlag.mentioned, MessageFlag.read]);
+
+        final message11 = eg.streamMessage(id: 11, stream: stream1, topic: 'a', flags: [MessageFlag.read]);
+        final message12 = eg.streamMessage(id: 12, stream: stream1, topic: 'a', flags: [MessageFlag.mentioned, MessageFlag.read]);
+        final message13 = eg.streamMessage(id: 13, stream: stream2, topic: 'b', flags: [MessageFlag.read]);
+        final message14 = eg.streamMessage(id: 14, stream: stream2, topic: 'b', flags: [MessageFlag.mentioned, MessageFlag.read]);
+
+        final messages = [
+          message1, message2, message3, message4, message5,
+          message6, message7, message8, message9, message10,
+          message11, message12, message13, message14,
+        ];
+
+        prepare();
+        fillWithMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message1]));
+        checkNotifiedOnce();
+        message1.flags.remove(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message14]));
+        checkNotifiedOnce();
+        message14.flags.remove(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message5, message6]));
+        checkNotifiedOnce();
+        message5.flags.remove(MessageFlag.read);
+        message6.flags.remove(MessageFlag.read);
+        checkMatchesMessages(messages);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([
+          message2, message3, message4,
+          message7, message8, message9, message10,
+          message11, message12, message13,
+        ]));
+        checkNotifiedOnce();
+        message2.flags.remove(MessageFlag.read);
+        message3.flags.remove(MessageFlag.read);
+        message4.flags.remove(MessageFlag.read);
+        message7.flags.remove(MessageFlag.read);
+        message8.flags.remove(MessageFlag.read);
+        message9.flags.remove(MessageFlag.read);
+        message10.flags.remove(MessageFlag.read);
+        message11.flags.remove(MessageFlag.read);
+        message12.flags.remove(MessageFlag.read);
+        message13.flags.remove(MessageFlag.read);
+        checkMatchesMessages(messages);
+      });
+
+      test('tolerates unsorted event.messages: stream messages', () {
+        final stream = eg.stream();
+        final message1 = eg.streamMessage(id: 1, flags: [MessageFlag.read], stream: stream, topic: 'a');
+        final message2 = eg.streamMessage(id: 2, flags: [MessageFlag.read], stream: stream, topic: 'a');
+
+        prepare();
+        fillWithMessages([message1, message2]);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message2, message1]));
+        checkNotifiedOnce();
+
+        message1.flags.remove(MessageFlag.read);
+        message2.flags.remove(MessageFlag.read);
+        checkMatchesMessages([message1, message2]);
+      });
+
+      test('tolerates unsorted event.messages: DM messages', () {
+        final message1 = eg.dmMessage(id: 1, flags: [MessageFlag.read], from: eg.otherUser, to: [eg.selfUser]);
+        final message2 = eg.dmMessage(id: 2, flags: [MessageFlag.read], from: eg.otherUser, to: [eg.selfUser]);
+
+        prepare();
+        fillWithMessages([message1, message2]);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message2, message1]));
+        checkNotifiedOnce();
+
+        message1.flags.remove(MessageFlag.read);
+        message2.flags.remove(MessageFlag.read);
+        checkMatchesMessages([message1, message2]);
+      });
+
+      // TODO(server-6) remove mention of zulip/zulip#22164, fixed in 6.0
+      test('tolerates event pointing to DM/stream messages that are already unread (zulip/zulip#22164)', () {
+        final message1 = eg.streamMessage(id: 1, flags: []);
+        final message2 = eg.dmMessage(id: 2, from: eg.otherUser, to: [eg.selfUser], flags: []);
+
+        prepare();
+        fillWithMessages([message1, message2]);
+
+        model.handleUpdateMessageFlagsEvent(mkEvent([message1, message2]));
+        checkNotifiedOnce();
+
+        message1.flags.remove(MessageFlag.read);
+        message2.flags.remove(MessageFlag.read);
+        checkMatchesMessages([message1, message2]);
+      });
+
+      test('tolerates "message details" missing', () {
+        final stream = eg.stream();
+        const topic = 'a';
+        final message1 = eg.streamMessage(id: 1, flags: [MessageFlag.read], stream: stream, topic: topic);
+        final message2 = eg.streamMessage(id: 2, flags: [MessageFlag.read], stream: stream, topic: topic);
+        final message3 = eg.dmMessage(id: 3, flags: [MessageFlag.read], from: eg.otherUser, to: [eg.selfUser]);
+        final message4 = eg.dmMessage(id: 4, flags: [MessageFlag.read], from: eg.otherUser, to: [eg.selfUser]);
+
+        prepare();
+        fillWithMessages([message1, message2, message3, message4]);
+
+        check(() {
+          model.handleUpdateMessageFlagsEvent(UpdateMessageFlagsRemoveEvent(
+            id: 0,
+            flag: MessageFlag.read,
+            messages: [message1.id, message2.id, message3.id, message4.id],
+            messageDetails: {
+              message1.id: UpdateMessageFlagsMessageDetail(
+                type: MessageType.stream,
+                mentioned: false,
+                streamId: stream.streamId,
+                topic: topic,
+                userIds: null,
+              ),
+              // message 2 and 3 have their details missing
+              message4.id: UpdateMessageFlagsMessageDetail(
+                type: MessageType.private,
+                mentioned: false,
+                streamId: null,
+                topic: null,
+                userIds: DmNarrow.ofMessage(message4, selfUserId: eg.selfUser.userId)
+                  .otherRecipientIds,
+              ),
+            }));
+        }).returnsNormally();
+
+        checkNotifiedOnce();
+
+        message1.flags.remove(MessageFlag.read);
+        // messages 2 and 3 not marked unread, but at least we didn't crash
+        message4.flags.remove(MessageFlag.read);
+        checkMatchesMessages([message1, message2, message3, message4]);
+      });
+    });
+  });
+}