Skip to content

Commit e431a00

Browse files
committed
autocomplete: Sort user-mention autocomplete results
The @-mention autocomplete shows relevant results in the following order of priority: * Recent conversation users * Recent DM users * Alphabetical order Fixes: zulip#228
1 parent fcd74e6 commit e431a00

6 files changed

+310
-5
lines changed

lib/model/algorithms.dart

+41
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,47 @@
11

22
import 'package:collection/collection.dart';
33

4+
/// The index of [value] in [sortedList].
5+
///
6+
/// Uses binary search to find the index, so the list must be sorted,
7+
/// otherwise the result is unpredictable.
8+
///
9+
/// If [value] is found, returns its index (0-based).
10+
///
11+
/// ```dart
12+
/// final somePrimes = [11, 13, 17, 23, 27];
13+
/// final index = possibleIndexIn(somePrimes, 17); // 2
14+
/// ```
15+
/// If [value] is not found, returns the possible index (1-based)
16+
/// with a negative sign, where if the [value] is inserted,
17+
/// the order of the list is maintained.
18+
///
19+
/// ```dart
20+
/// final somePrimes = [11, 13, 17, 23, 27];
21+
/// int index = possibleIndexIn(somePrimes, 7); // -1
22+
/// index = possibleIndexIn(somePrimes, 19); // -4
23+
/// ```
24+
///
25+
/// Note: Using a negative 1-based index will differentiate between the case where
26+
/// the element is found at index 0 and the case where the element is not
27+
/// found, but should be placed at the very start of the list (index -1).
28+
int possibleIndexIn<E extends Comparable>(List<E> sortedList, E value) {
29+
int min = 0;
30+
int max = sortedList.length - 1;
31+
while (min <= max) {
32+
final mid = min + ((max - min) >> 1);
33+
final midElement = sortedList[mid];
34+
final comp = midElement.compareTo(value);
35+
if (comp == 0) return mid;
36+
if (comp < 0) {
37+
min = mid + 1;
38+
} else {
39+
max = mid - 1;
40+
}
41+
}
42+
return -(min + 1);
43+
}
44+
445
/// Returns the index in [sortedList] of an element matching the given [key],
546
/// if there is one.
647
///

lib/model/autocomplete.dart

+96-4
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ class MentionAutocompleteView extends ChangeNotifier {
183183
@override
184184
void dispose() {
185185
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
186+
_sortedUsers = null;
186187
// We cancel in-progress computations by checking [hasListeners] between tasks.
187188
// After [super.dispose] is called, [hasListeners] returns false.
188189
// TODO test that logic (may involve detecting an unhandled Future rejection; how?)
@@ -206,6 +207,7 @@ class MentionAutocompleteView extends ChangeNotifier {
206207
/// Called in particular when we get a [RealmUserEvent].
207208
void refreshStaleUserResults() {
208209
if (_query != null) {
210+
_sortedUsers = null;
209211
_startSearch(_query!);
210212
}
211213
}
@@ -244,11 +246,89 @@ class MentionAutocompleteView extends ChangeNotifier {
244246
notifyListeners();
245247
}
246248

249+
List<User>? _sortedUsers;
250+
251+
int compareByRelevance({
252+
required User userA,
253+
required User userB,
254+
required int? streamId,
255+
required String? topic,
256+
}) {
257+
// TODO(#234): give preference to "all", "everyone" or "stream".
258+
259+
// TODO(#618): give preference to subscribed users first.
260+
261+
if (streamId != null) {
262+
final conversationPrecedence = store.recentSenders.compareByRecency(
263+
userA: userA,
264+
userB: userB,
265+
streamId: streamId,
266+
topic: topic);
267+
if (conversationPrecedence != 0) {
268+
return conversationPrecedence;
269+
}
270+
}
271+
272+
final dmPrecedence = store.recentDmConversationsView.compareByDms(userA, userB);
273+
if (dmPrecedence != 0) {
274+
return dmPrecedence;
275+
}
276+
277+
if (!userA.isBot && userB.isBot) {
278+
return -1;
279+
} else if (userA.isBot && !userB.isBot) {
280+
return 1;
281+
}
282+
283+
final userAName = store.autocompleteViewManager.autocompleteDataCache
284+
.nameForUser(userA);
285+
final userBName = store.autocompleteViewManager.autocompleteDataCache
286+
.nameForUser(userB);
287+
return userAName.compareTo(userBName);
288+
}
289+
290+
List<User> sortByRelevance({
291+
required List<User> users,
292+
required Narrow narrow,
293+
}) {
294+
switch (narrow) {
295+
case StreamNarrow():
296+
users.sort((userA, userB) => compareByRelevance(
297+
userA: userA,
298+
userB: userB,
299+
streamId: narrow.streamId,
300+
topic: null));
301+
case TopicNarrow():
302+
users.sort((userA, userB) => compareByRelevance(
303+
userA: userA,
304+
userB: userB,
305+
streamId: narrow.streamId,
306+
topic: narrow.topic));
307+
case DmNarrow():
308+
users.sort((userA, userB) => compareByRelevance(
309+
userA: userA,
310+
userB: userB,
311+
streamId: null,
312+
topic: null));
313+
case AllMessagesNarrow():
314+
// do nothing in this case for now
315+
}
316+
return users;
317+
}
318+
319+
void _sortUsers() {
320+
final users = store.users.values.toList();
321+
_sortedUsers = sortByRelevance(users: users, narrow: narrow);
322+
}
323+
247324
Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
248325
final List<MentionAutocompleteResult> results = [];
249-
final Iterable<User> users = store.users.values;
250326

251-
final iterator = users.iterator;
327+
if (_sortedUsers == null) {
328+
_sortUsers();
329+
}
330+
331+
final iterator = _sortedUsers!.iterator;
252332
bool isDone = false;
253333
while (!isDone) {
254334
// CPU perf: End this task; enqueue a new one for resuming this work
@@ -270,7 +350,7 @@ class MentionAutocompleteView extends ChangeNotifier {
270350
}
271351
}
272352
}
273-
return results; // TODO(#228) sort for most relevant first
353+
return results;
274354
}
275355
}
276356

@@ -330,13 +410,25 @@ class MentionAutocompleteQuery {
330410
}
331411

332412
class AutocompleteDataCache {
413+
final Map<int, String> _namesByUser = {};
414+
415+
/// The lowercase `fullName` of [user].
416+
String nameForUser(User user) {
417+
return _namesByUser[user.userId] ??= user.fullName.toLowerCase();
418+
}
419+
333420
final Map<int, List<String>> _nameWordsByUser = {};
334421

335422
List<String> nameWordsForUser(User user) {
336-
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
423+
final nameWords = _nameWordsByUser[user.userId];
424+
if (nameWords != null) return nameWords;
425+
426+
final name = _namesByUser[user.userId] ??= user.fullName.toLowerCase();
427+
return _nameWordsByUser[user.userId] = name.split(' ');
337428
}
338429

339430
void invalidateUser(int userId) {
431+
_namesByUser.remove(userId);
340432
_nameWordsByUser.remove(userId);
341433
}
342434
}

lib/model/message_list.dart

+1
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
381381
for (final message in result.messages) {
382382
if (_messageVisible(message)) {
383383
_addMessage(message);
384+
store.recentSenders.processAsStreamMessage(message);
384385
}
385386
}
386387
_fetched = true;

lib/model/recent_dm_conversations.dart

+29-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ class RecentDmConversationsView extends ChangeNotifier {
3030
required this.map,
3131
required this.sorted,
3232
required this.selfUserId,
33-
});
33+
}) {
34+
_computeLatestMessagesWithUsers();
35+
}
3436

3537
/// The latest message ID in each conversation.
3638
final Map<DmNarrow, int> map;
@@ -40,6 +42,32 @@ class RecentDmConversationsView extends ChangeNotifier {
4042

4143
final int selfUserId;
4244

45+
/// Map from user ID to the latest message ID in any conversation with the user.
46+
///
47+
/// Both 1:1 and group DM conversations are considered.
48+
/// The self-user ID is excluded even if there is a self-DM conversation.
49+
///
50+
/// (The identified message was not necessarily sent by the identified user;
51+
/// it might have been sent by anyone in its conversation.)
52+
final Map<int, int> _latestMessageByRecipient = {};
53+
54+
void _computeLatestMessagesWithUsers() {
55+
for (final dmNarrow in sorted) {
56+
for (final userId in dmNarrow.otherRecipientIds) {
57+
if (!_latestMessageByRecipient.containsKey(userId)) {
58+
_latestMessageByRecipient[userId] = map[dmNarrow]!;
59+
}
60+
}
61+
}
62+
}
63+
64+
int compareByDms(User userA, User userB) {
65+
final aLatestMessageId = _latestMessageByRecipient[userA.userId] ?? -1;
66+
final bLatestMessageId = _latestMessageByRecipient[userB.userId] ?? -1;
67+
68+
return bLatestMessageId - aLatestMessageId;
69+
}
70+
4371
/// Insert the key at the proper place in [sorted].
4472
///
4573
/// Optimized, taking O(1) time, for the case where that place is the start,

lib/model/recent_senders.dart

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import '../api/model/model.dart';
2+
import 'algorithms.dart';
3+
4+
class MessageIdTracker {
5+
final List<int> _ids = [];
6+
7+
void add(int id) {
8+
if (_ids.isEmpty) {
9+
_ids.add(id);
10+
} else {
11+
int i = possibleIndexIn(_ids, id);
12+
if (i >= 0) { // the [id] already exists, so do not add it.
13+
return;
14+
}
15+
i = -i - 1; // change the index back to 0-based for readability.
16+
if (i == _ids.length) { // the [id] should be added to the end of the list.
17+
_ids.add(id);
18+
} else {
19+
_ids.insert(i, id);
20+
}
21+
}
22+
}
23+
24+
// TODO: remove
25+
26+
/// The maximum id in the tracker list.
27+
///
28+
/// Returns -1 if the tracker list is empty.
29+
int get maxId => _ids.isNotEmpty ? _ids.last : -1;
30+
31+
@override
32+
String toString() => _ids.toString();
33+
}
34+
35+
/// A data structure to keep track of stream and topic messages.
36+
///
37+
/// The owner should call [clear] in order to free resources.
38+
class RecentSenders {
39+
// streamSenders[streamId][senderId] = IdTracker
40+
final Map<int, Map<int, MessageIdTracker>> _streamSenders = {};
41+
42+
// topicSenders[streamId][topic][senderId] = IdTracker
43+
final Map<int, Map<String, Map<int, MessageIdTracker>>> _topicSenders = {};
44+
45+
void clear() {
46+
_streamSenders.clear();
47+
_topicSenders.clear();
48+
}
49+
50+
int _latestMessageIdOfSenderInStream({required int streamId, required int senderId}) {
51+
return _streamSenders[streamId]?[senderId]?.maxId ?? -1;
52+
}
53+
54+
int _latestMessageIdOfSenderInTopic({
55+
required int streamId,
56+
required String topic,
57+
required int senderId,
58+
}) {
59+
return _topicSenders[streamId]?[topic]?[senderId]?.maxId ?? -1;
60+
}
61+
62+
void _addMessageInStream({
63+
required int streamId,
64+
required int senderId,
65+
required messageId,
66+
}) {
67+
final senderMap = _streamSenders[streamId] ?? {};
68+
final idTracker = senderMap[senderId] ?? MessageIdTracker();
69+
_streamSenders[streamId] = senderMap;
70+
senderMap[senderId] = idTracker;
71+
idTracker.add(messageId);
72+
}
73+
74+
void _addMessageInTopic({
75+
required int streamId,
76+
required String topic,
77+
required int senderId,
78+
required messageId,
79+
}) {
80+
final topicMap = _topicSenders[streamId] ?? {};
81+
final senderMap = topicMap[topic] ?? {};
82+
final idTracker = senderMap[senderId] ?? MessageIdTracker();
83+
_topicSenders[streamId] = topicMap;
84+
topicMap[topic] = senderMap;
85+
senderMap[senderId] = idTracker;
86+
idTracker.add(messageId);
87+
}
88+
89+
/// Extracts and keeps track of the necessary data from a [message] only
90+
/// if it is a stream message.
91+
void processAsStreamMessage(Message message) {
92+
if (message is! StreamMessage) {
93+
return;
94+
}
95+
96+
final streamId = message.streamId;
97+
final topic = message.subject;
98+
final senderId = message.senderId;
99+
final messageId = message.id;
100+
101+
_addMessageInStream(
102+
streamId: streamId, senderId: senderId, messageId: messageId);
103+
_addMessageInTopic(
104+
streamId: streamId,
105+
topic: topic,
106+
senderId: senderId,
107+
messageId: messageId);
108+
}
109+
110+
// TODO: removeMessageInTopic
111+
112+
int compareByRecency({
113+
required User userA,
114+
required User userB,
115+
required int streamId,
116+
required String? topic,
117+
}) {
118+
int aMessageId, bMessageId;
119+
120+
if (topic != null) {
121+
aMessageId = _latestMessageIdOfSenderInTopic(
122+
streamId: streamId, topic: topic, senderId: userA.userId);
123+
bMessageId = _latestMessageIdOfSenderInTopic(
124+
streamId: streamId, topic: topic, senderId: userB.userId);
125+
126+
if (aMessageId != bMessageId) {
127+
return bMessageId - aMessageId;
128+
}
129+
}
130+
131+
aMessageId =
132+
_latestMessageIdOfSenderInStream(streamId: streamId, senderId: userA.userId);
133+
bMessageId =
134+
_latestMessageIdOfSenderInStream(streamId: streamId, senderId: userB.userId);
135+
136+
return bMessageId - aMessageId;
137+
}
138+
}

0 commit comments

Comments
 (0)