Skip to content

Commit b7ed9a7

Browse files
chrisbobbegnprice
andcommitted
autocomplete [nfc]: Add bucket-sort step in mention-autocomplete
Like we do in emoji autocomplete. This commit doesn't make any changes to the results ordering; bucketSort sorts stably, and the input is still just the wildcard results followed by the user results. Soon, though, we'd like to rank by match quality and add user-group results (for #233) interleaved with user results. Bucket sorting will help us do this without making many intermediate copies of lists of results; see discussion: https://chat.zulip.org/#narrow/channel/48-mobile/topic/user-group.20mentions.20.23F233/near/2216353 Co-authored-by: Greg Price <[email protected]>
1 parent fd51498 commit b7ed9a7

File tree

2 files changed

+85
-9
lines changed

2 files changed

+85
-9
lines changed

lib/model/autocomplete.dart

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../api/model/model.dart';
88
import '../api/route/channels.dart';
99
import '../generated/l10n/zulip_localizations.dart';
1010
import '../widgets/compose_box.dart';
11+
import 'algorithms.dart';
1112
import 'compose.dart';
1213
import 'emoji.dart';
1314
import 'narrow.dart';
@@ -635,16 +636,18 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
635636

636637
@override
637638
Future<List<MentionAutocompleteResult>?> computeResults() async {
638-
final results = <MentionAutocompleteResult>[];
639+
final unsorted = <MentionAutocompleteResult>[];
639640
// Give priority to wildcard mentions.
640-
computeWildcardMentionResults(results: results,
641+
computeWildcardMentionResults(results: unsorted,
641642
isComposingChannelMessage: narrow is ChannelNarrow || narrow is TopicNarrow);
642643

643644
if (await filterCandidates(filter: _testUser,
644-
candidates: sortedUsers, results: results)) {
645+
candidates: sortedUsers, results: unsorted)) {
645646
return null;
646647
}
647-
return results;
648+
649+
return bucketSort(unsorted,
650+
(r) => r.rank, numBuckets: MentionAutocompleteQuery._numResultRanks);
648651
}
649652

650653
MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
@@ -749,7 +752,8 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
749752
final matches = wildcardOption.canonicalString.contains(_lowercase)
750753
|| wildcardOption.localizedCanonicalString(localizations).contains(_lowercase);
751754
if (!matches) return null;
752-
return WildcardMentionAutocompleteResult(wildcardOption: wildcardOption);
755+
return WildcardMentionAutocompleteResult(
756+
wildcardOption: wildcardOption, rank: _rankWildcardResult);
753757
}
754758

755759
MentionAutocompleteResult? testUser(User user, PerAccountStore store) {
@@ -760,13 +764,30 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
760764
// TODO(#236) test email too, not just name
761765
if (!_testName(user, cache)) return null;
762766

763-
return UserMentionAutocompleteResult(userId: user.userId);
767+
return UserMentionAutocompleteResult(
768+
userId: user.userId, rank: _rankUserResult(user));
764769
}
765770

766771
bool _testName(User user, AutocompleteDataCache cache) {
767772
return _testContainsQueryWords(cache.nameWordsForUser(user));
768773
}
769774

775+
/// A measure of a wildcard result's quality in the context of the query,
776+
/// from 0 (best) to one less than [_numResultRanks].
777+
///
778+
/// See also [_rankUserResult].
779+
static const _rankWildcardResult = 0;
780+
781+
/// A measure of a user result's quality in the context of the query,
782+
/// from 0 (best) to one less than [_numResultRanks].
783+
///
784+
/// See also [_rankWildcardResult].
785+
static int _rankUserResult(User user) => 1;
786+
787+
/// The number of possible values returned by
788+
/// [_rankWildcardResult] and [_rankUserResult].
789+
static const _numResultRanks = 2;
790+
770791
@override
771792
String toString() {
772793
return '${objectRuntimeType(this, 'MentionAutocompleteQuery')}(raw: $raw, silent: $silent})';
@@ -853,20 +874,31 @@ class EmojiAutocompleteResult extends ComposeAutocompleteResult {
853874
/// This is abstract because there are several kinds of result
854875
/// that can all be offered in the same @-mention autocomplete interaction:
855876
/// a user, a wildcard, or a user group.
856-
sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {}
877+
sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
878+
/// A measure of the result's quality in the context of the query.
879+
///
880+
/// Used internally by [MentionAutocompleteView] for ranking the results.
881+
int get rank;
882+
}
857883

858884
/// An autocomplete result for an @-mention of an individual user.
859885
class UserMentionAutocompleteResult extends MentionAutocompleteResult {
860-
UserMentionAutocompleteResult({required this.userId});
886+
UserMentionAutocompleteResult({required this.userId, required this.rank});
861887

862888
final int userId;
889+
890+
@override
891+
final int rank;
863892
}
864893

865894
/// An autocomplete result for an @-mention of all the users in a conversation.
866895
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
867-
WildcardMentionAutocompleteResult({required this.wildcardOption});
896+
WildcardMentionAutocompleteResult({required this.wildcardOption, required this.rank});
868897

869898
final WildcardMentionOption wildcardOption;
899+
900+
@override
901+
final int rank;
870902
}
871903

872904
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {

test/model/autocomplete_test.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,50 @@ void main() {
972972
});
973973
});
974974

975+
group('MentionAutocompleteQuery ranking', () {
976+
// This gets filled lazily, but never reset.
977+
// We're counting on this group's tests never doing anything to mutate it.
978+
PerAccountStore? store;
979+
980+
int? rankOf(String queryStr, Object candidate) {
981+
final query = MentionAutocompleteQuery(queryStr);
982+
final result = switch (candidate) {
983+
WildcardMentionOption() => query.testWildcardOption(candidate,
984+
localizations: GlobalLocalizations.zulipLocalizations),
985+
User() => query.testUser(candidate, (store ??= eg.store())),
986+
_ => throw StateError('invalid candidate'),
987+
};
988+
return result?.rank;
989+
}
990+
991+
void checkPrecedes(String query, Object a, Object b) {
992+
check(rankOf(query, a)!).isLessThan(rankOf(query, b)!);
993+
}
994+
995+
void checkSameRank(String query, Object a, Object b) {
996+
check(rankOf(query, a)!).equals(rankOf(query, b)!);
997+
}
998+
999+
test('wildcards, then users', () {
1000+
checkSameRank('', WildcardMentionOption.all, WildcardMentionOption.topic);
1001+
checkPrecedes('', WildcardMentionOption.topic, eg.user());
1002+
checkSameRank('', eg.user(), eg.user());
1003+
});
1004+
1005+
test('wildcard-vs-user more significant than match quality', () {
1006+
// Make the query an exact match for the user's name.
1007+
final user = eg.user(fullName: 'Ann');
1008+
checkPrecedes(user.fullName, WildcardMentionOption.channel, user);
1009+
});
1010+
1011+
test('full list of ranks', () {
1012+
check([
1013+
rankOf('', WildcardMentionOption.all), // wildcard
1014+
rankOf('', eg.user()), // user
1015+
]).deepEquals([0, 1]);
1016+
});
1017+
});
1018+
9751019
group('ComposeTopicAutocomplete.autocompleteIntent', () {
9761020
void doTest(String markedText, TopicAutocompleteQuery? expectedQuery) {
9771021
final parsed = parseMarkedText(markedText);

0 commit comments

Comments
 (0)