forked from zulip/zulip-flutter
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathunreads.dart
520 lines (470 loc) · 19.8 KB
/
unreads.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
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';
import 'channel.dart';
/// The view-model for unread messages.
///
/// Implemented to track actual unread state as faithfully as possible
/// given incomplete information in [UnreadMessagesSnapshot].
///
/// In [streams], [dms], and [mentions], if a message is not represented,
/// its status is either read or unknown. A message's status will be unknown if
/// it was very old at /register time; see [oldUnreadsMissing].
/// In [mentions], there's another more complex reason
/// the state might be unknown; see there.
///
/// 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 int selfUserId,
required ChannelStore channelStore,
}) {
final streams = <int, Map<String, QueueList<int>>>{};
final dms = <DmNarrow, QueueList<int>>{};
final mentions = Set.of(initial.mentions);
for (final unreadStreamSnapshot in initial.streams) {
final streamId = unreadStreamSnapshot.streamId;
final topic = unreadStreamSnapshot.topic;
(streams[streamId] ??= {})[topic] = QueueList.from(unreadStreamSnapshot.unreadMessageIds);
}
for (final unreadDmSnapshot in initial.dms) {
final otherUserId = unreadDmSnapshot.otherUserId;
final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId);
dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds);
}
for (final unreadHuddleSnapshot in initial.huddles) {
final narrow = DmNarrow.ofUnreadHuddleSnapshot(unreadHuddleSnapshot, selfUserId: selfUserId);
dms[narrow] = QueueList.from(unreadHuddleSnapshot.unreadMessageIds);
}
return Unreads._(
channelStore: channelStore,
streams: streams,
dms: dms,
mentions: mentions,
oldUnreadsMissing: initial.oldUnreadsMissing,
selfUserId: selfUserId,
);
}
Unreads._({
required this.channelStore,
required this.streams,
required this.dms,
required this.mentions,
required this.oldUnreadsMissing,
required this.selfUserId,
});
final ChannelStore channelStore;
// TODO excluded for now; would need to handle nuances around muting etc.
// int count;
/// Unread stream messages, as: stream ID → topic → message IDs (sorted).
final Map<int, Map<String, QueueList<int>>> streams;
/// Unread DM messages, as: DM narrow → message IDs (sorted).
final Map<DmNarrow, QueueList<int>> dms;
/// Unread messages with the self-user @-mentioned, directly or by wildcard.
///
/// At initialization, if a message is:
/// 1) muted because of the user's stream- and topic-level choices [1], and
/// 2) wildcard mentioned but not directly mentioned
/// then it will be absent, and its unread state will be unknown to [mentions]
/// because the message is absent in [UnreadMessagesSnapshot]:
/// https://chat.zulip.org/#narrow/stream/378-api-design/topic/register.3A.20maintaining.20.60unread_msgs.2Ementions.60.20correctly/near/1649584
/// If its state is actually unread, [mentions] recovers that knowledge when:
/// a) the message is edited at all ([UpdateMessageEvent]),
/// assuming it still has a direct or wildcard mention after the edit, or
/// b) the message gains a direct @-mention ([UpdateMessageFlagsEvent]), or
/// c) TODO unimplemented: the user loads the message in the message list
/// But otherwise, assume its unread state remains unknown to [mentions].
///
/// [1] This item applies verbatim at Server 8.0+. For older servers, the
/// item would say "in a muted stream" because the "unmute topic"
/// feature was not considered:
/// https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/register.3A.20.60unread_msgs.2Ementions.60/near/1645622
// If a message's unread state is unknown, it's likely the user doesn't
// care about it anyway -- it's really old, or it's in a muted conversation.
// Still, good to recover the knowledge when possible. In the rare case
// that a user shows they are interested, like by unmuting or loading messages
// in the message list, it's important to display as much known state as we can.
//
// TODO(server-8) Remove [1].
final Set<int> mentions;
/// Whether the model is missing data on old unread messages.
///
/// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing].
/// Is set to false when the user clears out all unreads.
bool oldUnreadsMissing;
final int selfUserId;
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInCombinedFeedNarrow() {
int c = 0;
for (final messageIds in dms.values) {
c = c + messageIds.length;
}
for (final MapEntry(key: streamId, value: topics) in streams.entries) {
for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
if (channelStore.isTopicVisible(streamId, topic)) {
c = c + messageIds.length;
}
}
}
return c;
}
/// The "strict" unread count for this stream,
/// using [ChannelStore.isTopicVisible].
///
/// If the stream is muted, this will count only topics that are
/// actively unmuted.
///
/// For a count that's appropriate in UI contexts that are focused
/// specifically on this stream, see [countInStreamNarrow].
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInStream(int streamId) {
final topics = streams[streamId];
if (topics == null) return 0;
int c = 0;
for (final entry in topics.entries) {
if (channelStore.isTopicVisible(streamId, entry.key)) {
c = c + entry.value.length;
}
}
return c;
}
/// The "broad" unread count for this stream,
/// using [ChannelStore.isTopicVisibleInStream].
///
/// This includes topics that have no visibility policy of their own,
/// even if the stream itself is muted.
///
/// For a count that's appropriate in UI contexts that are not already
/// focused on this stream, see [countInStream].
// TODO(#370): maintain this count incrementally, rather than recomputing from scratch
int countInStreamNarrow(int streamId) {
final topics = streams[streamId];
if (topics == null) return 0;
int c = 0;
for (final entry in topics.entries) {
if (channelStore.isTopicVisibleInStream(streamId, entry.key)) {
c = c + entry.value.length;
}
}
return c;
}
int countInTopicNarrow(int streamId, String topic) {
final topics = streams[streamId];
return topics?[topic]?.length ?? 0;
}
int countInDmNarrow(DmNarrow narrow) => dms[narrow]?.length ?? 0;
int countInNarrow(Narrow narrow) {
switch (narrow) {
case CombinedFeedNarrow():
return countInCombinedFeedNarrow();
case StreamNarrow():
return countInStreamNarrow(narrow.streamId);
case TopicNarrow():
return countInTopicNarrow(narrow.streamId, narrow.topic);
case DmNarrow():
return countInDmNarrow(narrow);
}
}
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.topic);
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.direct:
_slowRemoveAllInDms(messageIdsSet);
}
// TODO skip notifyListeners if unchanged?
notifyListeners();
}
void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) {
switch (event.flag) {
case MessageFlag.starred:
case MessageFlag.collapsed:
case MessageFlag.hasAlertWord:
case MessageFlag.historical:
case MessageFlag.unknown:
// These are irrelevant.
return;
case MessageFlag.mentioned:
case MessageFlag.wildcardMentioned:
// Empirically, we don't seem to get these events when a message is edited
// to add/remove an @-mention, even though @-mention state is represented
// as flags. Instead, we just get the [UpdateMessageEvent], and that
// contains the new set of flags, which we'll use to update [mentions].
// (See our handling of [UpdateMessageEvent].)
//
// Handle the event anyway, using the meaning on the tin.
// It might be used in a valid case we haven't thought of yet.
// TODO skip notifyListeners if unchanged?
switch (event) {
case UpdateMessageFlagsAddEvent():
mentions.addAll(
event.messages.where(
(messageId) => _slowIsPresentInStreams(messageId) || _slowIsPresentInDms(messageId),
),
);
case UpdateMessageFlagsRemoveEvent():
mentions.removeAll(event.messages);
}
case MessageFlag.read:
switch (event) {
case UpdateMessageFlagsAddEvent():
if (event.all) {
streams.clear();
dms.clear();
mentions.clear();
oldUnreadsMissing = false;
} else {
final messageIdsSet = Set.of(event.messages);
mentions.removeAll(messageIdsSet);
_slowRemoveAllInStreams(messageIdsSet);
_slowRemoveAllInDms(messageIdsSet);
}
case UpdateMessageFlagsRemoveEvent():
final newlyUnreadInStreams = <int, Map<String, QueueList<int>>>{};
final newlyUnreadInDms = <DmNarrow, QueueList<int>>{};
for (final messageId in event.messages) {
final detail = event.messageDetails![messageId];
if (detail == null) { // TODO(log) if on Zulip 6.0+
// Happens as a bug in some cases before fixed in Zulip 6.0:
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467
// TODO(server-6) remove Zulip 6.0 comment
continue;
}
if (detail.mentioned == true) {
mentions.add(messageId);
}
switch (detail.type) {
case MessageType.stream:
final topics = (newlyUnreadInStreams[detail.streamId!] ??= {});
final messageIds = (topics[detail.topic!] ??= QueueList());
messageIds.add(messageId);
case MessageType.direct:
final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId,
detail);
(newlyUnreadInDms[narrow] ??= QueueList())
.add(messageId);
}
}
for (final MapEntry(key: incomingStreamId, value: incomingTopics)
in newlyUnreadInStreams.entries) {
for (final MapEntry(key: incomingTopic, value: incomingMessageIds)
in incomingTopics.entries) {
_addAllInStreamTopic(incomingMessageIds..sort(), incomingStreamId, incomingTopic);
}
}
for (final MapEntry(key: incomingDmNarrow, value: incomingMessageIds)
in newlyUnreadInDms.entries) {
_addAllInDm(incomingMessageIds..sort(), incomingDmNarrow);
}
}
}
notifyListeners();
}
/// To be called on success of a mark-all-as-read task in the modern protocol.
///
/// When the user successfully marks all messages as read,
/// there can't possibly be ancient unreads we don't know about.
/// So this updates [oldUnreadsMissing] to false and calls [notifyListeners].
///
/// When we use POST /messages/flags/narrow (FL 155+) for mark-all-as-read,
/// we don't expect to get a mark-as-read event with `all: true`,
/// even on completion of the last batch of unreads.
/// If we did get an event with `all: true` (as we do in the legacy mark-all-
/// as-read protocol), this would be handled naturally, in
/// [handleUpdateMessageFlagsEvent].
///
/// Discussion:
/// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680275>
// TODO(server-6) Delete mentions of legacy protocol.
void handleAllMessagesReadSuccess() {
oldUnreadsMissing = false;
// Best not to actually clear any unreads out of the model.
// That'll be handled naturally when the event comes in, with a list of which
// messages were marked as read. When a mark-all-as-read task is complete,
// I don't think the server will always have zero unreads in its state.
// For example, I assume a new unread message could arrive while the work is
// in progress, and not get caught and marked as read. We should faithfully
// match that state. (This point seems especially relevant when the
// mark-as-read work is done in batches.)
//
// Even considering races like that, it does seem basically impossible for
// `oldUnreadsMissing: false` to be the wrong state at this point.
notifyListeners();
}
// TODO use efficient lookups
bool _slowIsPresentInStreams(int messageId) {
return streams.values.any(
(topics) => topics.values.any(
(messageIds) => messageIds.contains(messageId),
),
);
}
void _addLastInStreamTopic(int messageId, int streamId, String topic) {
((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId);
}
// [messageIds] must be sorted ascending and without duplicates.
void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) {
final topics = streams[streamId] ??= {};
topics.update(topic,
ifAbsent: () => messageIds,
// setUnion dedupes existing and incoming unread IDs,
// so we tolerate zulip/zulip#22164, fixed in 6.0
// TODO(server-6) remove 6.0 comment
(existing) => setUnion(existing, messageIds),
);
}
// TODO use efficient model lookups
void _slowRemoveAllInStreams(Set<int> idsToRemove) {
final newlyEmptyStreams = <int>[];
for (final MapEntry(key: streamId, value: topics) in streams.entries) {
final newlyEmptyTopics = <String>[];
for (final MapEntry(key: topic, value: messageIds) in topics.entries) {
messageIds.removeWhere((id) => idsToRemove.contains(id));
if (messageIds.isEmpty) {
newlyEmptyTopics.add(topic);
}
}
for (final topic in newlyEmptyTopics) {
topics.remove(topic);
}
if (topics.isEmpty) {
newlyEmptyStreams.add(streamId);
}
}
for (final streamId in newlyEmptyStreams) {
streams.remove(streamId);
}
}
void _removeAllInStreamTopic(Set<int> incomingMessageIds, int streamId, String topic) {
final topics = streams[streamId];
if (topics == null) return;
final messageIds = topics[topic];
if (messageIds == null) return;
// ([QueueList] doesn't have a `removeAll`)
messageIds.removeWhere((id) => incomingMessageIds.contains(id));
if (messageIds.isEmpty) {
topics.remove(topic);
if (topics.isEmpty) {
streams.remove(streamId);
}
}
}
// TODO use efficient model lookups
bool _slowIsPresentInDms(int messageId) {
return dms.values.any((ids) => ids.contains(messageId));
}
void _addLastInDm(int messageId, DmNarrow narrow) {
(dms[narrow] ??= QueueList()).addLast(messageId);
}
// [messageIds] must be sorted ascending and without duplicates.
void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) {
dms.update(dmNarrow,
ifAbsent: () => messageIds,
// setUnion dedupes existing and incoming unread IDs,
// so we tolerate zulip/zulip#22164, fixed in 6.0
// TODO(server-6) remove 6.0 comment
(existing) => setUnion(existing, messageIds),
);
}
// TODO use efficient model lookups
void _slowRemoveAllInDms(Set<int> idsToRemove) {
final newlyEmptyDms = <DmNarrow>[];
for (final MapEntry(key: dmNarrow, value: messageIds) in dms.entries) {
messageIds.removeWhere((id) => idsToRemove.contains(id));
if (messageIds.isEmpty) {
newlyEmptyDms.add(dmNarrow);
}
}
for (final dmNarrow in newlyEmptyDms) {
dms.remove(dmNarrow);
}
}
}