diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 9faa3d367e..92811a736f 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -701,6 +701,70 @@ class MessageEvent extends Event { } } +/// Data structure representing a message move. +class UpdateMessageMoveData { + final int origStreamId; + final int newStreamId; + + final PropagateMode propagateMode; + + final TopicName origTopic; + final TopicName newTopic; + + UpdateMessageMoveData({ + required this.origStreamId, + required this.newStreamId, + required this.propagateMode, + required this.origTopic, + required this.newTopic, + }) : assert(origStreamId != newStreamId || origTopic != newTopic); + + /// Try to extract [UpdateMessageMoveData] from the JSON object for an + /// [UpdateMessageEvent]. + /// + /// Returns `null` if there was no message move. + /// + /// Throws an error if the data is malformed. + // When parsing this, 'stream_id', which is also present when there was only + // a content edit, cannot be recovered if this ends up returning `null`. + // This may matter if we ever need 'stream_id' when no message move occurred. + static UpdateMessageMoveData? tryParseFromJson(Map json) { + final origStreamId = (json['stream_id'] as num?)?.toInt(); + final newStreamId = (json['new_stream_id'] as num?)?.toInt() ?? origStreamId; + final propagateModeString = json['propagate_mode'] as String?; + final propagateMode = propagateModeString == null ? null + : PropagateMode.fromRawString(propagateModeString); + final origTopic = json['orig_subject'] == null ? null + : TopicName.fromJson(json['orig_subject'] as String); + final newTopic = json['subject'] == null ? origTopic + : TopicName.fromJson(json['subject'] as String); + + final newChannelOrTopic = origStreamId != newStreamId || origTopic != newTopic; + switch ((propagateMode != null, newChannelOrTopic)) { + case (true, false): + throw FormatException( + 'UpdateMessageEvent: incoherent message-move fields; ' + 'propagate_mode present but no new channel or topic'); + case (false, true): + throw FormatException( + 'UpdateMessageEvent: incoherent message-move fields; ' + 'propagate_mode absent but new channel or topic'); + case (false, false): + return null; // No move. + case (true, true): + return UpdateMessageMoveData( + origStreamId: origStreamId!, + newStreamId: newStreamId!, + propagateMode: propagateMode!, + origTopic: origTopic!, + newTopic: newTopic!, + ); + } + } + + Object? toJson() => null; +} + /// A Zulip event of type `update_message`: https://zulip.com/api/get-events#update_message @JsonSerializable(fieldRename: FieldRename.snake) class UpdateMessageEvent extends Event { @@ -718,16 +782,8 @@ class UpdateMessageEvent extends Event { // final String? streamName; // ignore - @JsonKey(name: 'stream_id') - final int? origStreamId; - final int? newStreamId; - - final PropagateMode? propagateMode; - - @JsonKey(name: 'orig_subject') - final TopicName? origTopic; - @JsonKey(name: 'subject') - final TopicName? newTopic; + @JsonKey(readValue: _readMoveData, fromJson: UpdateMessageMoveData.tryParseFromJson) + final UpdateMessageMoveData? moveData; // final List topicLinks; // TODO handle @@ -747,11 +803,7 @@ class UpdateMessageEvent extends Event { required this.messageIds, required this.flags, required this.editTimestamp, - required this.origStreamId, - required this.newStreamId, - required this.propagateMode, - required this.origTopic, - required this.newTopic, + required this.moveData, required this.origContent, required this.origRenderedContent, required this.content, @@ -759,6 +811,11 @@ class UpdateMessageEvent extends Event { required this.isMeMessage, }); + static Object? _readMoveData(Map json, String key) { + // Parsing [UpdateMessageMoveData] requires `json`, not the default `json[key]`. + return json; + } + factory UpdateMessageEvent.fromJson(Map json) => _$UpdateMessageEventFromJson(json); diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 5d47444ecd..7bf4cefbb6 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -426,16 +426,9 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) .toList(), editTimestamp: (json['edit_timestamp'] as num?)?.toInt(), - origStreamId: (json['stream_id'] as num?)?.toInt(), - newStreamId: (json['new_stream_id'] as num?)?.toInt(), - propagateMode: - $enumDecodeNullable(_$PropagateModeEnumMap, json['propagate_mode']), - origTopic: json['orig_subject'] == null - ? null - : TopicName.fromJson(json['orig_subject'] as String), - newTopic: json['subject'] == null - ? null - : TopicName.fromJson(json['subject'] as String), + moveData: UpdateMessageMoveData.tryParseFromJson( + UpdateMessageEvent._readMoveData(json, 'move_data') + as Map), origContent: json['orig_content'] as String?, origRenderedContent: json['orig_rendered_content'] as String?, content: json['content'] as String?, @@ -453,11 +446,7 @@ Map _$UpdateMessageEventToJson(UpdateMessageEvent instance) => 'message_ids': instance.messageIds, 'flags': instance.flags, 'edit_timestamp': instance.editTimestamp, - 'stream_id': instance.origStreamId, - 'new_stream_id': instance.newStreamId, - 'propagate_mode': instance.propagateMode, - 'orig_subject': instance.origTopic, - 'subject': instance.newTopic, + 'move_data': instance.moveData, 'orig_content': instance.origContent, 'orig_rendered_content': instance.origRenderedContent, 'content': instance.content, @@ -476,12 +465,6 @@ const _$MessageFlagEnumMap = { MessageFlag.unknown: 'unknown', }; -const _$PropagateModeEnumMap = { - PropagateMode.changeOne: 'change_one', - PropagateMode.changeLater: 'change_later', - PropagateMode.changeAll: 'change_all', -}; - DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => DeleteMessageEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index fad8ddc5bc..5c94626584 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -954,4 +954,15 @@ enum PropagateMode { changeAll; String toJson() => _$PropagateModeEnumMap[this]!; + + /// Get a [PropagateMode] from a raw string. Throws if the string is + /// unrecognized. + /// + /// Example: + /// 'change_one' -> PropagateMode.changeOne + static PropagateMode fromRawString(String raw) => _byRawString[raw]!; + + // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` + static final _byRawString = _$PropagateModeEnumMap + .map((key, value) => MapEntry(value, key)); } diff --git a/lib/model/message.dart b/lib/model/message.dart index 84f3bbc3e5..c632bb83c7 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -169,49 +169,12 @@ class MessageStoreImpl with MessageStore { } void _handleUpdateMessageEventMove(UpdateMessageEvent event) { - // The interaction between the fields of these events are a bit tricky. - // For reference, see: https://zulip.com/api/get-events#update_message - - final origStreamId = event.origStreamId; - final newStreamId = event.newStreamId; // null if topic-only move - final origTopic = event.origTopic; - final newTopic = event.newTopic; - final propagateMode = event.propagateMode; - - if (origTopic == null) { - // There was no move. - assert(() { - if (newStreamId != null && origStreamId != null - && newStreamId != origStreamId) { - // This should be impossible; `orig_subject` (aka origTopic) is - // documented to be present when either the stream or topic changed. - debugLog('Malformed UpdateMessageEvent: stream move but no origTopic'); // TODO(log) - } - return true; - }()); + final messageMove = event.moveData; + if (messageMove == null) { + // There is no message move. return; } - if (newStreamId == null && newTopic == null) { - // If neither the channel nor topic name changed, nothing moved. - // In that case `orig_subject` (aka origTopic) should have been null. - assert(debugLog('Malformed UpdateMessageEvent: move but no newStreamId or newTopic')); // TODO(log) - return; - } - if (origStreamId == null) { - // The `stream_id` field (aka origStreamId) is documented to be present on moves. - assert(debugLog('Malformed UpdateMessageEvent: move but no origStreamId')); // TODO(log) - return; - } - if (propagateMode == null) { - // The `propagate_mode` field (aka propagateMode) is documented to be present on moves. - assert(debugLog('Malformed UpdateMessageEvent: move but no propagateMode')); // TODO(log) - return; - } - - final wasResolveOrUnresolve = (newStreamId == null - && MessageEditState.topicMoveWasResolveOrUnresolve(origTopic, newTopic!)); - for (final messageId in event.messageIds) { final message = messages[messageId]; if (message == null) continue; @@ -221,17 +184,21 @@ class MessageStoreImpl with MessageStore { continue; } - if (newStreamId != null) { - message.streamId = newStreamId; + if (messageMove.origStreamId != messageMove.newStreamId) { + message.streamId = messageMove.newStreamId; // See [StreamMessage.displayRecipient] on why the invalidation is // needed. message.displayRecipient = null; } - if (newTopic != null) { - message.topic = newTopic; + if (messageMove.origTopic != messageMove.newTopic) { + message.topic = messageMove.newTopic; } + final wasResolveOrUnresolve = + messageMove.origStreamId == messageMove.newStreamId + && MessageEditState.topicMoveWasResolveOrUnresolve( + messageMove.origTopic, messageMove.newTopic); if (!wasResolveOrUnresolve && message.editState == MessageEditState.none) { message.editState = MessageEditState.moved; @@ -240,12 +207,12 @@ class MessageStoreImpl with MessageStore { for (final view in _messageListViews) { view.messagesMoved( - origStreamId: origStreamId, - newStreamId: newStreamId ?? origStreamId, - origTopic: origTopic, - newTopic: newTopic ?? origTopic, + origStreamId: messageMove.origStreamId, + newStreamId: messageMove.newStreamId, + origTopic: messageMove.origTopic, + newTopic: messageMove.newTopic, messageIds: event.messageIds, - propagateMode: propagateMode, + propagateMode: messageMove.propagateMode, ); } } diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 1fcea3f83c..caca3e6f54 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -259,10 +259,8 @@ class Unreads extends ChangeNotifier { (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 + // We expect the event's 'read' flag to be boring, + // matching the message's local unread state. final bool isRead = event.flags.contains(MessageFlag.read); assert(() { final isUnreadLocally = isUnread(messageId); @@ -272,6 +270,17 @@ class Unreads extends ChangeNotifier { // We were going to check something but can't; shrug. if (isUnreadLocally == null) return true; + final newChannelId = event.moveData?.newStreamId; + if (newChannelId != null && !channelStore.subscriptions.containsKey(newChannelId)) { + // When unread messages are moved to an unsubscribed channel, the server + // marks them as read without sending a mark-as-read event. Clients are + // asked to special-case this by marking them as read, which we do in + // _handleMessageMove. That contract is clear enough and doesn't involve + // this event's 'read' flag, so don't bother logging about the flag; + // its behavior seems like an implementation detail that could change. + return true; + } + if (isUnreadLocally != isUnreadInEvent) { // If this happens, then either: // - the server and client have been out of sync about the message's @@ -296,13 +305,39 @@ class Unreads extends ChangeNotifier { madeAnyUpdate |= mentions.add(messageId); } - // TODO(#901) handle moved messages + madeAnyUpdate |= _handleMessageMove(event); if (madeAnyUpdate) { notifyListeners(); } } + bool _handleMessageMove(UpdateMessageEvent event) { + if (event.moveData == null) { + // No moved messages. + return false; + } + final UpdateMessageMoveData( + :origStreamId, :newStreamId, :origTopic, :newTopic) = event.moveData!; + + final messageToMoveIds = _removeAllInStreamTopic( + event.messageIds.toSet(), origStreamId, origTopic); + if (messageToMoveIds == null || messageToMoveIds.isEmpty) { + // No known unreads affected by move; nothing to do. + return false; + } + + if (!channelStore.subscriptions.containsKey(newStreamId)) { + // Unreads moved to an unsubscribed channel; just drop them. + // See also: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2101926 + return true; + } + + _addAllInStreamTopic(messageToMoveIds..sort(), newStreamId, newTopic); + return true; + } + void handleDeleteMessageEvent(DeleteMessageEvent event) { mentions.removeAll(event.messageIds); final messageIdsSet = Set.of(event.messageIds); @@ -455,6 +490,8 @@ class Unreads extends ChangeNotifier { // [messageIds] must be sorted ascending and without duplicates. void _addAllInStreamTopic(QueueList messageIds, int streamId, TopicName topic) { + assert(messageIds.isNotEmpty); + assert(isSortedWithoutDuplicates(messageIds)); final topics = streams[streamId] ??= {}; topics.update(topic, ifAbsent: () => messageIds, @@ -488,20 +525,28 @@ class Unreads extends ChangeNotifier { } } - void _removeAllInStreamTopic(Set incomingMessageIds, int streamId, TopicName topic) { + QueueList? _removeAllInStreamTopic(Set incomingMessageIds, int streamId, TopicName topic) { final topics = streams[streamId]; - if (topics == null) return; + if (topics == null) return null; final messageIds = topics[topic]; - if (messageIds == null) return; + if (messageIds == null) return null; // ([QueueList] doesn't have a `removeAll`) - messageIds.removeWhere((id) => incomingMessageIds.contains(id)); + final removedMessageIds = QueueList(); + messageIds.removeWhere((id) { + if (incomingMessageIds.contains(id)) { + removedMessageIds.add(id); + return true; + } + return false; + }); if (messageIds.isEmpty) { topics.remove(topic); if (topics.isEmpty) { streams.remove(streamId); } } + return removedMessageIds; } // TODO use efficient model lookups diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index c1fa0a117f..b9cf10ac1a 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -48,11 +48,7 @@ extension UpdateMessageEventChecks on Subject { Subject> get messageIds => has((e) => e.messageIds, 'messageIds'); Subject> get flags => has((e) => e.flags, 'flags'); Subject get editTimestamp => has((e) => e.editTimestamp, 'editTimestamp'); - Subject get origStreamId => has((e) => e.origStreamId, 'origStreamId'); - Subject get newStreamId => has((e) => e.newStreamId, 'newStreamId'); - Subject get propagateMode => has((e) => e.propagateMode, 'propagateMode'); - Subject get origTopic => has((e) => e.origTopic, 'origTopic'); - Subject get newTopic => has((e) => e.newTopic, 'newTopic'); + Subject get moveData => has((e) => e.moveData, 'moveData'); Subject get origContent => has((e) => e.origContent, 'origContent'); Subject get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent'); Subject get content => has((e) => e.content, 'content'); @@ -60,6 +56,15 @@ extension UpdateMessageEventChecks on Subject { Subject get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage'); } + +extension UpdateMessageMoveDataChecks on Subject { + Subject get origStreamId => has((e) => e.origStreamId, 'origStreamId'); + Subject get newStreamId => has((e) => e.newStreamId, 'newStreamId'); + Subject get propagateMode => has((e) => e.propagateMode, 'propagateMode'); + Subject get origTopic => has((e) => e.origTopic, 'origTopic'); + Subject get newTopic => has((e) => e.newTopic, 'newTopic'); +} + extension DeleteMessageEventChecks on Subject { Subject get messageType => has((e) => e.messageType, 'messageType'); } diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 51b36350cc..92a2296c2e 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -99,26 +99,120 @@ void main() { 'message_ids': [message.id], 'flags': [], 'edit_timestamp': 1718741351, - 'stream_id': eg.stream().streamId, }; + test('smoke moveData', () { + check(Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() + ..origStreamId.equals(1) + ..newStreamId.equals(2) + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('bar')) + ..propagateMode.equals(PropagateMode.changeAll); + }); + test('stream_id -> origStreamId', () { check(Event.fromJson({ ...baseJson, 'stream_id': 1, 'new_stream_id': 2, - }) as UpdateMessageEvent) + 'orig_subject': 'foo', + 'subject': null, + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() ..origStreamId.equals(1) ..newStreamId.equals(2); }); test('orig_subject -> origTopic, subject -> newTopic', () { check(Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': null, 'orig_subject': 'foo', 'subject': 'bar', - }) as UpdateMessageEvent) + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() ..origTopic.equals(const TopicName('foo')) ..newTopic.equals(const TopicName('bar')); }); + + test('orig_subject -> newTopic if no subject', () { + check(Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': null, + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('foo')); + }); + + test('stream_id -> newStreamId if no new_stream_id', () { + check(Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': null, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': 'change_all', + })).isA().moveData.isNotNull() + ..origStreamId.equals(1) + ..newStreamId.equals(1); + }); + + test('no message move', () { + check(Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'orig_content': 'foo', + 'orig_rendered_content': 'foo', + 'content': 'bar', + 'rendered_content': 'bar', + })).isA().moveData.isNull(); + }); + + test('stream move but no orig_subject', () { + check(() => Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': null, + 'subject': null, + 'propagate_mode': 'change_all', + })).throws(); + }); + + test('move but no subject or new_stream_id', () { + check(() => Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': null, + 'orig_subject': 'foo', + 'subject': null, + 'propagate_mode': 'change_all', + })).throws(); + }); + + test('move but no orig_stream_id', () { + check(() => Event.fromJson({ ...baseJson, + 'stream_id': null, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': 'change_all', + })).throws(); + }); + + test('move but no propagate_mode', () { + check(() => Event.fromJson({ ...baseJson, + 'stream_id': 1, + 'new_stream_id': 2, + 'orig_subject': 'foo', + 'subject': 'bar', + 'propagate_mode': null, + })).throws(); + }); }); test('delete_message: require streamId and topic for stream messages', () { diff --git a/test/example_data.dart b/test/example_data.dart index 04d6723b7a..f85ca86749 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -653,11 +653,7 @@ UpdateMessageEvent updateMessageEditEvent( messageIds: [messageId], flags: flags ?? origMessage.flags, editTimestamp: editTimestamp ?? 1234567890, // TODO generate timestamp - origStreamId: origMessage is StreamMessage ? origMessage.streamId : null, - newStreamId: null, - propagateMode: null, - origTopic: null, - newTopic: null, + moveData: null, origContent: 'some probably-mismatched old Markdown', origRenderedContent: origMessage.content, content: 'some probably-mismatched new Markdown', @@ -679,8 +675,6 @@ UpdateMessageEvent _updateMessageMoveEvent( }) { _checkPositive(origStreamId, 'stream ID'); _checkPositive(newStreamId, 'stream ID'); - assert(newTopic != origTopic - || (newStreamId != null && newStreamId != origStreamId)); assert(messageIds.isNotEmpty); return UpdateMessageEvent( id: 0, @@ -690,11 +684,13 @@ UpdateMessageEvent _updateMessageMoveEvent( messageIds: messageIds, flags: flags, editTimestamp: 1234567890, // TODO generate timestamp - origStreamId: origStreamId, - newStreamId: newStreamId, - propagateMode: propagateMode, - origTopic: origTopic, - newTopic: newTopic, + moveData: UpdateMessageMoveData( + origStreamId: origStreamId, + newStreamId: newStreamId ?? origStreamId, + propagateMode: propagateMode, + origTopic: origTopic, + newTopic: newTopic ?? origTopic, + ), origContent: origContent, origRenderedContent: origContent, content: newContent, diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index da286f3ef5..92c08ddd6b 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -825,11 +825,14 @@ void main() { }); test('unrelated channel -> new channel: unaffected', () async { + final thirdStream = eg.stream(); await prepareNarrow(narrow, initialMessages); + await store.addStream(thirdStream); + await store.addSubscription(eg.subscription(thirdStream)); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: otherChannelMovedMessages, - newStreamId: otherStream.streamId, + newStreamId: thirdStream.streamId, )); checkHasMessages(initialMessages); checkNotNotified(); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index 40e074dcaa..c02005230a 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -469,6 +469,212 @@ void main() { } } }); + + group('moves', () { + final origChannel = eg.stream(); + const origTopic = 'origTopic'; + const newTopic = 'newTopic'; + + Future prepareStore() async { + prepare(); + await channelStore.addStream(origChannel); + await channelStore.addSubscription(eg.subscription(origChannel)); + } + + group('move read messages', () { + final readMessages = List.generate(10, + (_) => eg.streamMessage( + stream: origChannel, topic: origTopic, flags: [MessageFlag.read])); + + test('to new channel', () async { + await prepareStore(); + final newChannel = eg.stream(); + await channelStore.addStream(newChannel); + await channelStore.addSubscription(eg.subscription(newChannel)); + fillWithMessages(readMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: readMessages, + newStreamId: newChannel.streamId)); + checkNotNotified(); + checkMatchesMessages([]); + }); + + test('to new topic', () async { + await prepareStore(); + fillWithMessages(readMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: readMessages, + newTopicStr: newTopic)); + checkNotNotified(); + checkMatchesMessages([]); + }); + + test('from topic with unreads', () async { + await prepareStore(); + final unreadMessage = eg.streamMessage( + stream: origChannel, topic: origTopic); + fillWithMessages([...readMessages, unreadMessage]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: readMessages, + newTopicStr: newTopic)); + checkNotNotified(); + checkMatchesMessages([unreadMessage]); + }); + + test('to topic with unreads', () async { + await prepareStore(); + final unreadMessage = eg.streamMessage( + stream: origChannel, topic: newTopic); + fillWithMessages([...readMessages, unreadMessage]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: readMessages, + newTopicStr: newTopic, + )); + checkNotNotified(); + checkMatchesMessages([unreadMessage]); + }); + }); + + group('move unread messages', () { + final unreadMessages = List.generate(10, + (_) => eg.streamMessage(stream: origChannel, topic: origTopic)); + + test('to another subscribed channel; same topic name', () async { + await prepareStore(); + final newChannel = eg.stream(); + await channelStore.addStream(newChannel); + await channelStore.addSubscription(eg.subscription(newChannel)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newStreamId: newChannel.streamId)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson( + message.toJson()..['stream_id'] = newChannel.streamId), + ]); + }); + + test('to another subscribed channel; different topic name', () async { + await prepareStore(); + final newChannel = eg.stream(); + await channelStore.addStream(newChannel); + await channelStore.addSubscription(eg.subscription(newChannel)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newStreamId: newChannel.streamId, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson( + message.toJson() + ..['stream_id'] = newChannel.streamId + ..['subject'] = newTopic + ), + ]); + }); + + test('to unsubscribed channel', () async { + await prepareStore(); + final newChannel = eg.stream(); + await channelStore.addStream(newChannel); + assert(!channelStore.subscriptions.containsKey(newChannel.streamId)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newStreamId: newChannel.streamId)); + checkNotifiedOnce(); + checkMatchesMessages([]); + }); + + test('to new topic', () async { + await prepareStore(); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson(message.toJson()..['subject'] = newTopic), + ]); + }); + + test('from topic containing other unreads', () async { + await prepareStore(); + final unreadMessage = eg.streamMessage( + stream: origChannel, topic: origTopic); + fillWithMessages([...unreadMessages, unreadMessage]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson(message.toJson()..['subject'] = newTopic), + unreadMessage, + ]); + }); + + test('to topic containing other unreads', () async { + await prepareStore(); + final unreadMessage = eg.streamMessage( + stream: origChannel, topic: newTopic); + fillWithMessages([...unreadMessages, unreadMessage]); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson(message.toJson()..['subject'] = newTopic), + unreadMessage, + ]); + }); + + test('tolerates unsorted messages', () async { + await prepareStore(); + final unreadMessages = List.generate(10, + (i) => eg.streamMessage(id: 1000-i, stream: origChannel, topic: origTopic)); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: unreadMessages, + newTopicStr: newTopic)); + checkNotifiedOnce(); + checkMatchesMessages([ + for (final message in unreadMessages) + Message.fromJson(message.toJson()..['subject'] = newTopic) + ]); + }); + + test('tolerates unreads unknown to the model', () async { + await prepareStore(); + final unknownUnreadMessage = eg.streamMessage( + stream: eg.stream(), topic: origTopic); + fillWithMessages(unreadMessages); + + model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom( + origMessages: [unknownUnreadMessage], + newTopicStr: newTopic)); + checkNotNotified(); + checkMatchesMessages(unreadMessages); + }); + }); + }); });