Skip to content

Commit 6e4c1e0

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 #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 #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: #455
1 parent 41d7918 commit 6e4c1e0

File tree

3 files changed

+167
-30
lines changed

3 files changed

+167
-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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,32 @@ 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 (older than window)', () async {
284+
await prepare(narrow: const CombinedFeedNarrow());
285+
await prepareMessages(foundOldest: false,
286+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)));
287+
model.notifyListenersIfMessagePresent(50);
288+
checkNotNotified();
289+
});
290+
291+
test('message absent (newer than window)', () async {
292+
await prepare(narrow: const CombinedFeedNarrow());
293+
await prepareMessages(foundOldest: false,
294+
messages: List.generate(100, (i) => eg.streamMessage(id: 100 + i)));
295+
model.notifyListenersIfMessagePresent(250);
296+
checkNotNotified();
297+
});
298+
});
299+
274300
group('maybeUpdateMessage', () {
275301
test('update a message', () async {
276302
final originalMessage = eg.streamMessage(
@@ -454,6 +480,116 @@ void main() {
454480
});
455481
});
456482

483+
group('regression tests for #455', () {
484+
test('reaction events handled once, even when message is in two message lists', () async {
485+
final stream = eg.stream();
486+
store = eg.store();
487+
await store.addStream(stream);
488+
await store.addSubscription(eg.subscription(stream));
489+
connection = store.connection as FakeApiConnection;
490+
491+
int notifiedCount1 = 0;
492+
final model1 = MessageListView.init(store: store,
493+
narrow: StreamNarrow(stream.streamId),
494+
)
495+
..addListener(() {
496+
notifiedCount1++;
497+
});
498+
499+
int notifiedCount2 = 0;
500+
final model2 = MessageListView.init(store: store,
501+
narrow: TopicNarrow(stream.streamId, 'hello'),
502+
)
503+
..addListener(() {
504+
notifiedCount2++;
505+
});
506+
507+
for (final m in [model1, model2]) {
508+
connection.prepare(json: newestResult(
509+
foundOldest: false,
510+
messages: [eg.streamMessage(stream: stream, topic: 'hello')]).toJson());
511+
await m.fetchInitial();
512+
}
513+
514+
final message = eg.streamMessage(stream: stream, topic: 'hello');
515+
await store.handleEvent(MessageEvent(id: 0, message: message));
516+
517+
final reaction = eg.unicodeEmojiReaction;
518+
await store.handleEvent(ReactionEvent(
519+
id: 1,
520+
op: ReactionOp.add,
521+
emojiName: reaction.emojiName,
522+
emojiCode: reaction.emojiCode,
523+
reactionType: reaction.reactionType,
524+
userId: reaction.userId,
525+
messageId: message.id,
526+
));
527+
528+
check(notifiedCount1).equals(3); // fetch, new-message event, reaction event
529+
check(notifiedCount2).equals(3); // fetch, new-message event, reaction event
530+
531+
final model1Message = model1.messages.last;
532+
final model2Message = model2.messages.last;
533+
check(model1Message)
534+
..identicalTo(model2Message)
535+
..reactions.isNotNull().total.equals(1);
536+
});
537+
538+
for (
539+
final (
540+
eventName: String eventName,
541+
mkEvent: Event Function(Message) mkEvent,
542+
doCheckMessageAfterFetch: void Function(Subject<Message>) doCheckMessageAfterFetch,
543+
) in [
544+
(
545+
eventName: 'ReactionEvent',
546+
mkEvent: (message) {
547+
final reaction = eg.unicodeEmojiReaction;
548+
return ReactionEvent(
549+
id: 1,
550+
op: ReactionOp.add,
551+
emojiName: reaction.emojiName,
552+
emojiCode: reaction.emojiCode,
553+
reactionType: reaction.reactionType,
554+
userId: reaction.userId,
555+
messageId: message.id,
556+
);
557+
},
558+
doCheckMessageAfterFetch:
559+
(messageSubject) => messageSubject.reactions.isNotNull().total.equals(1),
560+
),
561+
// TODO(#455) message edits; message flags
562+
]) {
563+
test('$eventName is applied even when message not in any msglists', () async {
564+
final stream = eg.stream();
565+
store = eg.store();
566+
await store.addStream(stream);
567+
await store.addSubscription(eg.subscription(stream));
568+
connection = store.connection as FakeApiConnection;
569+
570+
final message = eg.streamMessage(stream: stream);
571+
572+
await store.addMessage(message);
573+
await store.handleEvent(mkEvent(message));
574+
575+
final updatedMessage = store.messages[message.id]!;
576+
577+
// init msglist *after* event was handled
578+
model = MessageListView.init(store: store, narrow: const CombinedFeedNarrow());
579+
checkInvariants(model);
580+
581+
connection.prepare(json:
582+
newestResult(foundOldest: false, messages: [updatedMessage]).toJson());
583+
await model.fetchInitial();
584+
checkInvariants(model);
585+
doCheckMessageAfterFetch(
586+
check(model).messages.single
587+
..identicalTo(updatedMessage)
588+
);
589+
});
590+
}
591+
});
592+
457593
test('reassemble', () async {
458594
final stream = eg.stream();
459595
await prepare(narrow: StreamNarrow(stream.streamId));

0 commit comments

Comments
 (0)