Skip to content

Commit 40e413c

Browse files
committed
emoji: Rank "popular" > custom > other emoji
Fixes part of #1068.
1 parent bce5084 commit 40e413c

File tree

2 files changed

+81
-12
lines changed

2 files changed

+81
-12
lines changed

lib/model/emoji.dart

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,17 @@ class EmojiStoreImpl with EmojiStore {
237237
];
238238
}
239239

240+
static final _popularEmojiCodes = (() {
241+
assert(_popularCandidates.every((c) =>
242+
c.emojiType == ReactionType.unicodeEmoji));
243+
return Set.of(_popularCandidates.map((c) => c.emojiCode));
244+
})();
245+
246+
static bool _isPopularEmoji(EmojiCandidate candidate) {
247+
return candidate.emojiType == ReactionType.unicodeEmoji
248+
&& _popularEmojiCodes.contains(candidate.emojiCode);
249+
}
250+
240251
EmojiCandidate _emojiCandidateFor({
241252
required ReactionType emojiType,
242253
required String emojiCode,
@@ -406,7 +417,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
406417
EmojiAutocompleteResult? testCandidate(EmojiCandidate candidate) {
407418
final matchQuality = match(candidate);
408419
if (matchQuality == null) return null;
409-
return EmojiAutocompleteResult(candidate, _rankResult(matchQuality));
420+
return EmojiAutocompleteResult(candidate,
421+
_rankResult(matchQuality, candidate));
410422
}
411423

412424
// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
@@ -459,17 +471,24 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
459471

460472
/// A measure of the result's quality in the context of the query,
461473
/// ranked from 0 (best) to one less than [_numResultRanks].
462-
static int _rankResult(EmojiMatchQuality matchQuality) {
474+
static int _rankResult(EmojiMatchQuality matchQuality, EmojiCandidate candidate) {
463475
// Compare sort_emojis in Zulip web:
464476
// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L322-L382
465477
//
466478
// Behavior differences we should or might copy, TODO(#1068):
467-
// * Web ranks popular emoji > custom emoji > others; we don't yet.
468479
// * Web ranks matches starting at a word boundary ahead of
469480
// other non-prefix matches; we don't yet.
481+
// * Relatedly, web favors popular emoji only upon a word-aligned match.
470482
// * Web ranks each name of a Unicode emoji separately.
471483
//
472484
// Behavior differences that web should probably fix, TODO(web):
485+
// * Among popular emoji with non-exact matches,
486+
// web doesn't prioritize prefix over word-aligned; we do.
487+
// (This affects just one case: for query "o",
488+
// we put :octopus: before :working_on_it:.)
489+
// * Web only counts an emoji as "popular" for ranking if the query
490+
// is a prefix of a single word in the name; so "thumbs_" or "working_on_i"
491+
// lose the ranking boost for :thumbs_up: and :working_on_it: respectively.
473492
// * Web starts with only case-sensitive exact matches ("perfect matches"),
474493
// and puts case-insensitive exact matches just ahead of prefix matches;
475494
// it also distinguishes prefix matches by case-sensitive vs. not.
@@ -480,15 +499,24 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
480499
// because emoji with the same name will mostly both match or both not;
481500
// but it breaks if the Unicode emoji was a literal match.
482501

502+
final isPopular = EmojiStoreImpl._isPopularEmoji(candidate);
503+
final isCustomEmoji = switch (candidate.emojiType) {
504+
// The web implementation calls this condition `is_realm_emoji`,
505+
// but its actual semantics is it's true for the Zulip extra emoji too.
506+
// See `zulip_emoji` in web:src/emoji.ts .
507+
ReactionType.realmEmoji || ReactionType.zulipExtraEmoji => true,
508+
ReactionType.unicodeEmoji => false,
509+
};
483510
return switch (matchQuality) {
484511
EmojiMatchQuality.exact => 0,
485-
EmojiMatchQuality.prefix => 1,
486-
EmojiMatchQuality.other => 2,
512+
EmojiMatchQuality.prefix => isPopular ? 1 : isCustomEmoji ? 3 : 4,
513+
// TODO word-boundary vs. not
514+
EmojiMatchQuality.other => isPopular ? 2 : isCustomEmoji ? 5 : 6,
487515
};
488516
}
489517

490518
/// The number of possible values returned by [_rankResult].
491-
static const _numResultRanks = 3;
519+
static const _numResultRanks = 7;
492520

493521
@override
494522
String toString() {

test/model/emoji_test.dart

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,9 @@ void main() {
245245
await Future(() {});
246246
check(done).isTrue();
247247
check(view.results).deepEquals([
248-
isUnicodeResult(names: ['bookmark']),
249248
isRealmResult(emojiName: 'happy'),
250249
isZulipResult(),
250+
isUnicodeResult(names: ['bookmark']),
251251
]);
252252
});
253253

@@ -287,15 +287,17 @@ void main() {
287287
}
288288

289289
test('results end-to-end', () async {
290+
// (See more detailed rank tests below, on EmojiAutocompleteQuery.)
291+
290292
final unicodeEmoji = {
291293
'1f4d3': ['notebook'], '1f516': ['bookmark'], '1f4d6': ['book']};
292294

293295
// Empty query -> base ordering.
294296
check(await resultsOf('', unicodeEmoji: unicodeEmoji)).deepEquals([
297+
isZulipResult(),
295298
isUnicodeResult(names: ['notebook']),
296299
isUnicodeResult(names: ['bookmark']),
297300
isUnicodeResult(names: ['book']),
298-
isZulipResult(),
299301
]);
300302

301303
// With query, exact match precedes prefix match precedes other.
@@ -468,17 +470,56 @@ void main() {
468470
check(rankOf(query, a)!).isLessThan(rankOf(query, b)!);
469471
}
470472

473+
void checkSameRank(String query, EmojiCandidate a, EmojiCandidate b) {
474+
check(rankOf(query, a)!).equals(rankOf(query, b)!);
475+
}
476+
477+
final octopus = unicode(['octopus'], emojiCode: '1f419');
478+
final workingOnIt = unicode(['working_on_it'], emojiCode: '1f6e0');
479+
471480
test('ranks exact before prefix before other match', () {
472481
checkPrecedes('o', unicode(['o']), unicode(['onion']));
473482
checkPrecedes('o', unicode(['onion']), unicode(['book']));
474483
});
475484

485+
test('ranks popular before realm before other Unicode', () {
486+
checkPrecedes('o', octopus, realmCandidate('open_book'));
487+
checkPrecedes('o', realmCandidate('open_book'), unicode(['ok']));
488+
});
489+
490+
test('ranks Zulip extra emoji same as realm emoji', () {
491+
checkSameRank('z', zulipCandidate(), realmCandidate('zounds'));
492+
});
493+
494+
test('ranks exact-vs-not more significant than popular/custom/other', () {
495+
// Generic Unicode exact beats popular prefix…
496+
checkPrecedes('o', unicode(['o']), octopus);
497+
// … which really does count as popular, beating realm prefix.
498+
checkPrecedes('o', octopus, realmCandidate('open_book'));
499+
});
500+
501+
test('ranks popular-vs-not more significant than prefix/other', () {
502+
// Popular other beats realm prefix.
503+
checkPrecedes('o', workingOnIt, realmCandidate('open_book'));
504+
});
505+
506+
test('ranks prefix/other more significant than custom/other', () {
507+
// Generic Unicode prefix beats realm other.
508+
checkPrecedes('o', unicode(['ok']), realmCandidate('yo'));
509+
});
510+
476511
test('full list of ranks', () {
477512
check([
478-
rankOf('o', unicode(['o'])), // exact
479-
rankOf('o', unicode(['onion'])), // prefix
480-
rankOf('o', unicode(['book'])), // other
481-
]).deepEquals([0, 1, 2]);
513+
rankOf('o', unicode(['o'])), // exact (generic)
514+
rankOf('o', octopus), // prefix popular
515+
rankOf('o', workingOnIt), // other popular
516+
rankOf('o', realmCandidate('open_book')), // prefix realm
517+
rankOf('z', zulipCandidate()), // == prefix :zulip:
518+
rankOf('o', unicode(['ok'])), // prefix generic
519+
rankOf('o', realmCandidate('yo')), // other realm
520+
rankOf('p', zulipCandidate()), // == other :zulip:
521+
rankOf('o', unicode(['book'])), // other generic
522+
]).deepEquals([0, 1, 2, 3, 3, 4, 5, 5, 6]);
482523
});
483524
});
484525
}

0 commit comments

Comments
 (0)