Skip to content

Commit 4c25048

Browse files
committed
wip emojiCandidatesMatching; TODO maybe use autocomplete machinery; TODO test
1 parent 75d15e6 commit 4c25048

File tree

3 files changed

+162
-11
lines changed

3 files changed

+162
-11
lines changed

lib/model/emoji.dart

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:collection/collection.dart';
2+
13
import '../api/model/events.dart';
24
import '../api/model/initial_snapshot.dart';
35
import '../api/model/model.dart';
@@ -51,6 +53,39 @@ class TextEmojiDisplay extends EmojiDisplay {
5153
TextEmojiDisplay({required super.emojiName});
5254
}
5355

56+
/// An emoji that might be offered in an emoji picker UI.
57+
final class EmojiCandidate {
58+
/// The Zulip "emoji type" for this emoji.
59+
final ReactionType emojiType;
60+
61+
/// The Zulip "emoji code" for this emoji.
62+
///
63+
/// This is the value that would appear in [Reaction.emojiCode].
64+
final String emojiCode;
65+
66+
/// The Zulip "emoji name" to use for this emoji.
67+
///
68+
/// This might not be the only name this emoji has; see [aliases].
69+
final String emojiName;
70+
71+
/// Additional Zulip "emoji name" values for this emoji,
72+
/// to show in the emoji picker UI.
73+
List<String> get aliases => _aliases ?? const [];
74+
List<String>? _aliases;
75+
76+
void addAlias(String alias) => (_aliases ??= []).add(alias);
77+
78+
final EmojiDisplay emojiDisplay;
79+
80+
EmojiCandidate({
81+
required this.emojiType,
82+
required this.emojiCode,
83+
required this.emojiName,
84+
required List<String>? aliases,
85+
required this.emojiDisplay,
86+
}) : _aliases = aliases;
87+
}
88+
5489
/// The portion of [PerAccountStore] describing what emoji exist.
5590
mixin EmojiStore {
5691
/// The realm's custom emoji (for [ReactionType.realmEmoji],
@@ -63,9 +98,11 @@ mixin EmojiStore {
6398
required String emojiName,
6499
});
65100

101+
Iterable<EmojiCandidate> emojiCandidatesMatching(String query);
102+
66103
// TODO cut debugServerEmojiData once we can query for lists of emoji;
67104
// have tests make those queries end-to-end
68-
Map<String, List<String>> get debugServerEmojiData;
105+
Map<String, List<String>>? get debugServerEmojiData;
69106

70107
void setServerEmojiData(ServerEmojiData data);
71108
}
@@ -79,7 +116,7 @@ class EmojiStoreImpl with EmojiStore {
79116
EmojiStoreImpl({
80117
required this.realmUrl,
81118
required this.realmEmoji,
82-
}) : _serverEmojiData = {}; // TODO(#974) maybe start from a hard-coded baseline
119+
}) : _serverEmojiData = null; // TODO(#974) maybe start from a hard-coded baseline
83120

84121
/// The same as [PerAccountStore.realmUrl].
85122
final Uri realmUrl;
@@ -139,16 +176,127 @@ class EmojiStoreImpl with EmojiStore {
139176
}
140177

141178
@override
142-
Map<String, List<String>> get debugServerEmojiData => _serverEmojiData;
179+
Map<String, List<String>>? get debugServerEmojiData => _serverEmojiData;
180+
181+
/// The server's list of Unicode emoji and names for them,
182+
/// from [ServerEmojiData].
183+
///
184+
/// This is null until [UpdateMachine.fetchEmojiData] finishes
185+
/// retrieving the data.
186+
Map<String, List<String>>? _serverEmojiData;
187+
188+
List<EmojiCandidate>? _allEmojiCandidates;
189+
190+
EmojiCandidate _emojiCandidateFor({
191+
required ReactionType emojiType,
192+
required String emojiCode,
193+
required String emojiName,
194+
required List<String>? aliases,
195+
}) {
196+
return EmojiCandidate(
197+
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName,
198+
aliases: aliases,
199+
emojiDisplay: emojiDisplayFor(
200+
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName));
201+
}
202+
203+
List<EmojiCandidate> _generateAllCandidates() {
204+
final results = <EmojiCandidate>[];
205+
206+
final namesOverridden = {
207+
for (final emoji in realmEmoji.values) emoji.name,
208+
'zulip',
209+
};
210+
// TODO if _serverEmojiData missing, tell user data is missing
211+
for (final entry in (_serverEmojiData ?? {}).entries) {
212+
final allNames = entry.value;
213+
final String emojiName;
214+
final List<String>? aliases;
215+
if (allNames.any(namesOverridden.contains)) {
216+
final names = allNames.whereNot(namesOverridden.contains).toList();
217+
if (names.isEmpty) continue;
218+
emojiName = names.removeAt(0);
219+
aliases = names;
220+
} else {
221+
// Most emoji aren't overridden, so avoid copying the list.
222+
emojiName = allNames.first;
223+
aliases = allNames.length > 1 ? allNames.sublist(1) : null;
224+
}
225+
results.add(_emojiCandidateFor(
226+
emojiType: ReactionType.unicodeEmoji,
227+
emojiCode: entry.key, emojiName: emojiName,
228+
aliases: aliases));
229+
}
230+
231+
for (final entry in realmEmoji.entries) {
232+
final emojiName = entry.value.name;
233+
if (emojiName == 'zulip') {
234+
// TODO does 'zulip' really override realm emoji?
235+
// (This is copied from zulip-mobile's behavior.)
236+
continue;
237+
}
238+
results.add(_emojiCandidateFor(
239+
emojiType: ReactionType.realmEmoji,
240+
emojiCode: entry.key, emojiName: emojiName,
241+
aliases: null));
242+
}
243+
244+
results.add(_emojiCandidateFor(
245+
emojiType: ReactionType.zulipExtraEmoji,
246+
emojiCode: 'zulip', emojiName: 'zulip',
247+
aliases: null));
248+
249+
return results;
250+
}
251+
252+
// Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts .
253+
bool _nameMatches(String emojiName, String adjustedQuery) {
254+
// TODO this assumes emojiName is already lower-case (and no diacritics)
255+
const String separator = '_';
256+
257+
if (!adjustedQuery.contains(separator)) {
258+
// If the query is a single token (doesn't contain a separator),
259+
// the match can be anywhere in the string.
260+
return emojiName.contains(adjustedQuery);
261+
}
262+
263+
// If there is a separator in the query, then we
264+
// require the match to start at the start of a token.
265+
// (E.g. for 'ab_cd_ef', query could be 'ab_c' or 'cd_ef',
266+
// but not 'b_cd_ef'.)
267+
return emojiName.startsWith(adjustedQuery)
268+
|| emojiName.contains(separator + adjustedQuery);
269+
}
270+
271+
// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .
272+
bool _candidateMatches(EmojiCandidate candidate, String adjustedQuery) {
273+
if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) {
274+
if (adjustedQuery == emojiUnicode) return true;
275+
}
276+
return _nameMatches(candidate.emojiName, adjustedQuery)
277+
|| candidate.aliases.any((alias) => _nameMatches(alias, adjustedQuery));
278+
}
279+
280+
@override
281+
Iterable<EmojiCandidate> emojiCandidatesMatching(String query) {
282+
_allEmojiCandidates ??= _generateAllCandidates();
283+
if (query.isEmpty) {
284+
return _allEmojiCandidates!;
285+
}
143286

144-
// (Note this may be out of date; [UpdateMachine.fetchEmojiData]
145-
// sets it only after the store has been created.)
146-
// ignore: unused_field
147-
Map<String, List<String>> _serverEmojiData;
287+
final adjustedQuery = query.toLowerCase().replaceAll(' ', '_'); // TODO remove diacritics too
288+
final results = <EmojiCandidate>[];
289+
for (final candidate in _allEmojiCandidates!) {
290+
if (!_candidateMatches(candidate, adjustedQuery)) continue;
291+
results.add(candidate); // TODO reduce aliases to just matches
292+
}
293+
return results;
294+
}
148295

149296
@override
150297
void setServerEmojiData(ServerEmojiData data) {
151298
_serverEmojiData = data.codeToNames;
299+
_allEmojiCandidates = null;
152300
}
153301

154302
void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) {

lib/model/store.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,14 +347,17 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
347347
}
348348

349349
@override
350-
Map<String, List<String>> get debugServerEmojiData => _emoji.debugServerEmojiData;
350+
Map<String, List<String>>? get debugServerEmojiData => _emoji.debugServerEmojiData;
351351

352352
@override
353353
void setServerEmojiData(ServerEmojiData data) {
354354
_emoji.setServerEmojiData(data);
355355
notifyListeners();
356356
}
357357

358+
@override
359+
Iterable<EmojiCandidate> emojiCandidatesMatching(String query) =>
360+
_emoji.emojiCandidatesMatching(query);
358361

359362
EmojiStoreImpl _emoji;
360363

test/model/store_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ void main() {
345345

346346
test('happy case', () => awaitFakeAsync((async) async {
347347
prepareStore();
348-
check(store.debugServerEmojiData).isEmpty();
348+
check(store.debugServerEmojiData).isNull();
349349

350350
connection.prepare(json: ServerEmojiData(codeToNames: data).toJson());
351351
await updateMachine.fetchEmojiData(emojiDataUrl);
@@ -355,7 +355,7 @@ void main() {
355355

356356
test('retries on failure', () => awaitFakeAsync((async) async {
357357
prepareStore();
358-
check(store.debugServerEmojiData).isEmpty();
358+
check(store.debugServerEmojiData).isNull();
359359

360360
// Try to fetch, inducing an error in the request.
361361
connection.prepare(exception: Exception('failed'));
@@ -365,7 +365,7 @@ void main() {
365365
async.flushMicrotasks();
366366
checkLastRequest();
367367
check(complete).isFalse();
368-
check(store.debugServerEmojiData).isEmpty();
368+
check(store.debugServerEmojiData).isNull();
369369

370370
// The retry doesn't happen immediately; there's a timer.
371371
check(async.pendingTimers).length.equals(1);

0 commit comments

Comments
 (0)