Skip to content

Commit 31db256

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 b5bc4b7 commit 31db256

File tree

7 files changed

+185
-5
lines changed

7 files changed

+185
-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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 messages = [
30+
eg.streamMessage(),
31+
eg.streamMessage(),
32+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
33+
];
34+
store.reconcileMessages(messages);
35+
check(store.messages).deepEquals({
36+
for (final m in messages) m.id: m,
37+
});
38+
});
39+
40+
test('from not-empty', () async {
41+
prepare();
42+
final messages = [
43+
eg.streamMessage(),
44+
eg.streamMessage(),
45+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
46+
];
47+
await addMessages(messages);
48+
final newMessage = eg.streamMessage();
49+
store.reconcileMessages([newMessage]);
50+
check(store.messages).deepEquals({
51+
for (final m in messages) m.id: m,
52+
newMessage.id: newMessage,
53+
});
54+
});
55+
56+
test('new message does not clobber old on ID collision', () async {
57+
prepare();
58+
final message = eg.streamMessage(id: 1, content: '<p>foo</p>');
59+
await addMessages([message]);
60+
check(store.messages).deepEquals({1: message});
61+
final newMessage = eg.streamMessage(id: 1, content: '<p>bar</p>');
62+
store.reconcileMessages([newMessage]);
63+
check(store.messages).deepEquals({1: message});
64+
});
65+
});
66+
67+
group('handleMessageEvent', () {
68+
test('from empty', () async {
69+
prepare();
70+
check(store.messages).isEmpty();
71+
72+
final newMessage = eg.streamMessage();
73+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
74+
check(store.messages).deepEquals({
75+
newMessage.id: newMessage,
76+
});
77+
});
78+
79+
test('from not-empty', () async {
80+
prepare();
81+
final messages = [
82+
eg.streamMessage(),
83+
eg.streamMessage(),
84+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
85+
];
86+
await addMessages(messages);
87+
check(store.messages).deepEquals({
88+
for (final m in messages) m.id: m,
89+
});
90+
91+
final newMessage = eg.streamMessage();
92+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
93+
check(store.messages).deepEquals({
94+
for (final m in messages) m.id: m,
95+
newMessage.id: newMessage,
96+
});
97+
});
98+
99+
test('new message clobbers old on ID collision', () async {
100+
prepare();
101+
final message = eg.streamMessage(id: 1, content: '<p>foo</p>');
102+
await addMessages([message]);
103+
check(store.messages).deepEquals({1: message});
104+
105+
final newMessage = eg.streamMessage(id: 1, content: '<p>bar</p>');
106+
await store.handleEvent(MessageEvent(id: 1, message: newMessage));
107+
check(store.messages).deepEquals({1: newMessage});
108+
});
109+
});
110+
}

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)