Skip to content

Commit 70e132b

Browse files
committed
emoji [nfc]: Add ranking framework for emoji autocomplete results
1 parent 5cc5142 commit 70e132b

File tree

3 files changed

+75
-25
lines changed

3 files changed

+75
-25
lines changed

lib/model/autocomplete.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,10 +758,15 @@ sealed class ComposeAutocompleteResult extends AutocompleteResult {}
758758

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

763763
final EmojiCandidate candidate;
764764

765+
/// A measure of the result's quality in the context of the query.
766+
///
767+
/// Used internally by [EmojiAutocompleteView] for ranking the results.
768+
final int rank;
769+
765770
@override
766771
String toString() {
767772
return 'EmojiAutocompleteResult(${candidate.description()})';

lib/model/emoji.dart

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import '../api/model/events.dart';
55
import '../api/model/initial_snapshot.dart';
66
import '../api/model/model.dart';
77
import '../api/route/realm.dart';
8+
import 'algorithms.dart';
89
import 'autocomplete.dart';
910
import 'narrow.dart';
1011
import 'store.dart';
@@ -319,6 +320,24 @@ class EmojiStoreImpl with EmojiStore {
319320
}
320321
}
321322

323+
/// The quality of an emoji's match to an autocomplete query.
324+
///
325+
/// (Rather vacuous for the moment; this structure will
326+
/// gain more substance in an upcoming commit.)
327+
enum EmojiMatchQuality {
328+
match;
329+
330+
/// The best possible quality of match.
331+
static const best = match;
332+
333+
/// The better of the two given qualities of match,
334+
/// where null represents no match at all.
335+
static EmojiMatchQuality? bestOf(EmojiMatchQuality? a, EmojiMatchQuality? b) {
336+
if (b == null) return a;
337+
return b;
338+
}
339+
}
340+
322341
class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, EmojiAutocompleteResult> {
323342
EmojiAutocompleteView._({required super.store, required super.query});
324343

@@ -333,13 +352,13 @@ class EmojiAutocompleteView extends AutocompleteView<EmojiAutocompleteQuery, Emo
333352

334353
@override
335354
Future<List<EmojiAutocompleteResult>?> computeResults() async {
336-
// TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other)
337-
final results = <EmojiAutocompleteResult>[];
355+
final unsorted = <EmojiAutocompleteResult>[];
338356
if (await filterCandidates(filter: _testCandidate,
339-
candidates: store.allEmojiCandidates(), results: results)) {
357+
candidates: store.allEmojiCandidates(), results: unsorted)) {
340358
return null;
341359
}
342-
return results;
360+
return bucketSort(unsorted,
361+
(r) => r.rank, numBuckets: EmojiAutocompleteQuery._numResultRanks);
343362
}
344363

345364
static EmojiAutocompleteResult? _testCandidate(EmojiAutocompleteQuery query, EmojiCandidate candidate) {
@@ -377,18 +396,32 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
377396

378397
@visibleForTesting
379398
EmojiAutocompleteResult? testCandidate(EmojiCandidate candidate) {
380-
return matches(candidate) ? EmojiAutocompleteResult(candidate) : null;
399+
final matchQuality = match(candidate);
400+
if (matchQuality == null) return null;
401+
return EmojiAutocompleteResult(candidate, _rankResult(matchQuality));
381402
}
382403

383404
// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
384405
@visibleForTesting
385-
bool matches(EmojiCandidate candidate) {
386-
if (_adjusted == '') return true;
406+
EmojiMatchQuality? match(EmojiCandidate candidate) {
407+
if (_adjusted == '') return EmojiMatchQuality.match;
408+
387409
if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) {
388-
if (_adjusted == emojiUnicode) return true;
410+
if (_adjusted == emojiUnicode) {
411+
return EmojiMatchQuality.match;
412+
}
389413
}
390-
return _nameMatches(candidate.emojiName)
391-
|| candidate.aliases.any((alias) => _nameMatches(alias));
414+
415+
EmojiMatchQuality? result = _matchName(candidate.emojiName);
416+
for (final alias in candidate.aliases) {
417+
if (result == EmojiMatchQuality.best) return result;
418+
result = EmojiMatchQuality.bestOf(result, _matchName(alias));
419+
}
420+
return result;
421+
}
422+
423+
EmojiMatchQuality? _matchName(String emojiName) {
424+
return _nameMatches(emojiName) ? EmojiMatchQuality.match : null;
392425
}
393426

394427
// Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts .
@@ -409,6 +442,18 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
409442
|| emojiName.contains(_sepAdjusted);
410443
}
411444

445+
/// A measure of the result's quality in the context of the query,
446+
/// ranked from 0 (best) to one less than [_numResultRanks].
447+
static int _rankResult(EmojiMatchQuality matchQuality) {
448+
// TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other)
449+
return switch (matchQuality) {
450+
EmojiMatchQuality.match => 0,
451+
};
452+
}
453+
454+
/// The number of possible values returned by [_rankResult].
455+
static const _numResultRanks = 1;
456+
412457
@override
413458
String toString() {
414459
return '${objectRuntimeType(this, 'EmojiAutocompleteQuery')}($raw)';

test/model/emoji_test.dart

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ void main() {
309309
});
310310
});
311311

312-
group('EmojiAutocompleteQuery.matches', () {
312+
group('EmojiAutocompleteQuery.match', () {
313313
EmojiCandidate unicode(List<String> names, {String? emojiCode}) {
314314
emojiCode ??= '10ffff';
315315
return EmojiCandidate(emojiType: ReactionType.unicodeEmoji,
@@ -320,12 +320,12 @@ void main() {
320320
emojiUnicode: tryParseEmojiCodeToUnicode(emojiCode)!));
321321
}
322322

323-
bool matches(String query, EmojiCandidate candidate) {
324-
return EmojiAutocompleteQuery(query).matches(candidate);
323+
EmojiMatchQuality? matchOf(String query, EmojiCandidate candidate) {
324+
return EmojiAutocompleteQuery(query).match(candidate);
325325
}
326326

327327
bool matchesNames(String query, List<String> names) {
328-
return matches(query, unicode(names));
328+
return matchOf(query, unicode(names)) != null;
329329
}
330330

331331
bool matchesName(String query, String emojiName) {
@@ -395,7 +395,7 @@ void main() {
395395
test('query matches literal Unicode value', () {
396396
bool matchesLiteral(String query, String emojiCode, {required String aka}) {
397397
assert(aka == query);
398-
return matches(query, unicode(['asdf'], emojiCode: emojiCode));
398+
return matchOf(query, unicode(['asdf'], emojiCode: emojiCode)) != null;
399399
}
400400

401401
// Matching the code, in hex, doesn't count.
@@ -429,11 +429,11 @@ void main() {
429429
resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png')));
430430
}
431431

432-
check(matches('eqeq', realmCandidate('eqeq'))).isTrue();
433-
check(matches('open_', realmCandidate('open_book'))).isTrue();
434-
check(matches('n_b', realmCandidate('open_book'))).isFalse();
435-
check(matches('blue dia', realmCandidate('large_blue_diamond'))).isTrue();
436-
check(matches('Smi', realmCandidate('smile'))).isTrue();
432+
check(matchOf('eqeq', realmCandidate('eqeq'))).isNotNull();
433+
check(matchOf('open_', realmCandidate('open_book'))).isNotNull();
434+
check(matchOf('n_b', realmCandidate('open_book'))).isNull();
435+
check(matchOf('blue dia', realmCandidate('large_blue_diamond'))).isNotNull();
436+
check(matchOf('Smi', realmCandidate('smile'))).isNotNull();
437437
});
438438

439439
test('can match Zulip extra emoji', () {
@@ -445,10 +445,10 @@ void main() {
445445
emojiType: ReactionType.zulipExtraEmoji,
446446
emojiCode: 'zulip', emojiName: 'zulip'));
447447

448-
check(matches('z', zulipCandidate)).isTrue();
449-
check(matches('Zulip', zulipCandidate)).isTrue();
450-
check(matches('p', zulipCandidate)).isTrue();
451-
check(matches('x', zulipCandidate)).isFalse();
448+
check(matchOf('z', zulipCandidate)).isNotNull();
449+
check(matchOf('Zulip', zulipCandidate)).isNotNull();
450+
check(matchOf('p', zulipCandidate)).isNotNull();
451+
check(matchOf('x', zulipCandidate)).isNull();
452452
});
453453
});
454454
}

0 commit comments

Comments
 (0)