Skip to content

Commit 804287b

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: #228
1 parent ba74abd commit 804287b

File tree

5 files changed

+319
-4
lines changed

5 files changed

+319
-4
lines changed

lib/model/autocomplete.dart

+102-3
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,102 @@ class MentionAutocompleteView extends ChangeNotifier {
244246
notifyListeners();
245247
}
246248

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

251-
final iterator = users.iterator;
340+
if (_sortedUsers == null) {
341+
_sortUsers();
342+
}
343+
344+
final iterator = _sortedUsers!.iterator;
252345
bool isDone = false;
253346
while (!isDone) {
254347
// CPU perf: End this task; enqueue a new one for resuming this work
@@ -270,7 +363,7 @@ class MentionAutocompleteView extends ChangeNotifier {
270363
}
271364
}
272365
}
273-
return results; // TODO(#228) sort for most relevant first
366+
return results;
274367
}
275368
}
276369

@@ -339,6 +432,12 @@ class AutocompleteDataCache {
339432
void invalidateUser(int userId) {
340433
_nameWordsByUser.remove(userId);
341434
}
435+
436+
final Map<String, String> _namesLowercased = {};
437+
438+
String nameLowercased(String name) {
439+
return _namesLowercased[name] ??= name.toLowerCase();
440+
}
342441
}
343442

344443
sealed class MentionAutocompleteResult {}

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.processStreamMessage(message);
384385
}
385386
}
386387
_fetched = true;

lib/model/recent_dm_conversations.dart

+21-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+
_computeDmRecencyData();
35+
}
3436

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

4143
final int selfUserId;
4244

45+
// The ID of the latest messages exchanged with other users.
46+
final Map<int, int> _dmRecencyData = {};
47+
48+
/// The ID of the latest message exchanged with this user.
49+
///
50+
/// Returns -1 if there has been no DM message exchanged ever.
51+
int getRecencyIndex(final int userId) => _dmRecencyData[userId] ?? -1;
52+
53+
void _computeDmRecencyData() {
54+
for (final dmNarrow in sorted) {
55+
for (final userId in dmNarrow.otherRecipientIds) {
56+
if (!_dmRecencyData.containsKey(userId)) {
57+
_dmRecencyData[userId] = map[dmNarrow]!;
58+
}
59+
}
60+
}
61+
}
62+
4363
/// Insert the key at the proper place in [sorted].
4464
///
4565
/// Optimized, taking O(1) time, for the case where that place is the start,

lib/model/recent_senders.dart

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import 'dart:math' as math;
2+
3+
import '../api/model/model.dart';
4+
5+
extension Max<T extends num> on Iterable<T> {
6+
/// Finds the maximum number in an [Iterable].
7+
///
8+
/// Returns null if the [Iterable] is empty.
9+
T? max() => isNotEmpty ? reduce(math.max) : null;
10+
}
11+
12+
extension PossibleIndex<T extends num> on List<T> {
13+
/// The index of [element] in this sorted list.
14+
///
15+
/// Uses binary search to find the index, so the list must be sorted,
16+
/// otherwise the result is unpredictable.
17+
///
18+
/// If [element] is found, returns its index (0-based).
19+
///
20+
/// ```dart
21+
/// final somePrimes = [11, 13, 17, 23, 27, 29];
22+
/// final index = somePrimes.possibleIndexOf(17); // 2
23+
/// ```
24+
/// If [element] is not found, returns the possible index (1-based)
25+
/// with a negative sign, where if the [element] is inserted,
26+
/// the order of the list is maintained.
27+
///
28+
/// ```dart
29+
/// final somePrimes = [11, 13, 17, 23, 27, 29];
30+
/// int index = somePrimes.possibleIndexOf(7); // -1
31+
/// index = somePrimes.possibleIndexOf(19); // -4
32+
/// ```
33+
int possibleIndexOf(T element) {
34+
indexOf(element);
35+
int low = 0;
36+
int high = length - 1;
37+
38+
while (high >= low) {
39+
int mid = (low + high) ~/ 2;
40+
if (element == this[mid]) {
41+
return mid;
42+
} else if (element < this[mid]) {
43+
high = mid - 1;
44+
} else {
45+
low = mid + 1;
46+
}
47+
}
48+
return -low - 1;
49+
}
50+
}
51+
52+
/// A data structure to keep track of message ids.
53+
class IdTracker {
54+
final List<int> _ids = [];
55+
56+
void add(int id) {
57+
if (_ids.isEmpty) {
58+
_ids.add(id);
59+
} else {
60+
int i = _ids.possibleIndexOf(id);
61+
if (i >= 0) { // the [id] already exists, so do not add it.
62+
return;
63+
}
64+
i = -i - 1; // change the index back to 0-based for readability.
65+
if (i == _ids.length) { // the [id] should be added to the end of the list.
66+
_ids.add(id);
67+
} else {
68+
_ids.insert(i, id);
69+
}
70+
}
71+
}
72+
73+
// TODO: remove
74+
75+
/// The maximum id in the tracker list.
76+
///
77+
/// Returns -1 if the tracker list is empty.
78+
int get maxId => _ids.isNotEmpty ? _ids.last : -1;
79+
80+
@override
81+
String toString() {
82+
return _ids.toString();
83+
}
84+
}
85+
86+
/// A data structure to keep track of stream and topic messages.
87+
///
88+
/// The owner should call [clear] in order to free resources.
89+
class RecentSenders {
90+
// streamSenders[streamId][senderId] = IdTracker
91+
final Map<int, Map<int, IdTracker>> _streamSenders = {};
92+
93+
// topicSenders[streamId][topic][senderId] = IdTracker
94+
final Map<int, Map<String, Map<int, IdTracker>>> _topicSenders = {};
95+
96+
void clear() {
97+
_streamSenders.clear();
98+
_topicSenders.clear();
99+
}
100+
101+
int maxIdForStreamSender({required int streamId, required int senderId}) {
102+
return _streamSenders[streamId]?[senderId]?.maxId ?? -1;
103+
}
104+
105+
int maxIdForStreamTopicSender({
106+
required int streamId,
107+
required String topic,
108+
required int senderId,
109+
}) {
110+
topic = topic.toLowerCase();
111+
return _topicSenders[streamId]?[topic]?[senderId]?.maxId ?? -1;
112+
}
113+
114+
void addStreamMessage({
115+
required int streamId,
116+
required int senderId,
117+
required messageId,
118+
}) {
119+
final senderMap = _streamSenders[streamId] ?? {};
120+
final idTracker = senderMap[senderId] ?? IdTracker();
121+
_streamSenders[streamId] = senderMap;
122+
senderMap[senderId] = idTracker;
123+
idTracker.add(messageId);
124+
}
125+
126+
void addTopicMessage({
127+
required int streamId,
128+
required String topic,
129+
required int senderId,
130+
required messageId,
131+
}) {
132+
topic = topic.toLowerCase();
133+
final topicMap = _topicSenders[streamId] ?? {};
134+
final senderMap = topicMap[topic] ?? {};
135+
final idTracker = senderMap[senderId] ?? IdTracker();
136+
_topicSenders[streamId] = topicMap;
137+
topicMap[topic] = senderMap;
138+
senderMap[senderId] = idTracker;
139+
idTracker.add(messageId);
140+
}
141+
142+
void processStreamMessage(Message message) {
143+
if (message is! StreamMessage) {
144+
return;
145+
}
146+
147+
final streamId = message.streamId;
148+
final topic = message.subject;
149+
final senderId = message.senderId;
150+
final messageId = message.id;
151+
152+
addStreamMessage(
153+
streamId: streamId, senderId: senderId, messageId: messageId);
154+
addTopicMessage(
155+
streamId: streamId,
156+
topic: topic,
157+
senderId: senderId,
158+
messageId: messageId);
159+
}
160+
161+
// TODO: removeTopicMessage
162+
163+
int compareByRecency({
164+
required User userA,
165+
required User userB,
166+
required int streamId,
167+
// TODO(#493): may turn this into a non-nullable string.
168+
required String? topic,
169+
}) {
170+
int aMessageId, bMessageId;
171+
172+
if (topic != null) {
173+
aMessageId = maxIdForStreamTopicSender(
174+
streamId: streamId, topic: topic, senderId: userA.userId);
175+
bMessageId = maxIdForStreamTopicSender(
176+
streamId: streamId, topic: topic, senderId: userB.userId);
177+
178+
if (aMessageId != bMessageId) {
179+
return bMessageId - aMessageId;
180+
}
181+
}
182+
183+
aMessageId =
184+
maxIdForStreamSender(streamId: streamId, senderId: userA.userId);
185+
bMessageId =
186+
maxIdForStreamSender(streamId: streamId, senderId: userB.userId);
187+
188+
return bMessageId - aMessageId;
189+
}
190+
}

0 commit comments

Comments
 (0)