Skip to content

Commit d8949c1

Browse files
gnpricechrisbobbe
andcommitted
message: Store all messages centrally
In itself, this exacerbates #455 by making it trigger in more circumstances. That's OK for the moment, because this also gives us a foundation on which to fix that issue soon. A key part of testing this is the line added in message_list_test's `checkInvariants`, which checks that all messages tracked by the MessageListView are present in the central message store. Co-authored-by: Chris Bobbe <[email protected]>
1 parent 6eb9498 commit d8949c1

File tree

7 files changed

+191
-5
lines changed

7 files changed

+191
-5
lines changed

lib/model/message.dart

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
import '../api/model/events.dart';
2+
import '../api/model/model.dart';
23
import 'message_list.dart';
34

45
/// The portion of [PerAccountStore] for messages and message lists.
56
mixin MessageStore {
7+
/// All known messages, indexed by [Message.id].
8+
Map<int, Message> get messages;
9+
610
void registerMessageList(MessageListView view);
711
void unregisterMessageList(MessageListView view);
12+
13+
/// Reconcile a batch of just-fetched messages with the store,
14+
/// mutating the list.
15+
///
16+
/// This is called after a [getMessages] request to report the result
17+
/// to the store.
18+
///
19+
/// The list's length will not change, but some entries may be replaced
20+
/// by a different [Message] object with the same [Message.id].
21+
/// All [Message] objects in the resulting list will be present in
22+
/// [this.messages].
23+
void reconcileMessages(List<Message> messages);
824
}
925

1026
class MessageStoreImpl with MessageStore {
11-
MessageStoreImpl();
27+
MessageStoreImpl()
28+
// There are no messages in InitialSnapshot, so we don't have
29+
// a use case for initializing MessageStore with nonempty [messages].
30+
: messages = {};
31+
32+
@override
33+
final Map<int, Message> messages;
1234

1335
final Set<MessageListView> _messageListViews = {};
1436

@@ -36,31 +58,59 @@ class MessageStoreImpl with MessageStore {
3658
}
3759
}
3860

61+
@override
62+
void reconcileMessages(List<Message> messages) {
63+
// What to do when some of the just-fetched messages are already known?
64+
// This is common and normal: in particular it happens when one message list
65+
// overlaps another, e.g. a stream and a topic within it.
66+
//
67+
// Most often, the just-fetched message will look just like the one we
68+
// already have. But they can differ: message fetching happens out of band
69+
// from the event queue, so there's inherently a race.
70+
//
71+
// If the fetched message reflects changes we haven't yet heard from the
72+
// event queue, then it doesn't much matter which version we use: we'll
73+
// soon get the corresponding events and apply the changes anyway.
74+
// But if it lacks changes we've already heard from the event queue, then
75+
// we won't hear those events again; the only way to wind up with an
76+
// updated message is to use the version we have, that already reflects
77+
// those events' changes. So we always stick with the version we have.
78+
for (int i = 0; i < messages.length; i++) {
79+
final message = messages[i];
80+
messages[i] = this.messages.putIfAbsent(message.id, () => message);
81+
}
82+
}
83+
3984
void handleMessageEvent(MessageEvent event) {
85+
// If the message is one we already know about (from a fetch),
86+
// clobber it with the one from the event system.
87+
// See [fetchedMessages] for reasoning.
88+
messages[event.message.id] = event.message;
89+
4090
for (final view in _messageListViews) {
4191
view.handleMessageEvent(event);
4292
}
4393
}
4494

4595
void handleUpdateMessageEvent(UpdateMessageEvent event) {
4696
for (final view in _messageListViews) {
47-
view.maybeUpdateMessage(event);
97+
view.maybeUpdateMessage(event); // TODO update mainly in [messages] instead
4898
}
4999
}
50100

51101
void handleDeleteMessageEvent(DeleteMessageEvent event) {
52-
// TODO handle DeleteMessageEvent in MessageListView
102+
// TODO handle DeleteMessageEvent, particularly in MessageListView
53103
}
54104

55105
void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) {
56106
for (final view in _messageListViews) {
57-
view.maybeUpdateMessageFlags(event);
107+
view.maybeUpdateMessageFlags(event); // TODO update mainly in [messages] instead
58108
}
59109
}
60110

61111
void handleReactionEvent(ReactionEvent event) {
62112
for (final view in _messageListViews) {
63-
view.maybeUpdateMessageReactions(event);
113+
view.maybeUpdateMessageReactions(event); // TODO update mainly in [messages] instead
64114
}
65115
}
66116
}

lib/model/message_list.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
378378
numBefore: kMessageListFetchBatchSize,
379379
numAfter: 0,
380380
);
381+
store.reconcileMessages(result.messages);
381382
for (final message in result.messages) {
382383
if (_messageVisible(message)) {
383384
_addMessage(message);
@@ -413,6 +414,8 @@ class MessageListView with ChangeNotifier, _MessageSequence {
413414
result.messages.removeLast();
414415
}
415416

417+
store.reconcileMessages(result.messages);
418+
416419
final fetchedMessages = _allMessagesVisible
417420
? result.messages // Avoid unnecessarily copying the list.
418421
: result.messages.where(_messageVisible);

lib/model/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,17 @@ class PerAccountStore extends ChangeNotifier with StreamStore, MessageStore {
340340
////////////////////////////////
341341
// Messages, and summaries of messages.
342342

343+
@override
344+
Map<int, Message> get messages => _messages.messages;
343345
@override
344346
void registerMessageList(MessageListView view) =>
345347
_messages.registerMessageList(view);
346348
@override
347349
void unregisterMessageList(MessageListView view) =>
348350
_messages.unregisterMessageList(view);
351+
@override
352+
void reconcileMessages(List<Message> messages) =>
353+
_messages.reconcileMessages(messages);
349354

350355
final MessageStoreImpl _messages;
351356

test/model/message_list_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,7 @@ void checkInvariants(MessageListView model) {
933933
}
934934

935935
for (final message in model.messages) {
936+
check(model.store.messages)[message.id].isNotNull().identicalTo(message);
936937
check(model.narrow.containsMessage(message)).isTrue();
937938

938939
if (message is! StreamMessage) continue;

test/model/message_test.dart

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/api/model/events.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/model/store.dart';
6+
7+
import '../example_data.dart' as eg;
8+
9+
void main() {
10+
// These variables are the common state operated on by each test.
11+
// Each test case calls [prepare] to initialize them.
12+
late PerAccountStore store;
13+
14+
/// Initialize [store] and the rest of the test state.
15+
void prepare() {
16+
store = eg.store();
17+
}
18+
19+
Future<void> addMessages(Iterable<Message> messages) async {
20+
for (final m in messages) {
21+
await store.handleEvent(MessageEvent(id: 0, message: m));
22+
}
23+
}
24+
25+
group('reconcileMessages', () {
26+
test('from empty', () async {
27+
prepare();
28+
check(store.messages).isEmpty();
29+
final message1 = eg.streamMessage();
30+
final message2 = eg.streamMessage();
31+
final message3 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
32+
final messages = [message1, message2, message3];
33+
store.reconcileMessages(messages);
34+
check(messages).deepEquals(
35+
[message1, message2, message3]
36+
.map((m) => (Subject<Object?> it) => it.identicalTo(m)));
37+
check(store.messages).deepEquals({
38+
for (final m in messages) m.id: m,
39+
});
40+
});
41+
42+
test('from not-empty', () async {
43+
prepare();
44+
final message1 = eg.streamMessage();
45+
final message2 = eg.streamMessage();
46+
final message3 = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
47+
final messages = [message1, message2, message3];
48+
await addMessages(messages);
49+
final newMessage = eg.streamMessage();
50+
store.reconcileMessages([newMessage]);
51+
check(messages).deepEquals(
52+
[message1, message2, message3]
53+
.map((m) => (Subject<Object?> it) => it.identicalTo(m)));
54+
check(store.messages).deepEquals({
55+
for (final m in messages) m.id: m,
56+
newMessage.id: newMessage,
57+
});
58+
});
59+
60+
test('on ID collision, new message does not clobber old in store.messages', () async {
61+
prepare();
62+
final message = eg.streamMessage(id: 1, content: '<p>foo</p>');
63+
await addMessages([message]);
64+
check(store.messages).deepEquals({1: message});
65+
final newMessage = eg.streamMessage(id: 1, content: '<p>bar</p>');
66+
final messages = [newMessage];
67+
store.reconcileMessages(messages);
68+
check(messages).single.identicalTo(message);
69+
check(store.messages).deepEquals({1: message});
70+
});
71+
});
72+
73+
group('handleMessageEvent', () {
74+
test('from empty', () async {
75+
prepare();
76+
check(store.messages).isEmpty();
77+
78+
final newMessage = eg.streamMessage();
79+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
80+
check(store.messages).deepEquals({
81+
newMessage.id: newMessage,
82+
});
83+
});
84+
85+
test('from not-empty', () async {
86+
prepare();
87+
final messages = [
88+
eg.streamMessage(),
89+
eg.streamMessage(),
90+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
91+
];
92+
await addMessages(messages);
93+
check(store.messages).deepEquals({
94+
for (final m in messages) m.id: m,
95+
});
96+
97+
final newMessage = eg.streamMessage();
98+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
99+
check(store.messages).deepEquals({
100+
for (final m in messages) m.id: m,
101+
newMessage.id: newMessage,
102+
});
103+
});
104+
105+
test('new message clobbers old on ID collision', () async {
106+
prepare();
107+
final message = eg.streamMessage(id: 1, content: '<p>foo</p>');
108+
await addMessages([message]);
109+
check(store.messages).deepEquals({1: message});
110+
111+
final newMessage = eg.streamMessage(id: 1, content: '<p>bar</p>');
112+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
113+
check(store.messages).deepEquals({1: newMessage});
114+
});
115+
});
116+
}

test/model/store_checks.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ extension PerAccountStoreChecks on Subject<PerAccountStore> {
4242
Subject<Map<int, ZulipStream>> get streams => has((x) => x.streams, 'streams');
4343
Subject<Map<String, ZulipStream>> get streamsByName => has((x) => x.streamsByName, 'streamsByName');
4444
Subject<Map<int, Subscription>> get subscriptions => has((x) => x.subscriptions, 'subscriptions');
45+
Subject<Map<int, Message>> get messages => has((x) => x.messages, 'messages');
4546
Subject<Unreads> get unreads => has((x) => x.unreads, 'unreads');
4647
Subject<RecentDmConversationsView> get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView');
4748
Subject<AutocompleteViewManager> get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager');

test/model/test_store.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,14 @@ extension PerAccountStoreTestExtension on PerAccountStore {
154154
visibilityPolicy: visibilityPolicy,
155155
));
156156
}
157+
158+
Future<void> addMessage(Message message) async {
159+
await handleEvent(MessageEvent(id: 1, message: message));
160+
}
161+
162+
Future<void> addMessages(Iterable<Message> messages) async {
163+
for (final message in messages) {
164+
await addMessage(message);
165+
}
166+
}
157167
}

0 commit comments

Comments
 (0)