Skip to content

Commit ecd2cb5

Browse files
committed
emoji: Make list of emoji to consider for autocomplete or emoji picker
This leaves the emojiDisplay field of these objects untested. I skipped that because it seems like pretty boring low-risk code, just invoking emojiDisplayFor. (And emojiDisplayFor has its own tests.) But included a TODO comment for completeness in thinking about what logic there is to test here. Fixes: #669
1 parent a2399a8 commit ecd2cb5

File tree

3 files changed

+246
-0
lines changed

3 files changed

+246
-0
lines changed

lib/model/emoji.dart

Lines changed: 106 additions & 0 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,37 @@ 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+
Iterable<String> get aliases => _aliases ?? const [];
74+
final List<String>? _aliases;
75+
76+
final EmojiDisplay emojiDisplay;
77+
78+
EmojiCandidate({
79+
required this.emojiType,
80+
required this.emojiCode,
81+
required this.emojiName,
82+
required List<String>? aliases,
83+
required this.emojiDisplay,
84+
}) : _aliases = aliases;
85+
}
86+
5487
/// The portion of [PerAccountStore] describing what emoji exist.
5588
mixin EmojiStore {
5689
/// The realm's custom emoji (for [ReactionType.realmEmoji],
@@ -63,6 +96,8 @@ mixin EmojiStore {
6396
required String emojiName,
6497
});
6598

99+
Iterable<EmojiCandidate> allEmojiCandidates();
100+
66101
// TODO cut debugServerEmojiData once we can query for lists of emoji;
67102
// have tests make those queries end-to-end
68103
Map<String, List<String>>? get debugServerEmojiData;
@@ -148,12 +183,83 @@ class EmojiStoreImpl with EmojiStore {
148183
/// retrieving the data.
149184
Map<String, List<String>>? _serverEmojiData;
150185

186+
List<EmojiCandidate>? _allEmojiCandidates;
187+
188+
EmojiCandidate _emojiCandidateFor({
189+
required ReactionType emojiType,
190+
required String emojiCode,
191+
required String emojiName,
192+
required List<String>? aliases,
193+
}) {
194+
return EmojiCandidate(
195+
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName,
196+
aliases: aliases,
197+
emojiDisplay: emojiDisplayFor(
198+
emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName));
199+
}
200+
201+
List<EmojiCandidate> _generateAllCandidates() {
202+
final results = <EmojiCandidate>[];
203+
204+
final namesOverridden = {
205+
for (final emoji in realmEmoji.values) emoji.name,
206+
'zulip',
207+
};
208+
// TODO(log) if _serverEmojiData missing
209+
for (final entry in (_serverEmojiData ?? {}).entries) {
210+
final allNames = entry.value;
211+
final String emojiName;
212+
final List<String>? aliases;
213+
if (allNames.any(namesOverridden.contains)) {
214+
final names = allNames.whereNot(namesOverridden.contains).toList();
215+
if (names.isEmpty) continue;
216+
emojiName = names.removeAt(0);
217+
aliases = names;
218+
} else {
219+
// Most emoji aren't overridden, so avoid copying the list.
220+
emojiName = allNames.first;
221+
aliases = allNames.length > 1 ? allNames.sublist(1) : null;
222+
}
223+
results.add(_emojiCandidateFor(
224+
emojiType: ReactionType.unicodeEmoji,
225+
emojiCode: entry.key, emojiName: emojiName,
226+
aliases: aliases));
227+
}
228+
229+
for (final entry in realmEmoji.entries) {
230+
final emojiName = entry.value.name;
231+
if (emojiName == 'zulip') {
232+
// TODO does 'zulip' really override realm emoji?
233+
// (This is copied from zulip-mobile's behavior.)
234+
continue;
235+
}
236+
results.add(_emojiCandidateFor(
237+
emojiType: ReactionType.realmEmoji,
238+
emojiCode: entry.key, emojiName: emojiName,
239+
aliases: null));
240+
}
241+
242+
results.add(_emojiCandidateFor(
243+
emojiType: ReactionType.zulipExtraEmoji,
244+
emojiCode: 'zulip', emojiName: 'zulip',
245+
aliases: null));
246+
247+
return results;
248+
}
249+
250+
@override
251+
Iterable<EmojiCandidate> allEmojiCandidates() {
252+
return _allEmojiCandidates ??= _generateAllCandidates();
253+
}
254+
151255
@override
152256
void setServerEmojiData(ServerEmojiData data) {
153257
_serverEmojiData = data.codeToNames;
258+
_allEmojiCandidates = null;
154259
}
155260

156261
void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) {
157262
realmEmoji = event.realmEmoji;
263+
_allEmojiCandidates = null;
158264
}
159265
}

lib/model/store.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,9 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
408408
notifyListeners();
409409
}
410410

411+
@override
412+
Iterable<EmojiCandidate> allEmojiCandidates() => _emoji.allEmojiCandidates();
413+
411414
EmojiStoreImpl _emoji;
412415

413416
////////////////////////////////

test/model/emoji_test.dart

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import 'package:checks/checks.dart';
22
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/api/model/events.dart';
34
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/api/route/realm.dart';
46
import 'package:zulip/model/emoji.dart';
7+
import 'package:zulip/model/store.dart';
58

69
import '../example_data.dart' as eg;
710

@@ -73,6 +76,132 @@ void main() {
7376
..resolvedStillUrl.isNull();
7477
});
7578
});
79+
80+
Condition<Object?> isUnicodeCandidate(String? emojiCode, List<String>? names) {
81+
return (it_) {
82+
final it = it_.isA<EmojiCandidate>();
83+
it.emojiType.equals(ReactionType.unicodeEmoji);
84+
if (emojiCode != null) it.emojiCode.equals(emojiCode);
85+
if (names != null) {
86+
it.emojiName.equals(names.first);
87+
it.aliases.deepEquals(names.sublist(1));
88+
}
89+
};
90+
}
91+
92+
Condition<Object?> isRealmCandidate({String? emojiCode, String? emojiName}) {
93+
return (it_) {
94+
final it = it_.isA<EmojiCandidate>();
95+
it.emojiType.equals(ReactionType.realmEmoji);
96+
if (emojiCode != null) it.emojiCode.equals(emojiCode);
97+
if (emojiName != null) it.emojiName.equals(emojiName);
98+
it.aliases.isEmpty();
99+
};
100+
}
101+
102+
Condition<Object?> isZulipCandidate() {
103+
return (it) => it.isA<EmojiCandidate>()
104+
..emojiType.equals(ReactionType.zulipExtraEmoji)
105+
..emojiCode.equals('zulip')
106+
..emojiName.equals('zulip')
107+
..aliases.isEmpty();
108+
}
109+
110+
group('allEmojiCandidates', () {
111+
// TODO test emojiDisplay of candidates matches emojiDisplayFor
112+
113+
PerAccountStore prepare({
114+
Map<String, RealmEmojiItem> realmEmoji = const {},
115+
Map<String, List<String>>? unicodeEmoji,
116+
}) {
117+
final store = eg.store(
118+
initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji));
119+
if (unicodeEmoji != null) {
120+
store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji));
121+
}
122+
return store;
123+
}
124+
125+
test('realm emoji overrides Unicode emoji', () {
126+
final store = prepare(realmEmoji: {
127+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'smiley'),
128+
}, unicodeEmoji: {
129+
'1f642': ['smile'],
130+
'1f603': ['smiley'],
131+
});
132+
check(store.allEmojiCandidates()).deepEquals([
133+
isUnicodeCandidate('1f642', ['smile']),
134+
isRealmCandidate(emojiCode: '1', emojiName: 'smiley'),
135+
isZulipCandidate(),
136+
]);
137+
});
138+
139+
test('Unicode emoji with overridden aliases survives with remaining names', () {
140+
final store = prepare(realmEmoji: {
141+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'tangerine'),
142+
}, unicodeEmoji: {
143+
'1f34a': ['orange', 'tangerine', 'mandarin'],
144+
});
145+
check(store.allEmojiCandidates()).deepEquals([
146+
isUnicodeCandidate('1f34a', ['orange', 'mandarin']),
147+
isRealmCandidate(emojiCode: '1', emojiName: 'tangerine'),
148+
isZulipCandidate(),
149+
]);
150+
});
151+
152+
test('Unicode emoji with overridden primary name survives with remaining names', () {
153+
final store = prepare(realmEmoji: {
154+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'orange'),
155+
}, unicodeEmoji: {
156+
'1f34a': ['orange', 'tangerine', 'mandarin'],
157+
});
158+
check(store.allEmojiCandidates()).deepEquals([
159+
isUnicodeCandidate('1f34a', ['tangerine', 'mandarin']),
160+
isRealmCandidate(emojiCode: '1', emojiName: 'orange'),
161+
isZulipCandidate(),
162+
]);
163+
});
164+
165+
test('updates on setServerEmojiData', () {
166+
final store = prepare();
167+
check(store.allEmojiCandidates()).deepEquals([
168+
isZulipCandidate(),
169+
]);
170+
171+
store.setServerEmojiData(ServerEmojiData(codeToNames: {
172+
'1f642': ['smile'],
173+
}));
174+
check(store.allEmojiCandidates()).deepEquals([
175+
isUnicodeCandidate('1f642', ['smile']),
176+
isZulipCandidate(),
177+
]);
178+
});
179+
180+
test('updates on RealmEmojiUpdateEvent', () {
181+
final store = prepare();
182+
check(store.allEmojiCandidates()).deepEquals([
183+
isZulipCandidate(),
184+
]);
185+
186+
store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: {
187+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'),
188+
}));
189+
check(store.allEmojiCandidates()).deepEquals([
190+
isRealmCandidate(emojiCode: '1', emojiName: 'happy'),
191+
isZulipCandidate(),
192+
]);
193+
});
194+
195+
test('memoizes result', () {
196+
final store = prepare(realmEmoji: {
197+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'),
198+
}, unicodeEmoji: {
199+
'1f642': ['smile'],
200+
});
201+
final candidates = store.allEmojiCandidates();
202+
check(store.allEmojiCandidates()).identicalTo(candidates);
203+
});
204+
});
76205
}
77206

78207
extension EmojiDisplayChecks on Subject<EmojiDisplay> {
@@ -87,3 +216,11 @@ extension ImageEmojiDisplayChecks on Subject<ImageEmojiDisplay> {
87216
Subject<Uri> get resolvedUrl => has((x) => x.resolvedUrl, 'resolvedUrl');
88217
Subject<Uri?> get resolvedStillUrl => has((x) => x.resolvedStillUrl, 'resolvedStillUrl');
89218
}
219+
220+
extension EmojiCandidateChecks on Subject<EmojiCandidate> {
221+
Subject<ReactionType> get emojiType => has((x) => x.emojiType, 'emojiType');
222+
Subject<String> get emojiCode => has((x) => x.emojiCode, 'emojiCode');
223+
Subject<String> get emojiName => has((x) => x.emojiName, 'emojiName');
224+
Subject<Iterable<String>> get aliases => has((x) => x.aliases, 'aliases');
225+
Subject<EmojiDisplay> get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay');
226+
}

0 commit comments

Comments
 (0)