From 774dce1c3495961c5280f3ce9147bb4e1cf1322b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 6 Sep 2023 12:54:42 -0700 Subject: [PATCH] WIP model: Add Unreads model, for tracking unread-message counts TODO: - stopwatching --- lib/api/model/initial_snapshot.dart | 2 +- lib/model/narrow.dart | 18 + lib/model/store.dart | 9 +- lib/model/unreads.dart | 399 ++++++++++++ test/model/unreads_checks.dart | 11 + test/model/unreads_test.dart | 908 ++++++++++++++++++++++++++++ 6 files changed, 1345 insertions(+), 2 deletions(-) create mode 100644 lib/model/unreads.dart create mode 100644 test/model/unreads_checks.dart create mode 100644 test/model/unreads_test.dart diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 9096da03fd..8d7f983453 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -234,7 +234,7 @@ class UnreadMessagesSnapshot { final bool oldUnreadsMissing; - UnreadMessagesSnapshot({ + const UnreadMessagesSnapshot({ required this.count, required this.dms, required this.streams, diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index cfb6897a6d..014fa3c216 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -1,4 +1,5 @@ +import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/model/narrow.dart'; @@ -160,6 +161,23 @@ class DmNarrow extends Narrow implements SendableNarrow { ); } + /// 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 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 = >>{}; + final dms = >{}; + 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>> streams; + + /// Unread DM messages, as: DM narrow → message ID. + final Map> 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 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 = >>{}; + final newlyUnreadInDms = >{}; + 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 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 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 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 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 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/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 { + Subject>>> get streams => has((u) => u.streams, 'streams'); + Subject>> get dms => has((u) => u.dms, 'dms'); + Subject> get mentions => has((u) => u.mentions, 'mentions'); + Subject 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..33385c2ead --- /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 messages) { + for (final message in messages) { + model.handleMessageEvent(MessageEvent(id: 0, message: message)); + } + notifiedCount = 0; + } + + void checkMatchesMessages(Iterable messages) { + assert(Set.of(messages.map((m) => m.id)).length == messages.length, + 'checkMatchesMessages: duplicate messages in test input'); + + final Map>> expectedStreams = {}; + final Map> expectedDms = {}; + final Set 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, []], + ['1:1', eg.otherUser, [eg.selfUser]], + ['1:1', eg.thirdUser, [eg.selfUser]], + ['group', eg.otherUser, [eg.selfUser, eg.thirdUser]], + ['group', eg.otherUser, [eg.selfUser, eg.user(userId: 456)]], + ]; + + group('DM narrow subtypes', () { + for (final [desc as String, from as User, to as List] 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 as String, oldFrom as User, oldTo as List] 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 as String, newFrom as User, newTo as List] 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 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]); + }); + }); + }); +}