Skip to content

Commit 709714b

Browse files
committed
unreads: Handle updates when there are moved messages
We could have extend `_removeAllInStreamTopic` to fit the needs of handling moved unreads, but it is used in a code path where messages are marked as read (which will become hotter after #81). The new helper is designed to avoid making copies of message IDs when possible. As for name, we use "pop" (inspired by Python) to indicate that the removed items are returned, since "removeAll" in Dart doesn't carry that meaning. Signed-off-by: Zixuan James Li <[email protected]>
1 parent d57cf21 commit 709714b

File tree

2 files changed

+227
-5
lines changed

2 files changed

+227
-5
lines changed

lib/model/unreads.dart

+88-5
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,8 @@ class Unreads extends ChangeNotifier {
259259
(f) => f == MessageFlag.mentioned || f == MessageFlag.wildcardMentioned,
260260
);
261261

262-
// We assume this event can't signal a change in a message's 'read' flag.
263-
// TODO can it actually though, when it's about messages being moved into an
264-
// unsubscribed stream?
265-
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957
262+
// We expect the event's 'read' flag to be boring,
263+
// matching the message's local unread state.
266264
final bool isRead = event.flags.contains(MessageFlag.read);
267265
assert(() {
268266
final isUnreadLocally = isUnread(messageId);
@@ -272,6 +270,17 @@ class Unreads extends ChangeNotifier {
272270
// We were going to check something but can't; shrug.
273271
if (isUnreadLocally == null) return true;
274272

273+
final newChannelId = event.moveData?.newStreamId;
274+
if (newChannelId != null && !channelStore.subscriptions.containsKey(newChannelId)) {
275+
// When unread messages are moved to an unsubscribed channel, the server
276+
// marks them as read without sending a mark-as-read event. Clients are
277+
// asked to special-case this by marking them as read, which we do in
278+
// _handleMessageMove. That contract is clear enough and doesn't involve
279+
// this event's 'read' flag, so don't bother logging about the flag;
280+
// its behavior seems like an implementation detail that could change.
281+
return true;
282+
}
283+
275284
if (isUnreadLocally != isUnreadInEvent) {
276285
// If this happens, then either:
277286
// - the server and client have been out of sync about the message's
@@ -296,13 +305,40 @@ class Unreads extends ChangeNotifier {
296305
madeAnyUpdate |= mentions.add(messageId);
297306
}
298307

299-
// TODO(#901) handle moved messages
308+
madeAnyUpdate |= _handleMessageMove(event);
300309

301310
if (madeAnyUpdate) {
302311
notifyListeners();
303312
}
304313
}
305314

315+
bool _handleMessageMove(UpdateMessageEvent event) {
316+
if (event.moveData == null) {
317+
// No moved messages.
318+
return false;
319+
}
320+
final UpdateMessageMoveData(
321+
:origStreamId, :newStreamId, :propagateMode, :origTopic, :newTopic,
322+
) = event.moveData!;
323+
324+
final messageToMoveIds = _popAllInStreamTopic(
325+
event.messageIds.toSet(), origStreamId, origTopic)?..sort();
326+
327+
if (messageToMoveIds == null || messageToMoveIds.isEmpty) return false;
328+
assert(event.messageIds.toSet().containsAll(messageToMoveIds));
329+
330+
if (!channelStore.subscriptions.containsKey(newStreamId)) {
331+
// Unreads moved to an unsubscribed channel; just drop them.
332+
// See also:
333+
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2101926
334+
return true;
335+
}
336+
337+
_addAllInStreamTopic(messageToMoveIds, newStreamId, newTopic);
338+
339+
return true;
340+
}
341+
306342
void handleDeleteMessageEvent(DeleteMessageEvent event) {
307343
mentions.removeAll(event.messageIds);
308344
final messageIdsSet = Set.of(event.messageIds);
@@ -506,6 +542,53 @@ class Unreads extends ChangeNotifier {
506542
}
507543
}
508544

545+
/// Remove unread stream messages contained in `incomingMessageIds`, with
546+
/// the matching `streamId` and `topic`.
547+
///
548+
/// Returns the removed message IDs, or `null` if no messages are affected.
549+
///
550+
/// Use [_removeAllInStreamTopic] if the removed message IDs are not needed.
551+
// Part of this is adapted from [ListBase.removeWhere].
552+
QueueList<int>? _popAllInStreamTopic(Set<int> incomingMessageIds, int streamId, TopicName topic) {
553+
final topics = streams[streamId];
554+
if (topics == null) return null;
555+
final messageIds = topics[topic];
556+
if (messageIds == null) return null;
557+
558+
final retainedMessageIds = <int>[];
559+
for (final id in messageIds) {
560+
if (!incomingMessageIds.contains(id)) {
561+
retainedMessageIds.add(id);
562+
}
563+
}
564+
565+
if (retainedMessageIds.isEmpty) {
566+
// This is an optimization for the case when all messages in the
567+
// conversation are removed, which avoids making a copy of `messageId`
568+
// unnecessarily.
569+
topics.remove(topic);
570+
if (topics.isEmpty) {
571+
streams.remove(streamId);
572+
}
573+
return messageIds;
574+
}
575+
576+
QueueList<int>? poppedMessageIds;
577+
if (retainedMessageIds.length != messageIds.length) {
578+
poppedMessageIds = QueueList.from(
579+
messageIds.where((id) => incomingMessageIds.contains(id)));
580+
messageIds.setRange(0, retainedMessageIds.length, retainedMessageIds);
581+
messageIds.length = retainedMessageIds.length;
582+
}
583+
if (messageIds.isEmpty) {
584+
topics.remove(topic);
585+
if (topics.isEmpty) {
586+
streams.remove(streamId);
587+
}
588+
}
589+
return poppedMessageIds;
590+
}
591+
509592
// TODO use efficient model lookups
510593
bool _slowIsPresentInDms(int messageId) {
511594
return dms.values.any((ids) => ids.contains(messageId));

test/model/unreads_test.dart

+139
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,145 @@ void main() {
469469
}
470470
}
471471
});
472+
473+
group('moves', () {
474+
final origChannel = eg.stream();
475+
const origTopic = 'origTopic';
476+
const newTopic = 'newTopic';
477+
478+
final readMessages = List<StreamMessage>.generate(10,
479+
(_) => eg.streamMessage(
480+
stream: origChannel, topic: origTopic, flags: [MessageFlag.read]));
481+
final unreadMessages = List<StreamMessage>.generate(10,
482+
(_) => eg.streamMessage(stream: origChannel, topic: origTopic));
483+
484+
Future<void> prepareStore() async {
485+
prepare();
486+
await channelStore.addStream(origChannel);
487+
await channelStore.addSubscription(eg.subscription(origChannel));
488+
}
489+
490+
List<StreamMessage> copyMessagesWith(Iterable<StreamMessage> messages, {
491+
ZulipStream? newChannel,
492+
String? newTopic,
493+
}) {
494+
assert(newChannel != null || newTopic != null);
495+
return messages.map((message) => StreamMessage.fromJson(
496+
message.toJson()
497+
..['stream_id'] = newChannel?.streamId ?? message.streamId
498+
..['subject'] = newTopic ?? message.topic
499+
)).toList();
500+
}
501+
502+
test('smoke', () async {
503+
await prepareStore();
504+
final newChannel = eg.stream();
505+
await channelStore.addStream(newChannel);
506+
await channelStore.addSubscription(eg.subscription(newChannel));
507+
fillWithMessages(unreadMessages);
508+
509+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
510+
origMessages: unreadMessages,
511+
newStreamId: newChannel.streamId,
512+
newTopicStr: newTopic));
513+
checkNotifiedOnce();
514+
checkMatchesMessages(copyMessagesWith(unreadMessages,
515+
newChannel: newChannel, newTopic: newTopic));
516+
});
517+
518+
test('moving some read and unread messages from a conversation', () async {
519+
await prepareStore();
520+
final messagesToMove = [unreadMessages.first, readMessages.first];
521+
fillWithMessages(unreadMessages + readMessages);
522+
523+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
524+
origMessages: messagesToMove,
525+
newTopicStr: newTopic));
526+
checkNotifiedOnce();
527+
checkMatchesMessages([
528+
...copyMessagesWith(unreadMessages.take(1), newTopic: newTopic),
529+
...unreadMessages.skip(1),
530+
]);
531+
});
532+
533+
test('moving all read and unread messages from a conversation', () async {
534+
await prepareStore();
535+
final allMessages = unreadMessages + readMessages;
536+
fillWithMessages(allMessages);
537+
538+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
539+
origMessages: allMessages,
540+
newTopicStr: newTopic));
541+
checkNotifiedOnce();
542+
checkMatchesMessages(copyMessagesWith(unreadMessages, newTopic: newTopic));
543+
});
544+
545+
test('moving to unsubscribed channels drops the unreads', () async {
546+
await prepareStore();
547+
final unsubscribedChannel = eg.stream();
548+
await channelStore.addStream(unsubscribedChannel);
549+
assert(!channelStore.subscriptions.containsKey(
550+
unsubscribedChannel.streamId));
551+
fillWithMessages(unreadMessages);
552+
553+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
554+
origMessages: unreadMessages,
555+
newStreamId: unsubscribedChannel.streamId));
556+
checkNotifiedOnce();
557+
checkMatchesMessages([]);
558+
});
559+
560+
test('tolerates unsorted messages', () async {
561+
await prepareStore();
562+
final unreadMessages = List.generate(10, (i) =>
563+
eg.streamMessage(
564+
id: 1000 - i, stream: origChannel, topic: origTopic));
565+
fillWithMessages(unreadMessages);
566+
567+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
568+
origMessages: unreadMessages,
569+
newTopicStr: newTopic));
570+
checkNotifiedOnce();
571+
checkMatchesMessages(copyMessagesWith(unreadMessages, newTopic: newTopic));
572+
});
573+
574+
test('tolerates unreads unknown to the model', () async {
575+
await prepareStore();
576+
fillWithMessages(unreadMessages);
577+
578+
final unknownChannel = eg.stream();
579+
assert(!channelStore.streams.containsKey(unknownChannel.streamId));
580+
final unknownUnreadMessage = eg.streamMessage(
581+
stream: unknownChannel, topic: origTopic);
582+
583+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
584+
origMessages: [unknownUnreadMessage],
585+
newTopicStr: newTopic));
586+
checkNotNotified();
587+
checkMatchesMessages(unreadMessages);
588+
});
589+
590+
test('moving read messages has no effect', () async {
591+
await prepareStore();
592+
fillWithMessages(unreadMessages + readMessages);
593+
594+
model.handleUpdateMessageEvent(eg.updateMessageEventMoveFrom(
595+
origMessages: readMessages,
596+
newTopicStr: newTopic));
597+
checkNotNotified();
598+
checkMatchesMessages(unreadMessages);
599+
});
600+
601+
test('message edit but no move', () async {
602+
await prepareStore();
603+
fillWithMessages(unreadMessages);
604+
605+
model.handleUpdateMessageEvent(eg.updateMessageEditEvent(
606+
unreadMessages.first));
607+
checkNotNotified();
608+
checkMatchesMessages(unreadMessages);
609+
});
610+
});
472611
});
473612

474613

0 commit comments

Comments
 (0)