Skip to content

Commit

Permalink
emoji [nfc]: Add ranking framework for emoji autocomplete results
Browse files Browse the repository at this point in the history
  • Loading branch information
gnprice committed Dec 8, 2024
1 parent 5e182d4 commit b78baf6
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 25 deletions.
7 changes: 6 additions & 1 deletion lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -758,10 +758,15 @@ sealed class ComposeAutocompleteResult extends AutocompleteResult {}

/// An emoji chosen in an autocomplete interaction, via [EmojiAutocompleteView].
class EmojiAutocompleteResult extends ComposeAutocompleteResult {
EmojiAutocompleteResult(this.candidate);
EmojiAutocompleteResult(this.candidate, this.rank);

final EmojiCandidate candidate;

/// A measure of the result's quality in the context of the query.
///
/// Used internally by [EmojiAutocompleteView] for ranking the results.
final int rank;

@override
String toString() {
return 'EmojiAutocompleteResult(${candidate.description()})';
Expand Down
65 changes: 55 additions & 10 deletions lib/model/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
import '../api/route/realm.dart';
import 'algorithms.dart';
import 'autocomplete.dart';
import 'narrow.dart';
import 'store.dart';
Expand Down Expand Up @@ -280,6 +281,24 @@ class EmojiStoreImpl with EmojiStore {
}
}

/// The quality of an emoji's match to an autocomplete query.
///
/// (Rather vacuous for the moment; this structure will
/// gain more substance in an upcoming commit.)
enum EmojiMatchQuality {
match;

/// The best possible quality of match.
static const best = match;

/// The better of the two given qualities of match,
/// where null represents no match at all.
static EmojiMatchQuality? bestOf(EmojiMatchQuality? a, EmojiMatchQuality? b) {
if (b == null) return a;
return b;
}
}

class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, EmojiAutocompleteResult> {
EmojiAutocompleteView._({required super.store, required super.query});

Expand All @@ -294,13 +313,13 @@ class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, Emo

@override
Future<List<EmojiAutocompleteResult>?> computeResults() async {
// TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other)
final results = <EmojiAutocompleteResult>[];
final unsorted = <EmojiAutocompleteResult>[];
if (await filterCandidates(filter: _testCandidate,
candidates: store.allEmojiCandidates(), results: results)) {
candidates: store.allEmojiCandidates(), results: unsorted)) {
return null;
}
return results;
return bucketSort(unsorted,
(r) => r.rank, numBuckets: EmojiAutocompleteQuery._numResultRanks);
}

static EmojiAutocompleteResult? _testCandidate(EmojiAutocompleteQuery query, EmojiCandidate candidate) {
Expand Down Expand Up @@ -338,18 +357,32 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {

@visibleForTesting
EmojiAutocompleteResult? testCandidate(EmojiCandidate candidate) {
return matches(candidate) ? EmojiAutocompleteResult(candidate) : null;
final matchQuality = match(candidate);
if (matchQuality == null) return null;
return EmojiAutocompleteResult(candidate, _rankResult(matchQuality));
}

// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
@visibleForTesting
bool matches(EmojiCandidate candidate) {
if (_adjusted == '') return true;
EmojiMatchQuality? match(EmojiCandidate candidate) {
if (_adjusted == '') return EmojiMatchQuality.match;

if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) {
if (_adjusted == emojiUnicode) return true;
if (_adjusted == emojiUnicode) {
return EmojiMatchQuality.match;
}
}
return _nameMatches(candidate.emojiName)
|| candidate.aliases.any((alias) => _nameMatches(alias));

EmojiMatchQuality? result = _matchName(candidate.emojiName);
for (final alias in candidate.aliases) {
if (result == EmojiMatchQuality.best) return result;
result = EmojiMatchQuality.bestOf(result, _matchName(alias));
}
return result;
}

EmojiMatchQuality? _matchName(String emojiName) {
return _nameMatches(emojiName) ? EmojiMatchQuality.match : null;
}

// Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts .
Expand All @@ -370,6 +403,18 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
|| emojiName.contains(_sepAdjusted);
}

/// A measure of the result's quality in the context of the query,
/// ranked from 0 (best) to one less than [_numResultRanks].
static int _rankResult(EmojiMatchQuality matchQuality) {
// TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other)
return switch (matchQuality) {
EmojiMatchQuality.match => 0,
};
}

/// The number of possible values returned by [_rankResult].
static const _numResultRanks = 1;

@override
String toString() {
return '${objectRuntimeType(this, 'EmojiAutocompleteQuery')}($raw)';
Expand Down
28 changes: 14 additions & 14 deletions test/model/emoji_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ void main() {
});
});

group('EmojiAutocompleteQuery.matches', () {
group('EmojiAutocompleteQuery.match', () {
EmojiCandidate unicode(List<String> names, {String? emojiCode}) {
emojiCode ??= '10ffff';
return EmojiCandidate(emojiType: ReactionType.unicodeEmoji,
Expand All @@ -283,12 +283,12 @@ void main() {
emojiUnicode: tryParseEmojiCodeToUnicode(emojiCode)!));
}

bool matches(String query, EmojiCandidate candidate) {
return EmojiAutocompleteQuery(query).matches(candidate);
EmojiMatchQuality? matchOf(String query, EmojiCandidate candidate) {
return EmojiAutocompleteQuery(query).match(candidate);
}

bool matchesNames(String query, List<String> names) {
return matches(query, unicode(names));
return matchOf(query, unicode(names)) != null;
}

bool matchesName(String query, String emojiName) {
Expand Down Expand Up @@ -358,7 +358,7 @@ void main() {
test('query matches literal Unicode value', () {
bool matchesLiteral(String query, String emojiCode, {required String aka}) {
assert(aka == query);
return matches(query, unicode(['asdf'], emojiCode: emojiCode));
return matchOf(query, unicode(['asdf'], emojiCode: emojiCode)) != null;
}

// Matching the code, in hex, doesn't count.
Expand Down Expand Up @@ -392,11 +392,11 @@ void main() {
resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png')));
}

check(matches('eqeq', realmCandidate('eqeq'))).isTrue();
check(matches('open_', realmCandidate('open_book'))).isTrue();
check(matches('n_b', realmCandidate('open_book'))).isFalse();
check(matches('blue dia', realmCandidate('large_blue_diamond'))).isTrue();
check(matches('Smi', realmCandidate('smile'))).isTrue();
check(matchOf('eqeq', realmCandidate('eqeq'))).isNotNull();
check(matchOf('open_', realmCandidate('open_book'))).isNotNull();
check(matchOf('n_b', realmCandidate('open_book'))).isNull();
check(matchOf('blue dia', realmCandidate('large_blue_diamond'))).isNotNull();
check(matchOf('Smi', realmCandidate('smile'))).isNotNull();
});

test('can match Zulip extra emoji', () {
Expand All @@ -408,10 +408,10 @@ void main() {
emojiType: ReactionType.zulipExtraEmoji,
emojiCode: 'zulip', emojiName: 'zulip'));

check(matches('z', zulipCandidate)).isTrue();
check(matches('Zulip', zulipCandidate)).isTrue();
check(matches('p', zulipCandidate)).isTrue();
check(matches('x', zulipCandidate)).isFalse();
check(matchOf('z', zulipCandidate)).isNotNull();
check(matchOf('Zulip', zulipCandidate)).isNotNull();
check(matchOf('p', zulipCandidate)).isNotNull();
check(matchOf('x', zulipCandidate)).isNull();
});
});
}
Expand Down

0 comments on commit b78baf6

Please sign in to comment.