Skip to content

autocomplete: Sort user-mention autocomplete results #608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 118 additions & 12 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,26 +125,37 @@ class AutocompleteViewManager {
assert(removed);
}

void handleRealmUserAddEvent(RealmUserAddEvent event) {
/// Recomputes the autocomplete results for users.
///
/// Calls [MentionAutocompleteView.refreshStaleUserResults] for all that are registered.
void _refreshStaleUserResults() {
for (final view in _mentionAutocompleteViews) {
view.refreshStaleUserResults();
}
}

void handleRealmUserAddEvent(RealmUserAddEvent event) {
_refreshStaleUserResults();
}

void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) {
for (final view in _mentionAutocompleteViews) {
view.refreshStaleUserResults();
}
_refreshStaleUserResults();
autocompleteDataCache.invalidateUser(event.userId);
}

void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) {
for (final view in _mentionAutocompleteViews) {
view.refreshStaleUserResults();
}
_refreshStaleUserResults();
autocompleteDataCache.invalidateUser(event.userId);
}

void handleMessageEvent(MessageEvent event) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if it is necessary to pass the event parameter as it is not used for now. I just followed the code for handleRealmUserAddEvent(RealmUserAddEvent event) method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, following that pattern is good. This is also the pattern you'll find at the call site in PerAccountStore.handleEvent.

_refreshStaleUserResults();
}

void handleOlderMessages() {
_refreshStaleUserResults();
}

/// Called when the app is reassembled during debugging, e.g. for hot reload.
///
/// Calls [MentionAutocompleteView.reassemble] for all that are registered.
Expand Down Expand Up @@ -190,6 +201,7 @@ class MentionAutocompleteView extends ChangeNotifier {
@override
void dispose() {
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
_sortedUsers = null;
// We cancel in-progress computations by checking [hasListeners] between tasks.
// After [super.dispose] is called, [hasListeners] returns false.
// TODO test that logic (may involve detecting an unhandled Future rejection; how?)
Expand All @@ -213,6 +225,7 @@ class MentionAutocompleteView extends ChangeNotifier {
/// Called in particular when we get a [RealmUserEvent].
void refreshStaleUserResults() {
if (_query != null) {
_sortedUsers = null;
_startSearch(_query!);
}
}
Expand All @@ -222,6 +235,7 @@ class MentionAutocompleteView extends ChangeNotifier {
/// This will redo the search from scratch for the current query, if any.
void reassemble() {
if (_query != null) {
_sortedUsers = null;
_startSearch(_query!);
}
}
Expand Down Expand Up @@ -251,22 +265,106 @@ class MentionAutocompleteView extends ChangeNotifier {
notifyListeners();
}

List<User>? _sortedUsers;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on #608 (review) :

a series of commits that […] do any needed prep for the concept of _sortedUsers, then starts comparing […]

A further improvement to the sequence of commits would be to pull out the changes that set up and maintain this _sortedUsers field, into an NFC commit where no actual sorting happens (i.e. where sortByRelevance doesn't do anything). Then the specific comparisons, even the first one, each come in separate later commits.

One reason that's helpful is that it makes it easier to reorder the commits that add comparisons, for example in order to merge some that are ready while we're still working on others.

Another is that the rest of these mechanics, outside the particular comparison itself, have some pretty subtle aspects. That makes it useful to have them in an isolated commit of their own, the better to read and understand them.


int compareByRelevance({
required User userA,
required User userB,
required int? streamId,
required String? topic,
}) {
// TODO(#234): give preference to "all", "everyone" or "stream".

// TODO(#618): give preference to subscribed users first.

if (streamId != null) {
final conversationPrecedence = store.recentSenders.compareByRecency(
userA,
userB,
streamId: streamId,
topic: topic);
if (conversationPrecedence != 0) {
return conversationPrecedence;
}
}
final dmPrecedence = store.recentDmConversationsView.compareByDms(userA, userB);
if (dmPrecedence != 0) {
return dmPrecedence;
}

if (!userA.isBot && userB.isBot) {
return -1;
} else if (userA.isBot && !userB.isBot) {
return 1;
}

final userAName = store.autocompleteViewManager.autocompleteDataCache
.normalizedNameForUser(userA);
final userBName = store.autocompleteViewManager.autocompleteDataCache
.normalizedNameForUser(userB);
return userAName.compareTo(userBName);
}

List<User> sortByRelevance({
required List<User> users,
required Narrow narrow,
}) {
switch (narrow) {
case StreamNarrow():
users.sort((userA, userB) => compareByRelevance(
userA: userA,
userB: userB,
streamId: narrow.streamId,
topic: null));
case TopicNarrow():
users.sort((userA, userB) => compareByRelevance(
userA: userA,
userB: userB,
streamId: narrow.streamId,
topic: narrow.topic));
case DmNarrow():
users.sort((userA, userB) => compareByRelevance(
userA: userA,
userB: userB,
streamId: null,
topic: null));
case AllMessagesNarrow():
// do nothing in this case for now
}
return users;
}

void _sortUsers() {
final users = store.users.values.toList();
_sortedUsers = sortByRelevance(users: users, narrow: narrow);
}

Future<List<MentionAutocompleteResult>?> _computeResults(MentionAutocompleteQuery query) async {
final List<MentionAutocompleteResult> results = [];
final Iterable<User> users = store.users.values;

final iterator = users.iterator;
if (_sortedUsers == null) {
_sortUsers();
}

final sortedUsers = _sortedUsers!;
final iterator = sortedUsers.iterator;
bool isDone = false;
while (!isDone) {
// CPU perf: End this task; enqueue a new one for resuming this work
await Future(() {});

if (_sortedUsers != sortedUsers) {
// The list of users this loop has been working from has become stale.
// Abort so _startSearch can retry with the new list.
throw ConcurrentModificationError();
}

if (query != _query || !hasListeners) { // false if [dispose] has been called.
return null;
}

for (int i = 0; i < 1000; i++) {
if (!iterator.moveNext()) { // Can throw ConcurrentModificationError
if (!iterator.moveNext()) {
isDone = true;
break;
}
Expand All @@ -277,7 +375,7 @@ class MentionAutocompleteView extends ChangeNotifier {
}
}
}
return results; // TODO(#228) sort for most relevant first
return results;
}
}

Expand Down Expand Up @@ -337,13 +435,21 @@ class MentionAutocompleteQuery {
}

class AutocompleteDataCache {
final Map<int, String> _normalizedNamesByUser = {};

/// The lowercase `fullName` of [user].
String normalizedNameForUser(User user) {
return _normalizedNamesByUser[user.userId] ??= user.fullName.toLowerCase();
}

final Map<int, List<String>> _nameWordsByUser = {};

List<String> nameWordsForUser(User user) {
return _nameWordsByUser[user.userId] ??= user.fullName.toLowerCase().split(' ');
return _nameWordsByUser[user.userId] ??= normalizedNameForUser(user).split(' ');
}

void invalidateUser(int userId) {
_normalizedNamesByUser.remove(userId);
_nameWordsByUser.remove(userId);
}
}
Expand Down
6 changes: 6 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
for (final message in result.messages) {
if (_messageVisible(message)) {
_addMessage(message);
store.recentSenders.handleMessage(message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ideal version of this would go on top of a central message store, #648, so that MessageListView wouldn't have to know individually about all the miscellaneous data structures that care about a summary of messages.

This PR doesn't need to block on that one; for now, it's fine to add these calls in a more ad-hoc way like this.

The current version of this PR doesn't call this RecentSenders.handleMessage method in enough cases, though. Compare which messages get passed to reconcileMessages in #648.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure whether to add it inside the if block or outside!!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question.

What does the dartdoc on that _messageVisible method say?

Based on that, what would be the consequence of having that condition control whether to make this call? And what would be the consequence of ignoring that condition when making this call?

}
}
_fetched = true;
Expand Down Expand Up @@ -417,6 +418,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
? result.messages // Avoid unnecessarily copying the list.
: result.messages.where(_messageVisible);

for (final message in fetchedMessages) {
store.recentSenders.handleMessage(message);
}
store.autocompleteViewManager.handleOlderMessages();

_insertAllMessages(0, fetchedMessages);
_haveOldest = result.foundOldest;
} finally {
Expand Down
45 changes: 42 additions & 3 deletions lib/model/recent_dm_conversations.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

Expand All @@ -19,16 +21,30 @@ class RecentDmConversationsView extends ChangeNotifier {
DmNarrow.ofRecentDmConversation(conversation, selfUserId: selfUserId),
conversation.maxMessageId,
)).toList()..sort((a, b) => -a.value.compareTo(b.value));
final map = Map.fromEntries(entries);
final sorted = QueueList.from(entries.map((e) => e.key));

final msgsByUser = <int, int>{};
for (final entry in entries) {
final dmNarrow = entry.key;
final msg = entry.value;
for (final userId in dmNarrow.otherRecipientIds) {
// only take the latest message of a user among all the conversations.
msgsByUser.putIfAbsent(userId, () => msg);
}
}
return RecentDmConversationsView._(
map: Map.fromEntries(entries),
sorted: QueueList.from(entries.map((e) => e.key)),
map: map,
sorted: sorted,
latestMessagesByRecipient: msgsByUser,
selfUserId: selfUserId,
);
}

RecentDmConversationsView._({
required this.map,
required this.sorted,
required this.latestMessagesByRecipient,
required this.selfUserId,
});

Expand All @@ -38,8 +54,24 @@ class RecentDmConversationsView extends ChangeNotifier {
/// The [DmNarrow] keys of [map], sorted by latest message descending.
final QueueList<DmNarrow> sorted;

/// Map from user ID to the latest message ID in any conversation with the user.
///
/// Both 1:1 and group DM conversations are considered.
/// The self-user ID is excluded even if there is a self-DM conversation.
///
/// (The identified message was not necessarily sent by the identified user;
/// it might have been sent by anyone in its conversation.)
final Map<int, int> latestMessagesByRecipient;

final int selfUserId;

int compareByDms(User userA, User userB) {
final aLatestMessageId = latestMessagesByRecipient[userA.userId] ?? -1;
final bLatestMessageId = latestMessagesByRecipient[userB.userId] ?? -1;

return bLatestMessageId.compareTo(aLatestMessageId);
}

/// Insert the key at the proper place in [sorted].
///
/// Optimized, taking O(1) time, for the case where that place is the start,
Expand All @@ -58,7 +90,7 @@ class RecentDmConversationsView extends ChangeNotifier {
}
}

/// Handle [MessageEvent], updating [map] and [sorted].
/// Handle [MessageEvent], updating [map], [sorted], and [latestMessagesByRecipient].
///
/// Can take linear time in general. That sounds inefficient...
/// but it's what the webapp does, so must not be catastrophic. 🤷
Expand Down Expand Up @@ -117,6 +149,13 @@ class RecentDmConversationsView extends ChangeNotifier {
_insertSorted(key, message.id);
}
}
for (final recipient in key.otherRecipientIds) {
latestMessagesByRecipient.update(
recipient,
(latestMessageId) => max(message.id, latestMessageId),
ifAbsent: () => message.id,
);
}
notifyListeners();
}

Expand Down
Loading