Skip to content

Commit 69e2a9d

Browse files
committed
WIP model: Add Unreads model, for tracking unread-message counts
TODO: - Tests - Change Sets of message IDs to sorted Lists, like in zulip-mobile, for space efficiency (needs attention to make sure they stay sorted and that operations are as time-efficient as possible) - Open #api documentation thread for when (if ever) to expect update_message_flags with 'mentioned' and 'wildcard_mentioned' flags - Track docs-change requests; as those are completed, have the code point to the doc instead of restating what the doc should say: - https://chat.zulip.org/#narrow/stream/378-api-design/topic/.60all.60.20in.20update_message_flags.2Fremove/near/1639963 - https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/mark-as-read.20events.20on.20stream.20unsubscribe/near/1639566 - Other TODOs as noted in comments (perhaps in later work though)
1 parent 6dcb602 commit 69e2a9d

File tree

3 files changed

+302
-0
lines changed

3 files changed

+302
-0
lines changed

lib/model/narrow.dart

+17
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11

2+
import '../api/model/events.dart';
23
import '../api/model/initial_snapshot.dart';
34
import '../api/model/model.dart';
45
import '../api/model/narrow.dart';
@@ -161,6 +162,22 @@ class DmNarrow extends Narrow implements SendableNarrow {
161162
);
162163
}
163164

165+
/// A [DmNarrow] from an [UnreadHuddleSnapshot].
166+
factory DmNarrow.ofUnreadHuddleSnapshot(UnreadHuddleSnapshot snapshot, {required int selfUserId}) {
167+
final userIds = snapshot.userIdsString.split(',').map((id) => int.parse(id));
168+
return DmNarrow(selfUserId: selfUserId,
169+
allRecipientIds: userIds.toList(growable: false)..sort());
170+
}
171+
172+
factory DmNarrow.ofUpdateMessageFlagsMessageDetail(
173+
UpdateMessageFlagsMessageDetail detail, {
174+
required int selfUserId,
175+
}) {
176+
assert(detail.type == MessageType.private);
177+
return DmNarrow(selfUserId: selfUserId,
178+
allRecipientIds: [...detail.userIds!, selfUserId]..sort());
179+
}
180+
164181
factory DmNarrow.withUser(int userId, {required int selfUserId}) {
165182
return DmNarrow(
166183
allRecipientIds: {userId, selfUserId}.toList()..sort(),

lib/model/store.dart

+1
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ class PerAccountStore extends ChangeNotifier {
179179

180180
// Data attached to the self-account on the realm.
181181
final UserSettings? userSettings; // TODO(server-5)
182+
// final Unreads unreads;
182183

183184
// Users and data about them.
184185
final Map<int, User> users;

lib/model/unreads.dart

+284
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import 'dart:core';
2+
3+
import 'package:flutter/foundation.dart';
4+
5+
import '../api/model/initial_snapshot.dart';
6+
import '../api/model/model.dart';
7+
import '../api/model/events.dart';
8+
import '../log.dart';
9+
import 'narrow.dart';
10+
11+
/// A view-model for unread messages.
12+
///
13+
/// If a message ID is not represented, then its status is either:
14+
/// - read, or
15+
/// - unknown, because we're missing data on old unread messages
16+
/// (see [oldUnreadsMissing]).
17+
///
18+
/// Messages in unsubscribed streams are generally considered unread by the
19+
/// server and should not be expected to appear here. They may still appear
20+
/// temporarily when the server hasn't finished processing the transition to the
21+
/// unsubscribed-stream state; the marking-as-read may be done asynchronously:
22+
/// https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639926
23+
/// For that reason, consumers of this model may wish to filter out messages in
24+
/// unsubscribed streams.
25+
class Unreads extends ChangeNotifier {
26+
factory Unreads({required UnreadMessagesSnapshot initial, required selfUserId}) {
27+
final streams = <int, Map<String, Set<int>>>{};
28+
final dms = <DmNarrow, Set<int>>{};
29+
final mentions = Set.of(initial.mentions);
30+
31+
for (final unreadStreamSnapshot in initial.streams) {
32+
final streamId = unreadStreamSnapshot.streamId;
33+
final topic = unreadStreamSnapshot.topic;
34+
35+
(streams[streamId] ??= {})[topic] = Set.of(unreadStreamSnapshot.unreadMessageIds);
36+
}
37+
38+
for (final unreadDmSnapshot in initial.dms) {
39+
final otherUserId = unreadDmSnapshot.otherUserId;
40+
41+
final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId);
42+
dms[narrow] = Set.of(unreadDmSnapshot.unreadMessageIds);
43+
}
44+
45+
for (final unreadHuddleSnapshot in initial.huddles) {
46+
final narrow = DmNarrow.ofUnreadHuddleSnapshot(selfUserId: selfUserId,
47+
unreadHuddleSnapshot);
48+
dms[narrow] = Set.of(unreadHuddleSnapshot.unreadMessageIds);
49+
}
50+
51+
return Unreads._(
52+
streams: streams,
53+
dms: dms,
54+
mentions: mentions,
55+
oldUnreadsMissing: initial.oldUnreadsMissing,
56+
selfUserId: selfUserId,
57+
);
58+
}
59+
60+
Unreads._({
61+
required this.streams,
62+
required this.dms,
63+
required this.mentions,
64+
required this.oldUnreadsMissing,
65+
required this.selfUserId,
66+
});
67+
68+
/// Unread stream messages, as: stream ID → topic → message ID.
69+
final Map<int, Map<String, Set<int>>> streams;
70+
71+
/// Unread DM messages, as: DM narrow → message ID.
72+
final Map<DmNarrow, Set<int>> dms;
73+
74+
/// Unread messages with the self-user @-mentioned, as a set of message IDs.
75+
///
76+
/// Includes messages with
77+
/// [MessageFlag.mentioned] or [MessageFlag.wildcardMentioned] or both.
78+
final Set<int> mentions;
79+
80+
/// Whether the model might be missing data on old unread messages.
81+
///
82+
/// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing].
83+
/// Is set to false when the user clears out all unreads at once
84+
/// (signaled by a [UpdateMessageFlagsAddEvent] with [MessageFlag.read] and
85+
/// `true` for [UpdateMessageFlagsAddEvent.all]).
86+
bool oldUnreadsMissing;
87+
88+
final int selfUserId;
89+
90+
// TODO replace with efficient alternative
91+
bool _slowIsPresentInStreams(int messageId) {
92+
return streams.values.any(
93+
(topics) => topics.values.any(
94+
(ids) => ids.contains(messageId),
95+
),
96+
);
97+
}
98+
99+
// TODO replace with efficient alternative
100+
bool _slowIsPresentInDms(int messageId) {
101+
return dms.values.any((ids) => ids.contains(messageId));
102+
}
103+
104+
// TODO replace with efficient alternative
105+
void _slowRemoveInStreams(Iterable<int> messageIds) {
106+
for (final messageId in messageIds) {
107+
for (final stream in streams.values) {
108+
for (final topic in stream.values) {
109+
topic.remove(messageId);
110+
}
111+
}
112+
}
113+
}
114+
115+
// TODO replace with efficient alternative
116+
void _slowRemoveInDms(Iterable<int> messageIds) {
117+
for (final messageId in messageIds) {
118+
for (final dm in dms.values) {
119+
dm.remove(messageId);
120+
}
121+
}
122+
}
123+
124+
void _addToStreams(int messageId, int streamId, String topic) {
125+
((streams[streamId] ??= {})[topic] ??= {}).add(messageId);
126+
}
127+
128+
void _addToDms(int messageId, DmNarrow narrow) {
129+
(dms[narrow] ??= {}).add(messageId);
130+
}
131+
132+
void handleUpdateMessageEvent(UpdateMessageEvent event) {
133+
final messageId = event.messageId;
134+
final bool isMentioned = event.flags.any(
135+
(f) => f == MessageFlag.mentioned || f == MessageFlag.wildcardMentioned,
136+
);
137+
138+
// We assume this event can't signal a change in a message's 'read' flag.
139+
// TODO can it actually though, when it's about messages being moved into an
140+
// unsubscribed stream?
141+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/mark-as-read.20events.20with.20message.20moves.3F/near/1639957
142+
final bool isRead = event.flags.contains(MessageFlag.read);
143+
assert(() {
144+
if (!oldUnreadsMissing && !event.messageIds.every((messageId) {
145+
final isUnreadLocally = _slowIsPresentInDms(messageId) || _slowIsPresentInStreams(messageId);
146+
return isUnreadLocally == !isRead;
147+
})) {
148+
// If this happens, then either:
149+
// - the server and client have been out of sync about a message's
150+
// unread state since before this event, or
151+
// - this event was unexpectedly used to announce a change in a
152+
// message's 'read' flag.
153+
debugLog('Unreads warning: got surprising UpdateMessageEvent');
154+
}
155+
return true;
156+
}());
157+
158+
switch ((isRead, isMentioned)) {
159+
case (true, false):
160+
mentions.remove(messageId);
161+
case (true, true ):
162+
// A mention (even if new with this event) makes no difference
163+
// for a message that's already read.
164+
break;
165+
case (false, false):
166+
mentions.remove(messageId);
167+
case (false, true ):
168+
mentions.add(messageId);
169+
}
170+
171+
// TODO: Handle moved messages.
172+
173+
notifyListeners();
174+
}
175+
176+
void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) {
177+
switch (event.flag) {
178+
case MessageFlag.starred:
179+
case MessageFlag.collapsed:
180+
case MessageFlag.hasAlertWord:
181+
case MessageFlag.historical:
182+
case MessageFlag.unknown:
183+
// These are irrelevant.
184+
return;
185+
186+
case MessageFlag.mentioned:
187+
case MessageFlag.wildcardMentioned:
188+
// Empirically, we don't seem to get these events when a message is edited
189+
// to add/remove an @-mention, even though @-mention state is represented
190+
// as flags. Instead, we just get the [UpdateMessageEvent], and that
191+
// contains the new set of flags, which we'll use to update [mentions].
192+
// (See our handling of [UpdateMessageEvent].)
193+
//
194+
// Best to handle the event anyway, using the meaning on the tin.
195+
// It might be used in a valid case we haven't thought of yet.
196+
switch (event) {
197+
case UpdateMessageFlagsAddEvent():
198+
if (event.all) { // TODO(log)
199+
// Strange and unexpected for this flag.
200+
return;
201+
}
202+
mentions.addAll(
203+
event.messages.where(
204+
(messageId) => _slowIsPresentInStreams(messageId) || _slowIsPresentInDms(messageId),
205+
),
206+
);
207+
208+
case UpdateMessageFlagsRemoveEvent():
209+
mentions.removeAll(event.messages);
210+
}
211+
212+
case MessageFlag.read:
213+
switch (event) {
214+
case UpdateMessageFlagsAddEvent():
215+
if (event.all) {
216+
streams.clear();
217+
dms.clear();
218+
mentions.clear();
219+
oldUnreadsMissing = false;
220+
} else {
221+
// We get these mark-as-read events when the user's scrolling
222+
// causes messages to be marked as read, as you would expect.
223+
// We also get them when you unsubscribe from a stream that has
224+
// unreads in it:
225+
// https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/mark-as-read.20events.20on.20stream.20unsubscribe/near/1639566
226+
227+
mentions.removeAll(event.messages);
228+
_slowRemoveInStreams(event.messages);
229+
_slowRemoveInDms(event.messages);
230+
}
231+
case UpdateMessageFlagsRemoveEvent():
232+
for (final messageId in event.messages) {
233+
final detail = event.messageDetails![messageId];
234+
if (detail == null) { // TODO(log) if on Zulip 6.0+
235+
// Known to happen as a bug in some cases before Zulip 6.0:
236+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467
237+
// TODO(server-6) remove Zulip 6.0 comment
238+
return;
239+
}
240+
switch (detail.type) {
241+
case MessageType.stream:
242+
_addToStreams(messageId, detail.streamId!, detail.topic!);
243+
case MessageType.private:
244+
final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId,
245+
detail);
246+
_addToDms(messageId, narrow);
247+
}
248+
}
249+
}
250+
}
251+
notifyListeners();
252+
}
253+
254+
void handleMessageEvent(MessageEvent event) {
255+
final message = event.message;
256+
if (message.flags.contains(MessageFlag.read)) {
257+
return;
258+
}
259+
260+
switch (message) {
261+
case StreamMessage():
262+
_addToStreams(message.id, message.streamId, message.subject);
263+
case DmMessage():
264+
final narrow = DmNarrow.ofMessage(message, selfUserId: selfUserId);
265+
_addToDms(message.id, narrow);
266+
}
267+
if (message.flags.contains(MessageFlag.mentioned)
268+
|| message.flags.contains(MessageFlag.wildcardMentioned)) {
269+
mentions.add(message.id);
270+
}
271+
notifyListeners();
272+
}
273+
274+
void handleDeleteMessageEvent(DeleteMessageEvent event) {
275+
mentions.removeAll(event.messageIds);
276+
switch (event.messageType) {
277+
case MessageType.stream:
278+
streams[event.streamId!]?[event.topic!]?.removeAll(event.messageIds);
279+
case MessageType.private:
280+
_slowRemoveInDms(event.messageIds);
281+
}
282+
notifyListeners();
283+
}
284+
}

0 commit comments

Comments
 (0)