Skip to content

Commit

Permalink
emoji: Rank "popular" > custom > other emoji
Browse files Browse the repository at this point in the history
Fixes part of zulip#1068.
  • Loading branch information
gnprice committed Dec 8, 2024
1 parent bce5084 commit 40e413c
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 12 deletions.
40 changes: 34 additions & 6 deletions lib/model/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ class EmojiStoreImpl with EmojiStore {
];
}

static final _popularEmojiCodes = (() {
assert(_popularCandidates.every((c) =>
c.emojiType == ReactionType.unicodeEmoji));
return Set.of(_popularCandidates.map((c) => c.emojiCode));
})();

static bool _isPopularEmoji(EmojiCandidate candidate) {
return candidate.emojiType == ReactionType.unicodeEmoji
&& _popularEmojiCodes.contains(candidate.emojiCode);
}

EmojiCandidate _emojiCandidateFor({
required ReactionType emojiType,
required String emojiCode,
Expand Down Expand Up @@ -406,7 +417,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
EmojiAutocompleteResult? testCandidate(EmojiCandidate candidate) {
final matchQuality = match(candidate);
if (matchQuality == null) return null;
return EmojiAutocompleteResult(candidate, _rankResult(matchQuality));
return EmojiAutocompleteResult(candidate,
_rankResult(matchQuality, candidate));
}

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

/// 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) {
static int _rankResult(EmojiMatchQuality matchQuality, EmojiCandidate candidate) {
// Compare sort_emojis in Zulip web:
// https://github.com/zulip/zulip/blob/83a121c7e/web/shared/src/typeahead.ts#L322-L382
//
// Behavior differences we should or might copy, TODO(#1068):
// * Web ranks popular emoji > custom emoji > others; we don't yet.
// * Web ranks matches starting at a word boundary ahead of
// other non-prefix matches; we don't yet.
// * Relatedly, web favors popular emoji only upon a word-aligned match.
// * Web ranks each name of a Unicode emoji separately.
//
// Behavior differences that web should probably fix, TODO(web):
// * Among popular emoji with non-exact matches,
// web doesn't prioritize prefix over word-aligned; we do.
// (This affects just one case: for query "o",
// we put :octopus: before :working_on_it:.)
// * Web only counts an emoji as "popular" for ranking if the query
// is a prefix of a single word in the name; so "thumbs_" or "working_on_i"
// lose the ranking boost for :thumbs_up: and :working_on_it: respectively.
// * Web starts with only case-sensitive exact matches ("perfect matches"),
// and puts case-insensitive exact matches just ahead of prefix matches;
// it also distinguishes prefix matches by case-sensitive vs. not.
Expand All @@ -480,15 +499,24 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
// because emoji with the same name will mostly both match or both not;
// but it breaks if the Unicode emoji was a literal match.

final isPopular = EmojiStoreImpl._isPopularEmoji(candidate);
final isCustomEmoji = switch (candidate.emojiType) {
// The web implementation calls this condition `is_realm_emoji`,
// but its actual semantics is it's true for the Zulip extra emoji too.
// See `zulip_emoji` in web:src/emoji.ts .
ReactionType.realmEmoji || ReactionType.zulipExtraEmoji => true,
ReactionType.unicodeEmoji => false,
};
return switch (matchQuality) {
EmojiMatchQuality.exact => 0,
EmojiMatchQuality.prefix => 1,
EmojiMatchQuality.other => 2,
EmojiMatchQuality.prefix => isPopular ? 1 : isCustomEmoji ? 3 : 4,
// TODO word-boundary vs. not
EmojiMatchQuality.other => isPopular ? 2 : isCustomEmoji ? 5 : 6,
};
}

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

@override
String toString() {
Expand Down
53 changes: 47 additions & 6 deletions test/model/emoji_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ void main() {
await Future(() {});
check(done).isTrue();
check(view.results).deepEquals([
isUnicodeResult(names: ['bookmark']),
isRealmResult(emojiName: 'happy'),
isZulipResult(),
isUnicodeResult(names: ['bookmark']),
]);
});

Expand Down Expand Up @@ -287,15 +287,17 @@ void main() {
}

test('results end-to-end', () async {
// (See more detailed rank tests below, on EmojiAutocompleteQuery.)

final unicodeEmoji = {
'1f4d3': ['notebook'], '1f516': ['bookmark'], '1f4d6': ['book']};

// Empty query -> base ordering.
check(await resultsOf('', unicodeEmoji: unicodeEmoji)).deepEquals([
isZulipResult(),
isUnicodeResult(names: ['notebook']),
isUnicodeResult(names: ['bookmark']),
isUnicodeResult(names: ['book']),
isZulipResult(),
]);

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

void checkSameRank(String query, EmojiCandidate a, EmojiCandidate b) {
check(rankOf(query, a)!).equals(rankOf(query, b)!);
}

final octopus = unicode(['octopus'], emojiCode: '1f419');
final workingOnIt = unicode(['working_on_it'], emojiCode: '1f6e0');

test('ranks exact before prefix before other match', () {
checkPrecedes('o', unicode(['o']), unicode(['onion']));
checkPrecedes('o', unicode(['onion']), unicode(['book']));
});

test('ranks popular before realm before other Unicode', () {
checkPrecedes('o', octopus, realmCandidate('open_book'));
checkPrecedes('o', realmCandidate('open_book'), unicode(['ok']));
});

test('ranks Zulip extra emoji same as realm emoji', () {
checkSameRank('z', zulipCandidate(), realmCandidate('zounds'));
});

test('ranks exact-vs-not more significant than popular/custom/other', () {
// Generic Unicode exact beats popular prefix…
checkPrecedes('o', unicode(['o']), octopus);
// … which really does count as popular, beating realm prefix.
checkPrecedes('o', octopus, realmCandidate('open_book'));
});

test('ranks popular-vs-not more significant than prefix/other', () {
// Popular other beats realm prefix.
checkPrecedes('o', workingOnIt, realmCandidate('open_book'));
});

test('ranks prefix/other more significant than custom/other', () {
// Generic Unicode prefix beats realm other.
checkPrecedes('o', unicode(['ok']), realmCandidate('yo'));
});

test('full list of ranks', () {
check([
rankOf('o', unicode(['o'])), // exact
rankOf('o', unicode(['onion'])), // prefix
rankOf('o', unicode(['book'])), // other
]).deepEquals([0, 1, 2]);
rankOf('o', unicode(['o'])), // exact (generic)
rankOf('o', octopus), // prefix popular
rankOf('o', workingOnIt), // other popular
rankOf('o', realmCandidate('open_book')), // prefix realm
rankOf('z', zulipCandidate()), // == prefix :zulip:
rankOf('o', unicode(['ok'])), // prefix generic
rankOf('o', realmCandidate('yo')), // other realm
rankOf('p', zulipCandidate()), // == other :zulip:
rankOf('o', unicode(['book'])), // other generic
]).deepEquals([0, 1, 2, 3, 3, 4, 5, 5, 6]);
});
});
}
Expand Down

0 comments on commit 40e413c

Please sign in to comment.