Skip to content

Commit 8be5e12

Browse files
committed
unreads: Add/use locatorMap, to efficiently locate messages
Fixes #332. Really the same optimization as described for zulip-mobile in zulip/zulip-mobile#4684 , and makes mark-as-read take 0ms (to the nearest ms) down from 3 or 4, in my testing, and so fixes #332.
1 parent 2b0944f commit 8be5e12

File tree

3 files changed

+79
-60
lines changed

3 files changed

+79
-60
lines changed

lib/model/unreads.dart

Lines changed: 74 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
4141
required ChannelStore channelStore,
4242
required UnreadMessagesSnapshot initial,
4343
}) {
44+
final locatorMap = <int, SendableNarrow>{};
4445
final streams = <int, TopicKeyedMap<QueueList<int>>>{};
4546
final dms = <DmNarrow, QueueList<int>>{};
4647
final mentions = Set.of(initial.mentions);
@@ -57,23 +58,34 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
5758
// TODO(server-10) simplify away
5859
(value) => setUnion(value, unreadChannelSnapshot.unreadMessageIds),
5960
ifAbsent: () => QueueList.from(unreadChannelSnapshot.unreadMessageIds));
61+
final narrow = TopicNarrow(streamId, topic);
62+
for (final messageId in unreadChannelSnapshot.unreadMessageIds) {
63+
locatorMap[messageId] = narrow;
64+
}
6065
}
6166

6267
for (final unreadDmSnapshot in initial.dms) {
6368
final otherUserId = unreadDmSnapshot.otherUserId;
6469
final narrow = DmNarrow.withUser(otherUserId, selfUserId: core.selfUserId);
6570
dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds);
71+
for (final messageId in dms[narrow]!) {
72+
locatorMap[messageId] = narrow;
73+
}
6674
}
6775

6876
for (final unreadHuddleSnapshot in initial.huddles) {
6977
final narrow = DmNarrow.ofUnreadHuddleSnapshot(unreadHuddleSnapshot,
7078
selfUserId: core.selfUserId);
7179
dms[narrow] = QueueList.from(unreadHuddleSnapshot.unreadMessageIds);
80+
for (final messageId in dms[narrow]!) {
81+
locatorMap[messageId] = narrow;
82+
}
7283
}
7384

7485
return Unreads._(
7586
core: core,
7687
channelStore: channelStore,
88+
locatorMap: locatorMap,
7789
streams: streams,
7890
dms: dms,
7991
mentions: mentions,
@@ -84,6 +96,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
8496
Unreads._({
8597
required super.core,
8698
required this.channelStore,
99+
required this.locatorMap,
87100
required this.streams,
88101
required this.dms,
89102
required this.mentions,
@@ -92,6 +105,12 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
92105

93106
final ChannelStore channelStore;
94107

108+
/// All unread messages, as: message ID → narrow ([TopicNarrow] or [DmNarrow]).
109+
///
110+
/// Enables efficient [isUnread] and efficient lookups in [streams] and [dms].
111+
@visibleForTesting
112+
final Map<int, SendableNarrow> locatorMap;
113+
95114
// TODO excluded for now; would need to handle nuances around muting etc.
96115
// int count;
97116

@@ -233,11 +252,8 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
233252
/// The unread state for [messageId], or null if unknown.
234253
///
235254
/// May be unknown only if [oldUnreadsMissing].
236-
///
237-
/// This is inefficient; it iterates through [dms] and [channels].
238-
// TODO implement efficiently
239255
bool? isUnread(int messageId) {
240-
final isPresent = _slowIsPresentInDms(messageId) || _slowIsPresentInStreams(messageId);
256+
final isPresent = locatorMap.containsKey(messageId);
241257
if (oldUnreadsMissing && !isPresent) return null;
242258
return isPresent;
243259
}
@@ -250,9 +266,12 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
250266

251267
switch (message) {
252268
case StreamMessage():
269+
final narrow = TopicNarrow.ofMessage(message);
270+
locatorMap[event.message.id] = narrow;
253271
_addLastInStreamTopic(message.id, message.streamId, message.topic);
254272
case DmMessage():
255273
final narrow = DmNarrow.ofMessage(message, selfUserId: selfUserId);
274+
locatorMap[event.message.id] = narrow;
256275
_addLastInDm(message.id, narrow);
257276
}
258277
if (
@@ -346,24 +365,35 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
346365
// Unreads moved to an unsubscribed channel; just drop them.
347366
// See also:
348367
// https://chat.zulip.org/#narrow/channel/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/2101926
368+
for (final messageId in messageToMoveIds) {
369+
locatorMap.remove(messageId);
370+
}
349371
return true;
350372
}
351373

374+
final narrow = TopicNarrow(newStreamId, newTopic);
375+
for (final messageId in messageToMoveIds) {
376+
locatorMap[messageId] = narrow;
377+
}
352378
_addAllInStreamTopic(messageToMoveIds, newStreamId, newTopic);
353379

354380
return true;
355381
}
356382

357383
void handleDeleteMessageEvent(DeleteMessageEvent event) {
358384
mentions.removeAll(event.messageIds);
359-
final messageIdsSet = Set.of(event.messageIds);
360385
switch (event.messageType) {
361386
case MessageType.stream:
387+
// All the messages are in [event.streamId] and [event.topic],
388+
// so we can be more efficient than _removeAllInStreamsAndDms.
362389
final streamId = event.streamId!;
363390
final topic = event.topic!;
364-
_removeAllInStreamTopic(messageIdsSet, streamId, topic);
391+
_removeAllInStreamTopic(Set.of(event.messageIds), streamId, topic);
365392
case MessageType.direct:
366-
_slowRemoveAllInDms(messageIdsSet);
393+
_removeAllInStreamsAndDms(event.messageIds, expectOnlyDms: true);
394+
}
395+
for (final messageId in event.messageIds) {
396+
locatorMap.remove(messageId);
367397
}
368398

369399
// TODO skip notifyListeners if unchanged?
@@ -405,15 +435,18 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
405435
switch (event) {
406436
case UpdateMessageFlagsAddEvent():
407437
if (event.all) {
438+
locatorMap.clear();
408439
streams.clear();
409440
dms.clear();
410441
mentions.clear();
411442
oldUnreadsMissing = false;
412443
} else {
413-
final messageIdsSet = Set.of(event.messages);
414-
mentions.removeAll(messageIdsSet);
415-
_slowRemoveAllInStreams(messageIdsSet);
416-
_slowRemoveAllInDms(messageIdsSet);
444+
final messageIds = event.messages;
445+
mentions.removeAll(messageIds);
446+
_removeAllInStreamsAndDms(messageIds);
447+
for (final messageId in messageIds) {
448+
locatorMap.remove(messageId);
449+
}
417450
}
418451
case UpdateMessageFlagsRemoveEvent():
419452
final newlyUnreadInStreams = <int, TopicKeyedMap<QueueList<int>>>{};
@@ -431,12 +464,15 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
431464
}
432465
switch (detail.type) {
433466
case MessageType.stream:
434-
final topics = (newlyUnreadInStreams[detail.streamId!] ??= makeTopicKeyedMap());
435-
final messageIds = (topics[detail.topic!] ??= QueueList());
467+
final UpdateMessageFlagsMessageDetail(:streamId, :topic) = detail;
468+
locatorMap[messageId] = TopicNarrow(streamId!, topic!);
469+
final topics = (newlyUnreadInStreams[streamId] ??= makeTopicKeyedMap());
470+
final messageIds = (topics[topic] ??= QueueList());
436471
messageIds.add(messageId);
437472
case MessageType.direct:
438473
final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId,
439474
detail);
475+
locatorMap[messageId] = narrow;
440476
(newlyUnreadInDms[narrow] ??= QueueList())
441477
.add(messageId);
442478
}
@@ -489,15 +525,6 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
489525
notifyListeners();
490526
}
491527

492-
// TODO use efficient lookups
493-
bool _slowIsPresentInStreams(int messageId) {
494-
return streams.values.any(
495-
(topics) => topics.values.any(
496-
(messageIds) => messageIds.contains(messageId),
497-
),
498-
);
499-
}
500-
501528
void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) {
502529
((streams[streamId] ??= makeTopicKeyedMap())[topic] ??= QueueList())
503530
.addLast(messageId);
@@ -517,26 +544,32 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
517544
);
518545
}
519546

520-
// TODO use efficient model lookups
521-
void _slowRemoveAllInStreams(Set<int> idsToRemove) {
522-
final newlyEmptyStreams = <int>[];
523-
for (final MapEntry(key: streamId, value: topics) in streams.entries) {
524-
final newlyEmptyTopics = <TopicName>[];
525-
for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
526-
messageIds.removeWhere((id) => idsToRemove.contains(id));
527-
if (messageIds.isEmpty) {
528-
newlyEmptyTopics.add(topic);
529-
}
530-
}
531-
for (final topic in newlyEmptyTopics) {
532-
topics.remove(topic);
533-
}
534-
if (topics.isEmpty) {
535-
newlyEmptyStreams.add(streamId);
536-
}
547+
/// Remove [idsToRemove] from [streams] and [dms].
548+
void _removeAllInStreamsAndDms(Iterable<int> idsToRemove, {bool expectOnlyDms = false}) {
549+
final idsPresentByNarrow = <SendableNarrow, Set<int>>{};
550+
for (final id in idsToRemove) {
551+
final narrow = locatorMap[id];
552+
if (narrow == null) continue;
553+
(idsPresentByNarrow[narrow] ??= {}).add(id);
537554
}
538-
for (final streamId in newlyEmptyStreams) {
539-
streams.remove(streamId);
555+
556+
for (final MapEntry(key: narrow, value: ids) in idsPresentByNarrow.entries) {
557+
switch (narrow) {
558+
case TopicNarrow():
559+
if (expectOnlyDms) {
560+
// TODO(log)?
561+
}
562+
_removeAllInStreamTopic(ids, narrow.streamId, narrow.topic);
563+
case DmNarrow():
564+
final messageIds = dms[narrow];
565+
if (messageIds == null) return;
566+
567+
// ([QueueList] doesn't have a `removeAll`)
568+
messageIds.removeWhere((id) => ids.contains(id));
569+
if (messageIds.isEmpty) {
570+
dms.remove(narrow);
571+
}
572+
}
540573
}
541574
}
542575

@@ -599,11 +632,6 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
599632
return poppedMessageIds;
600633
}
601634

602-
// TODO use efficient model lookups
603-
bool _slowIsPresentInDms(int messageId) {
604-
return dms.values.any((ids) => ids.contains(messageId));
605-
}
606-
607635
void _addLastInDm(int messageId, DmNarrow narrow) {
608636
(dms[narrow] ??= QueueList()).addLast(messageId);
609637
}
@@ -618,18 +646,4 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
618646
(existing) => setUnion(existing, messageIds),
619647
);
620648
}
621-
622-
// TODO use efficient model lookups
623-
void _slowRemoveAllInDms(Set<int> idsToRemove) {
624-
final newlyEmptyDms = <DmNarrow>[];
625-
for (final MapEntry(key: dmNarrow, value: messageIds) in dms.entries) {
626-
messageIds.removeWhere((id) => idsToRemove.contains(id));
627-
if (messageIds.isEmpty) {
628-
newlyEmptyDms.add(dmNarrow);
629-
}
630-
}
631-
for (final dmNarrow in newlyEmptyDms) {
632-
dms.remove(dmNarrow);
633-
}
634-
}
635649
}

test/model/unreads_checks.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:zulip/model/narrow.dart';
55
import 'package:zulip/model/unreads.dart';
66

77
extension UnreadsChecks on Subject<Unreads> {
8+
Subject<Map<int, SendableNarrow>> get locatorMap => has((u) => u.locatorMap, 'locatorMap');
89
Subject<Map<int, Map<TopicName, QueueList<int>>>> get streams => has((u) => u.streams, 'streams');
910
Subject<Map<DmNarrow, QueueList<int>>> get dms => has((u) => u.dms, 'dms');
1011
Subject<Set<int>> get mentions => has((u) => u.mentions, 'mentions');

test/model/unreads_test.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ void main() {
7878
assert(Set.of(messages.map((m) => m.id)).length == messages.length,
7979
'checkMatchesMessages: duplicate messages in test input');
8080

81+
final Map<int, SendableNarrow> expectedLocatorMap = {};
8182
final Map<int, TopicKeyedMap<QueueList<int>>> expectedStreams = {};
8283
final Map<DmNarrow, QueueList<int>> expectedDms = {};
8384
final Set<int> expectedMentions = {};
@@ -87,10 +88,12 @@ void main() {
8788
}
8889
switch (message) {
8990
case StreamMessage():
91+
expectedLocatorMap[message.id] = TopicNarrow.ofMessage(message);
9092
final perTopic = expectedStreams[message.streamId] ??= makeTopicKeyedMap();
9193
final messageIds = perTopic[message.topic] ??= QueueList();
9294
messageIds.add(message.id);
9395
case DmMessage():
96+
expectedLocatorMap[message.id] = DmNarrow.ofMessage(message, selfUserId: store.selfUserId);
9497
final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId);
9598
final messageIds = expectedDms[narrow] ??= QueueList();
9699
messageIds.add(message.id);
@@ -112,6 +115,7 @@ void main() {
112115
}
113116

114117
check(model)
118+
..locatorMap.deepEquals(expectedLocatorMap)
115119
..streams.deepEquals(expectedStreams)
116120
..dms.deepEquals(expectedDms)
117121
..mentions.unorderedEquals(expectedMentions);

0 commit comments

Comments
 (0)