Skip to content

Commit ca6b5a0

Browse files
gnpricechrisbobbe
andcommitted
message: Mutate messages for reaction events here, instead of in msglists
This looks potentially NFC, but in fact it fixes part of 455: it fixes the issue as far as reactions are concerned. The change is NFC only if the message appears in just one message list. In addition to the symptom explained at zulip#455, this fixes another, which exists now that we've added the central message store with `reconcileMessages`. It's arguably a bit of an inverse of zulip#455, but fundamentally the same issue: if there are currently *zero* message lists that contain the message, then we would drop the update. Then on later fetching the same message, we'd use the version we had (for the good reason explained in reconcileMessages) but in fact it'd be stale. This zero-message-lists symptom also reproduces with other kinds of updates that we handle: editing a message, and adding/removing flags. So, in the regression test for this symptom, I've tried to make some reusable test code that we can use when we fix those other ways of updating messages. Co-authored-by: Chris Bobbe <[email protected]> Fixes-partly: zulip#455
1 parent 3e852c6 commit ca6b5a0

File tree

3 files changed

+146
-30
lines changed

3 files changed

+146
-30
lines changed

lib/model/message.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,30 @@ class MessageStoreImpl with MessageStore {
109109
}
110110

111111
void handleReactionEvent(ReactionEvent event) {
112+
final message = messages[event.messageId];
113+
if (message == null) return;
114+
115+
switch (event.op) {
116+
case ReactionOp.add:
117+
(message.reactions ??= Reactions([])).add(Reaction(
118+
emojiName: event.emojiName,
119+
emojiCode: event.emojiCode,
120+
reactionType: event.reactionType,
121+
userId: event.userId,
122+
));
123+
case ReactionOp.remove:
124+
if (message.reactions == null) { // TODO(log)
125+
return;
126+
}
127+
message.reactions!.remove(
128+
reactionType: event.reactionType,
129+
emojiCode: event.emojiCode,
130+
userId: event.userId,
131+
);
132+
}
133+
112134
for (final view in _messageListViews) {
113-
view.maybeUpdateMessageReactions(event); // TODO update mainly in [messages] instead
135+
view.notifyListenersIfMessagePresent(event.messageId);
114136
}
115137
}
116138
}

lib/model/message_list.dart

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,14 @@ class MessageListView with ChangeNotifier, _MessageSequence {
444444
notifyListeners();
445445
}
446446

447+
/// Notify listeners if the given message is present in this view.
448+
void notifyListenersIfMessagePresent(int messageId) {
449+
final index = _findMessageWithId(messageId);
450+
if (index != -1) {
451+
notifyListeners();
452+
}
453+
}
454+
447455
static void _applyChangesToMessage(UpdateMessageEvent event, Message message) {
448456
// TODO(server-5): Cut this fallback; rely on renderingOnly from FL 114
449457
final isRenderingOnly = event.renderingOnly ?? (event.userId == null);
@@ -515,35 +523,6 @@ class MessageListView with ChangeNotifier, _MessageSequence {
515523
notifyListeners();
516524
}
517525

518-
void maybeUpdateMessageReactions(ReactionEvent event) {
519-
final index = _findMessageWithId(event.messageId);
520-
if (index == -1) {
521-
return;
522-
}
523-
524-
final message = messages[index];
525-
switch (event.op) {
526-
case ReactionOp.add:
527-
(message.reactions ??= Reactions([])).add(Reaction(
528-
emojiName: event.emojiName,
529-
emojiCode: event.emojiCode,
530-
reactionType: event.reactionType,
531-
userId: event.userId,
532-
));
533-
case ReactionOp.remove:
534-
if (message.reactions == null) { // TODO(log)
535-
return;
536-
}
537-
message.reactions!.remove(
538-
reactionType: event.reactionType,
539-
emojiCode: event.emojiCode,
540-
userId: event.userId,
541-
);
542-
}
543-
544-
notifyListeners();
545-
}
546-
547526
/// Called when the app is reassembled during debugging, e.g. for hot reload.
548527
///
549528
/// This will redo from scratch any computations we can, such as parsing

test/model/message_list_test.dart

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,41 @@ void main() {
271271
checkInvariants(model);
272272
});
273273

274+
group('notifyListenersIfMessagePresent', () {
275+
test('message present', () async {
276+
await prepare(narrow: const CombinedFeedNarrow());
277+
await prepareMessages(foundOldest: false,
278+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)));
279+
model.notifyListenersIfMessagePresent(150);
280+
checkNotifiedOnce();
281+
});
282+
283+
test('message absent', () async {
284+
await prepare(narrow: const CombinedFeedNarrow());
285+
await prepareMessages(foundOldest: false,
286+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i))
287+
.where((m) => m.id != 150).toList());
288+
model.notifyListenersIfMessagePresent(150);
289+
checkNotNotified();
290+
});
291+
292+
test('message absent (older than window)', () async {
293+
await prepare(narrow: const CombinedFeedNarrow());
294+
await prepareMessages(foundOldest: false,
295+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)));
296+
model.notifyListenersIfMessagePresent(50);
297+
checkNotNotified();
298+
});
299+
300+
test('message absent (newer than window)', () async {
301+
await prepare(narrow: const CombinedFeedNarrow());
302+
await prepareMessages(foundOldest: false,
303+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)));
304+
model.notifyListenersIfMessagePresent(250);
305+
checkNotNotified();
306+
});
307+
});
308+
274309
group('maybeUpdateMessage', () {
275310
test('update a message', () async {
276311
final originalMessage = eg.streamMessage(
@@ -454,6 +489,86 @@ void main() {
454489
});
455490
});
456491

492+
group('regression tests for #455', () {
493+
test('reaction events handled once, even when message is in two message lists', () async {
494+
final stream = eg.stream();
495+
store = eg.store();
496+
await store.addStream(stream);
497+
await store.addSubscription(eg.subscription(stream));
498+
connection = store.connection as FakeApiConnection;
499+
500+
int notifiedCount1 = 0;
501+
final model1 = MessageListView.init(store: store,
502+
narrow: StreamNarrow(stream.streamId))
503+
..addListener(() => notifiedCount1++);
504+
505+
int notifiedCount2 = 0;
506+
final model2 = MessageListView.init(store: store,
507+
narrow: TopicNarrow(stream.streamId, 'hello'))
508+
..addListener(() => notifiedCount2++);
509+
510+
for (final m in [model1, model2]) {
511+
connection.prepare(json: newestResult(
512+
foundOldest: false,
513+
messages: [eg.streamMessage(stream: stream, topic: 'hello')]).toJson());
514+
await m.fetchInitial();
515+
}
516+
517+
final message = eg.streamMessage(stream: stream, topic: 'hello');
518+
await store.handleEvent(MessageEvent(id: 0, message: message));
519+
520+
await store.handleEvent(
521+
eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, message.id));
522+
523+
check(notifiedCount1).equals(3); // fetch, new-message event, reaction event
524+
check(notifiedCount2).equals(3); // fetch, new-message event, reaction event
525+
526+
check(model1.messages.last)
527+
..identicalTo(model2.messages.last)
528+
..reactions.isNotNull().total.equals(1);
529+
});
530+
531+
Future<void> checkApplied({
532+
required Event Function(Message) mkEvent,
533+
required void Function(Subject<Message>) doCheckMessageAfterFetch,
534+
}) async {
535+
final stream = eg.stream();
536+
store = eg.store();
537+
await store.addStream(stream);
538+
await store.addSubscription(eg.subscription(stream));
539+
connection = store.connection as FakeApiConnection;
540+
541+
final message = eg.streamMessage(stream: stream);
542+
543+
await store.addMessage(Message.fromJson(message.toJson()));
544+
await store.handleEvent(mkEvent(message));
545+
546+
// init msglist *after* event was handled
547+
model = MessageListView.init(store: store, narrow: const CombinedFeedNarrow());
548+
checkInvariants(model);
549+
550+
connection.prepare(json:
551+
newestResult(foundOldest: false, messages: [message]).toJson());
552+
await model.fetchInitial();
553+
checkInvariants(model);
554+
doCheckMessageAfterFetch(
555+
check(model).messages.single
556+
..id.equals(message.id)
557+
);
558+
}
559+
560+
test('ReactionEvent is applied even when message not in any msglists', () async {
561+
await checkApplied(
562+
mkEvent: (message) =>
563+
eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, message.id),
564+
doCheckMessageAfterFetch:
565+
(messageSubject) => messageSubject.reactions.isNotNull().total.equals(1),
566+
);
567+
});
568+
569+
// TODO(#455) message edits; message flags
570+
});
571+
457572
test('reassemble', () async {
458573
final stream = eg.stream();
459574
await prepare(narrow: StreamNarrow(stream.streamId));

0 commit comments

Comments
 (0)