Skip to content

Commit cc41c08

Browse files
committed
emoji: Define which emoji match a given autocomplete query
The initViewModel method can't yet be called, because these EmojiAutocompleteQuery objects aren't yet ever constructed in the actual app. So for the moment it throws UnimplementedError, letting us save for a later commit the implementation of the class it should return an instance of.
1 parent ecd2cb5 commit cc41c08

File tree

2 files changed

+206
-0
lines changed

2 files changed

+206
-0
lines changed

lib/model/emoji.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import 'package:collection/collection.dart';
2+
import 'package:flutter/foundation.dart';
23

34
import '../api/model/events.dart';
45
import '../api/model/initial_snapshot.dart';
56
import '../api/model/model.dart';
67
import '../api/route/realm.dart';
8+
import 'autocomplete.dart';
9+
import 'narrow.dart';
10+
import 'store.dart';
711

812
/// An emoji, described by how to display it in the UI.
913
sealed class EmojiDisplay {
@@ -263,3 +267,60 @@ class EmojiStoreImpl with EmojiStore {
263267
_allEmojiCandidates = null;
264268
}
265269
}
270+
271+
class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
272+
EmojiAutocompleteQuery(super.raw)
273+
: _adjusted = _adjustQuery(raw);
274+
275+
final String _adjusted;
276+
277+
static String _adjustQuery(String raw) {
278+
return raw.toLowerCase().replaceAll(' ', '_'); // TODO(#1067) remove diacritics too
279+
}
280+
281+
@override
282+
ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) {
283+
throw UnimplementedError(); // TODO(#670)
284+
}
285+
286+
// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
287+
bool matches(EmojiCandidate candidate) {
288+
if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) {
289+
if (_adjusted == emojiUnicode) return true;
290+
}
291+
return _nameMatches(candidate.emojiName)
292+
|| candidate.aliases.any((alias) => _nameMatches(alias));
293+
}
294+
295+
// Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts .
296+
bool _nameMatches(String emojiName) {
297+
// TODO(#1067) this assumes emojiName is already lower-case (and no diacritics)
298+
const String separator = '_';
299+
300+
if (!_adjusted.contains(separator)) {
301+
// If the query is a single token (doesn't contain a separator),
302+
// the match can be anywhere in the string.
303+
return emojiName.contains(_adjusted);
304+
}
305+
306+
// If there is a separator in the query, then we
307+
// require the match to start at the start of a token.
308+
// (E.g. for 'ab_cd_ef', query could be 'ab_c' or 'cd_ef',
309+
// but not 'b_cd_ef'.)
310+
return emojiName.startsWith(_adjusted)
311+
|| emojiName.contains(separator + _adjusted);
312+
}
313+
314+
@override
315+
String toString() {
316+
return '${objectRuntimeType(this, 'EmojiAutocompleteQuery')}($raw)';
317+
}
318+
319+
@override
320+
bool operator ==(Object other) {
321+
return other is EmojiAutocompleteQuery && other.raw == raw;
322+
}
323+
324+
@override
325+
int get hashCode => Object.hash('EmojiAutocompleteQuery', raw);
326+
}

test/model/emoji_test.dart

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,151 @@ void main() {
202202
check(store.allEmojiCandidates()).identicalTo(candidates);
203203
});
204204
});
205+
206+
group('EmojiAutocompleteQuery.matches', () {
207+
EmojiCandidate unicode(List<String> names, {String? emojiCode}) {
208+
emojiCode ??= '10ffff';
209+
return EmojiCandidate(emojiType: ReactionType.unicodeEmoji,
210+
emojiCode: emojiCode,
211+
emojiName: names.first, aliases: names.sublist(1),
212+
emojiDisplay: UnicodeEmojiDisplay(
213+
emojiName: names.first,
214+
emojiUnicode: tryParseEmojiCodeToUnicode(emojiCode)!));
215+
}
216+
217+
bool matchesName(String query, String emojiName) {
218+
return EmojiAutocompleteQuery(query).matches(unicode([emojiName]));
219+
}
220+
221+
test('one-word query matches anywhere in name', () {
222+
check(matchesName('', 'smile')).isTrue();
223+
check(matchesName('s', 'smile')).isTrue();
224+
check(matchesName('sm', 'smile')).isTrue();
225+
check(matchesName('smile', 'smile')).isTrue();
226+
check(matchesName('m', 'smile')).isTrue();
227+
check(matchesName('mile', 'smile')).isTrue();
228+
check(matchesName('e', 'smile')).isTrue();
229+
230+
check(matchesName('smiley', 'smile')).isFalse();
231+
check(matchesName('a', 'smile')).isFalse();
232+
233+
check(matchesName('o', 'open_book')).isTrue();
234+
check(matchesName('open', 'open_book')).isTrue();
235+
check(matchesName('pe', 'open_book')).isTrue();
236+
check(matchesName('boo', 'open_book')).isTrue();
237+
check(matchesName('ok', 'open_book')).isTrue();
238+
});
239+
240+
test('multi-word query matches from start of a word', () {
241+
check(matchesName('open_', 'open_book')).isTrue();
242+
check(matchesName('open_b', 'open_book')).isTrue();
243+
check(matchesName('open_book', 'open_book')).isTrue();
244+
245+
check(matchesName('pen_', 'open_book')).isFalse();
246+
check(matchesName('n_b', 'open_book')).isFalse();
247+
248+
check(matchesName('blue_dia', 'large_blue_diamond')).isTrue();
249+
});
250+
251+
test('spaces in query behave as underscores', () {
252+
check(matchesName('open ', 'open_book')).isTrue();
253+
check(matchesName('open b', 'open_book')).isTrue();
254+
check(matchesName('open book', 'open_book')).isTrue();
255+
256+
check(matchesName('pen ', 'open_book')).isFalse();
257+
check(matchesName('n b', 'open_book')).isFalse();
258+
259+
check(matchesName('blue dia', 'large_blue_diamond')).isTrue();
260+
});
261+
262+
test('query is lower-cased', () {
263+
check(matchesName('Smi', 'smile')).isTrue();
264+
});
265+
266+
test('query matches aliases same way as primary name', () {
267+
bool matchesNames(String query, List<String> names) {
268+
return EmojiAutocompleteQuery(query).matches(unicode(names));
269+
}
270+
271+
check(matchesNames('a', ['a', 'b'])).isTrue();
272+
check(matchesNames('b', ['a', 'b'])).isTrue();
273+
check(matchesNames('c', ['a', 'b'])).isFalse();
274+
275+
check(matchesNames('pe', ['x', 'open_book'])).isTrue();
276+
check(matchesNames('ok', ['x', 'open_book'])).isTrue();
277+
278+
check(matchesNames('open_', ['x', 'open_book'])).isTrue();
279+
check(matchesNames('open b', ['x', 'open_book'])).isTrue();
280+
check(matchesNames('pen_', ['x', 'open_book'])).isFalse();
281+
282+
check(matchesNames('Smi', ['x', 'smile'])).isTrue();
283+
});
284+
285+
test('query matches literal Unicode value', () {
286+
bool matchesLiteral(String query, String emojiCode, {required String aka}) {
287+
assert(aka == query);
288+
return EmojiAutocompleteQuery(query)
289+
.matches(unicode(['asdf'], emojiCode: emojiCode));
290+
}
291+
292+
// Matching the code, in hex, doesn't count.
293+
check(matchesLiteral('1f642', aka: '1f642', '1f642')).isFalse();
294+
295+
// Matching the Unicode value the code describes does count…
296+
check(matchesLiteral('🙂', aka: '\u{1f642}', '1f642')).isTrue();
297+
// … and failing to match it doesn't make a match.
298+
check(matchesLiteral('🙁', aka: '\u{1f641}', '1f642')).isFalse();
299+
300+
// Multi-code-point emoji work fine.
301+
check(matchesLiteral('🏳‍🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
302+
'1f3f3-200d-1f308')).isTrue();
303+
// Only exact matches count; no partial matches.
304+
check(matchesLiteral('🏳', aka: '\u{1f3f3}',
305+
'1f3f3-200d-1f308')).isFalse();
306+
check(matchesLiteral('‍🌈', aka: '\u{200d}\u{1f308}',
307+
'1f3f3-200d-1f308')).isFalse();
308+
check(matchesLiteral('🏳‍🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}',
309+
'1f3f3')).isFalse();
310+
});
311+
312+
test('can match realm emoji', () {
313+
EmojiCandidate realmCandidate(String emojiName) {
314+
return EmojiCandidate(
315+
emojiType: ReactionType.realmEmoji,
316+
emojiCode: '1', emojiName: emojiName, aliases: null,
317+
emojiDisplay: ImageEmojiDisplay(
318+
emojiName: emojiName,
319+
resolvedUrl: eg.realmUrl.resolve('/emoji/1.png'),
320+
resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png')));
321+
}
322+
323+
check(EmojiAutocompleteQuery('eqeq')
324+
.matches(realmCandidate('eqeq'))).isTrue();
325+
check(EmojiAutocompleteQuery('open_')
326+
.matches(realmCandidate('open_book'))).isTrue();
327+
check(EmojiAutocompleteQuery('n_b')
328+
.matches(realmCandidate('open_book'))).isFalse();
329+
check(EmojiAutocompleteQuery('blue dia')
330+
.matches(realmCandidate('large_blue_diamond'))).isTrue();
331+
check(EmojiAutocompleteQuery('Smi')
332+
.matches(realmCandidate('smile'))).isTrue();
333+
});
334+
335+
test('can match Zulip extra emoji', () {
336+
final store = eg.store();
337+
final zulipCandidate = EmojiCandidate(
338+
emojiType: ReactionType.zulipExtraEmoji,
339+
emojiCode: 'zulip', emojiName: 'zulip', aliases: null,
340+
emojiDisplay: store.emojiDisplayFor(
341+
emojiType: ReactionType.zulipExtraEmoji,
342+
emojiCode: 'zulip', emojiName: 'zulip'));
343+
344+
check(EmojiAutocompleteQuery('z').matches(zulipCandidate)).isTrue();
345+
check(EmojiAutocompleteQuery('Zulip').matches(zulipCandidate)).isTrue();
346+
check(EmojiAutocompleteQuery('p').matches(zulipCandidate)).isTrue();
347+
check(EmojiAutocompleteQuery('x').matches(zulipCandidate)).isFalse();
348+
});
349+
});
205350
}
206351

207352
extension EmojiDisplayChecks on Subject<EmojiDisplay> {

0 commit comments

Comments
 (0)