Skip to content

Commit 5508672

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 5508672

File tree

5 files changed

+318
-4
lines changed

5 files changed

+318
-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

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
int low = 0;
35+
int high = length - 1;
36+
37+
while (high >= low) {
38+
int mid = (low + high) ~/ 2;
39+
if (element == this[mid]) {
40+
return mid;
41+
} else if (element < this[mid]) {
42+
high = mid - 1;
43+
} else {
44+
low = mid + 1;
45+
}
46+
}
47+
return -low - 1;
48+
}
49+
}
50+
51+
/// A data structure to keep track of message ids.
52+
class IdTracker {
53+
final List<int> _ids = [];
54+
55+
void add(int id) {
56+
if (_ids.isEmpty) {
57+
_ids.add(id);
58+
} else {
59+
int i = _ids.possibleIndexOf(id);
60+
if (i >= 0) { // the [id] already exists, so do not add it.
61+
return;
62+
}
63+
i = -i - 1; // change the index back to 0-based for readability.
64+
if (i == _ids.length) { // the [id] should be added to the end of the list.
65+
_ids.add(id);
66+
} else {
67+
_ids.insert(i, id);
68+
}
69+
}
70+
}
71+
72+
// TODO: remove
73+
74+
/// The maximum id in the tracker list.
75+
///
76+
/// Returns -1 if the tracker list is empty.
77+
int get maxId => _ids.isNotEmpty ? _ids.last : -1;
78+
79+
@override
80+
String toString() {
81+
return _ids.toString();
82+
}
83+
}
84+
85+
/// A data structure to keep track of stream and topic messages.
86+
///
87+
/// The owner should call [clear] in order to free resources.
88+
class RecentSenders {
89+
// streamSenders[streamId][senderId] = IdTracker
90+
final Map<int, Map<int, IdTracker>> _streamSenders = {};
91+
92+
// topicSenders[streamId][topic][senderId] = IdTracker
93+
final Map<int, Map<String, Map<int, IdTracker>>> _topicSenders = {};
94+
95+
void clear() {
96+
_streamSenders.clear();
97+
_topicSenders.clear();
98+
}
99+
100+
int maxIdForStreamSender({required int streamId, required int senderId}) {
101+
return _streamSenders[streamId]?[senderId]?.maxId ?? -1;
102+
}
103+
104+
int maxIdForStreamTopicSender({
105+
required int streamId,
106+
required String topic,
107+
required int senderId,
108+
}) {
109+
topic = topic.toLowerCase();
110+
return _topicSenders[streamId]?[topic]?[senderId]?.maxId ?? -1;
111+
}
112+
113+
void addStreamMessage({
114+
required int streamId,
115+
required int senderId,
116+
required messageId,
117+
}) {
118+
final senderMap = _streamSenders[streamId] ?? {};
119+
final idTracker = senderMap[senderId] ?? IdTracker();
120+
_streamSenders[streamId] = senderMap;
121+
senderMap[senderId] = idTracker;
122+
idTracker.add(messageId);
123+
}
124+
125+
void addTopicMessage({
126+
required int streamId,
127+
required String topic,
128+
required int senderId,
129+
required messageId,
130+
}) {
131+
topic = topic.toLowerCase();
132+
final topicMap = _topicSenders[streamId] ?? {};
133+
final senderMap = topicMap[topic] ?? {};
134+
final idTracker = senderMap[senderId] ?? IdTracker();
135+
_topicSenders[streamId] = topicMap;
136+
topicMap[topic] = senderMap;
137+
senderMap[senderId] = idTracker;
138+
idTracker.add(messageId);
139+
}
140+
141+
void processStreamMessage(Message message) {
142+
if (message is! StreamMessage) {
143+
return;
144+
}
145+
146+
final streamId = message.streamId;
147+
final topic = message.subject;
148+
final senderId = message.senderId;
149+
final messageId = message.id;
150+
151+
addStreamMessage(
152+
streamId: streamId, senderId: senderId, messageId: messageId);
153+
addTopicMessage(
154+
streamId: streamId,
155+
topic: topic,
156+
senderId: senderId,
157+
messageId: messageId);
158+
}
159+
160+
// TODO: removeTopicMessage
161+
162+
int compareByRecency({
163+
required User userA,
164+
required User userB,
165+
required int streamId,
166+
// TODO(#493): may turn this into a non-nullable string.
167+
required String? topic,
168+
}) {
169+
int aMessageId, bMessageId;
170+
171+
if (topic != null) {
172+
aMessageId = maxIdForStreamTopicSender(
173+
streamId: streamId, topic: topic, senderId: userA.userId);
174+
bMessageId = maxIdForStreamTopicSender(
175+
streamId: streamId, topic: topic, senderId: userB.userId);
176+
177+
if (aMessageId != bMessageId) {
178+
return bMessageId - aMessageId;
179+
}
180+
}
181+
182+
aMessageId =
183+
maxIdForStreamSender(streamId: streamId, senderId: userA.userId);
184+
bMessageId =
185+
maxIdForStreamSender(streamId: streamId, senderId: userB.userId);
186+
187+
return bMessageId - aMessageId;
188+
}
189+
}

0 commit comments

Comments
 (0)