Skip to content

Commit adf3690

Browse files
committed
recent_senders: Handle moved messages
This is similar to how we add support to handling moves for unreads (commit 34e6201), also with optimizations to avoid making unnecessary copies of message IDs when the entire conversation is moved (e.g. resolve/unresolve topic). An alternative approach to this that we didn't take is extracting helpers from handleMessages and handleDeleteMessageEvent and combining the two to handle moves, like web does (https://github.com/zulip/zulip/blob/bd04a30b/web/src/recent_senders.ts#L165-L190), but creating a dedicated helper makes it more sraightforward to optimize for our performance need. Fixes: zulip#901
1 parent bb200ac commit adf3690

File tree

3 files changed

+162
-0
lines changed

3 files changed

+162
-0
lines changed

lib/model/recent_senders.dart

+39
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,45 @@ class RecentSenders {
6868
[senderId] ??= MessageIdTracker()).add(messageId);
6969
}
7070

71+
void handleUpdateMessageEvent(UpdateMessageEvent event, Map<int, Message> cachedMessages) {
72+
if (event.moveData == null) {
73+
// No move; nothing to do.
74+
return;
75+
}
76+
final UpdateMessageMoveData(
77+
:origStreamId, :newStreamId, :origTopic, :newTopic) = event.moveData!;
78+
79+
final messagesBySender = _groupStreamMessageIdsBySender(event.messageIds, cachedMessages);
80+
final sendersInChannel = streamSenders[origStreamId];
81+
final topicsInChannel = topicSenders[origStreamId];
82+
final sendersInTopic = topicsInChannel?[origTopic];
83+
for (final MapEntry(key: senderId, value: messages) in messagesBySender.entries) {
84+
if (newStreamId != origStreamId) {
85+
final streamTracker = sendersInChannel?[senderId];
86+
final movedMessagesInStreamTracker = streamTracker?.popAll(messages);
87+
if (streamTracker?.maxId == null) sendersInChannel?.remove(senderId);
88+
if (movedMessagesInStreamTracker != null) {
89+
((streamSenders[newStreamId] ??= {})
90+
[senderId] ??= MessageIdTracker()).addAll(movedMessagesInStreamTracker);
91+
}
92+
}
93+
94+
// This is an invariant of [UpdateMessageMoveData].
95+
assert(newStreamId != origStreamId || newTopic != origTopic);
96+
97+
final topicTracker = sendersInTopic?[senderId];
98+
final movedMessagesInTopicTracker = topicTracker?.popAll(messages);
99+
if (topicTracker?.maxId == null) sendersInTopic?.remove(senderId);
100+
if (movedMessagesInTopicTracker != null) {
101+
(((topicSenders[newStreamId] ??= {})[newTopic] ??= {})
102+
[senderId] ??= MessageIdTracker()).addAll(movedMessagesInTopicTracker);
103+
}
104+
}
105+
if (sendersInChannel?.isEmpty ?? false) streamSenders.remove(origStreamId);
106+
if (sendersInTopic?.isEmpty ?? false) topicsInChannel?.remove(origTopic);
107+
if (topicsInChannel?.isEmpty ?? false) topicSenders.remove(origStreamId);
108+
}
109+
71110
void handleDeleteMessageEvent(DeleteMessageEvent event, Map<int, Message> cachedMessages) {
72111
if (event.messageType != MessageType.stream) return;
73112

lib/model/store.dart

+1
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel
754754

755755
case UpdateMessageEvent():
756756
assert(debugLog("server event: update_message ${event.messageId}"));
757+
recentSenders.handleUpdateMessageEvent(event, messages);
757758
_messages.handleUpdateMessageEvent(event);
758759
unreads.handleUpdateMessageEvent(event);
759760

test/model/recent_senders_test.dart

+122
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import 'package:checks/checks.dart';
22
import 'package:flutter_test/flutter_test.dart';
33
import 'package:zulip/api/model/model.dart';
44
import 'package:zulip/model/recent_senders.dart';
5+
import 'package:zulip/model/store.dart';
56
import '../example_data.dart' as eg;
7+
import 'test_store.dart';
68

79
/// [messages] should be sorted by [id] ascending.
810
void checkMatchesMessages(RecentSenders model, List<Message> messages) {
@@ -142,6 +144,126 @@ void main() {
142144
});
143145
});
144146

147+
group('RecentSenders.handleUpdateMessageUpdate', () {
148+
late PerAccountStore store;
149+
late RecentSenders model;
150+
151+
final origChannel = eg.stream(); final newChannel = eg.stream();
152+
final origTopic = 'origTopic'; final newTopic = 'newTopic';
153+
final userX = eg.user(); final userY = eg.user();
154+
155+
Future<void> prepare(List<Message> messages) async {
156+
store = eg.store();
157+
await store.addMessages(messages);
158+
await store.addStreams([origChannel, newChannel]);
159+
await store.addUsers([userX, userY]);
160+
model = store.recentSenders;
161+
}
162+
163+
List<StreamMessage> copyMessagesWith(Iterable<StreamMessage> messages, {
164+
ZulipStream? newChannel,
165+
String? newTopic,
166+
}) {
167+
assert(newChannel != null || newTopic != null);
168+
return messages.map((message) => StreamMessage.fromJson(
169+
message.toJson()
170+
..['stream_id'] = newChannel?.streamId ?? message.streamId
171+
// See [StreamMessage.displayRecipient] for why this is needed.
172+
..['display_recipient'] = newChannel?.name ?? message.displayRecipient!
173+
174+
..['subject'] = newTopic ?? message.topic
175+
)).toList();
176+
}
177+
178+
test('move a conversation exactly', () async {
179+
final messages = List.generate(10, (i) => eg.streamMessage(
180+
stream: origChannel, topic: origTopic, sender: userX));
181+
await prepare(messages);
182+
final streamSenderIdsBefore = model.streamSenders
183+
[origChannel.streamId]![userX.userId]!.ids;
184+
final topicSenderIdsBefore = model.topicSenders
185+
[origChannel.streamId]![TopicName(origTopic)]![userX.userId]!.ids;
186+
187+
await store.handleEvent(eg.updateMessageEventMoveFrom(
188+
origMessages: messages,
189+
newStreamId: newChannel.streamId,
190+
newTopicStr: newTopic));
191+
checkMatchesMessages(model, copyMessagesWith(
192+
messages, newChannel: newChannel, newTopic: newTopic));
193+
// Check we avoided creating a new list for the moved message IDs.
194+
final streamSenderIdsAfter = model.streamSenders
195+
[newChannel.streamId]![userX.userId]!.ids;
196+
final topicSenderIdsAfter = model.topicSenders
197+
[newChannel.streamId]![TopicName(newTopic)]![userX.userId]!.ids;
198+
check(streamSenderIdsBefore).identicalTo(streamSenderIdsAfter);
199+
check(topicSenderIdsBefore).identicalTo(topicSenderIdsAfter);
200+
});
201+
202+
test('move a conversation partially to a different channel', () async {
203+
final messages = List.generate(10, (i) => eg.streamMessage(
204+
stream: origChannel, topic: origTopic));
205+
final movedMessages = messages.take(5).toList();
206+
final otherMessages = messages.skip(5);
207+
await prepare(messages);
208+
209+
await store.handleEvent(eg.updateMessageEventMoveFrom(
210+
origMessages: movedMessages,
211+
newStreamId: newChannel.streamId));
212+
checkMatchesMessages(model, [
213+
...copyMessagesWith(movedMessages, newChannel: newChannel),
214+
...otherMessages,
215+
]);
216+
});
217+
218+
test('move a conversation partially to a different topic, within the same channel', () async {
219+
final messages = List.generate(10, (i) => eg.streamMessage(
220+
stream: origChannel, topic: origTopic, sender: userX));
221+
final movedMessages = messages.take(5).toList();
222+
final otherMessages = messages.skip(5);
223+
await prepare(messages);
224+
final streamSenderIdsBefore = model.streamSenders
225+
[origChannel.streamId]![userX.userId]!.ids;
226+
227+
await store.handleEvent(eg.updateMessageEventMoveFrom(
228+
origMessages: movedMessages,
229+
newTopicStr: newTopic));
230+
checkMatchesMessages(model, [
231+
...copyMessagesWith(movedMessages, newTopic: newTopic),
232+
...otherMessages,
233+
]);
234+
235+
// Check that we did not touch stream message IDs tracker
236+
// when there wasn't a stream move.
237+
final streamSenderIdsAfter = model.streamSenders
238+
[origChannel.streamId]![userX.userId]!.ids;
239+
check(streamSenderIdsBefore).identicalTo(streamSenderIdsAfter);
240+
});
241+
242+
test('move a conversation with multiple senders', () async {
243+
final messages = [
244+
eg.streamMessage(stream: origChannel, topic: origTopic, sender: userX),
245+
eg.streamMessage(stream: origChannel, topic: origTopic, sender: userX),
246+
eg.streamMessage(stream: origChannel, topic: origTopic, sender: userY),
247+
];
248+
await prepare(messages);
249+
250+
await store.handleEvent(eg.updateMessageEventMoveFrom(
251+
origMessages: messages,
252+
newStreamId: newChannel.streamId));
253+
checkMatchesMessages(model, copyMessagesWith(
254+
messages, newChannel: newChannel));
255+
});
256+
257+
test('message edit update without move', () async {
258+
final messages = List.generate(10, (i) => eg.streamMessage(
259+
stream: origChannel, topic: origTopic));
260+
await prepare(messages);
261+
262+
await store.handleEvent(eg.updateMessageEditEvent(messages[0]));
263+
checkMatchesMessages(model, messages);
264+
});
265+
});
266+
145267
test('RecentSenders.handleDeleteMessageEvent', () {
146268
final model = RecentSenders();
147269
final stream = eg.stream();

0 commit comments

Comments
 (0)