Skip to content

Commit 0fd1d64

Browse files
committed
emoji: Finish emoji autocomplete for compose box
Fixes: #670
1 parent 8d84178 commit 0fd1d64

File tree

3 files changed

+159
-18
lines changed

3 files changed

+159
-18
lines changed

lib/model/emoji.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
307307
}
308308

309309
@override
310-
ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) {
311-
throw UnimplementedError(); // TODO(#670)
310+
EmojiAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) {
311+
return EmojiAutocompleteView.init(store: store, query: this);
312312
}
313313

314314
// Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts .

lib/widgets/autocomplete.dart

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22

33
import '../model/emoji.dart';
44
import 'content.dart';
5+
import 'emoji.dart';
56
import 'store.dart';
67
import '../model/autocomplete.dart';
78
import '../model/compose.dart';
@@ -39,8 +40,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
3940
}
4041

4142
void _handleControllerChange() {
42-
var newQuery = widget.autocompleteIntent()?.query;
43-
if (newQuery is EmojiAutocompleteQuery) newQuery = null; // TODO(#670)
43+
final newQuery = widget.autocompleteIntent()?.query;
4444
// First, tear down the old view-model if necessary.
4545
if (_viewModel != null
4646
&& (newQuery == null
@@ -189,8 +189,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
189189
final store = PerAccountStoreWidget.of(context);
190190
final String replacementString;
191191
switch (option) {
192-
case EmojiAutocompleteResult():
193-
throw UnimplementedError(); // TODO(#670)
192+
case EmojiAutocompleteResult(:var candidate):
193+
replacementString = ':${candidate.emojiName}:';
194194
case UserMentionAutocompleteResult(:var userId):
195195
if (query is! MentionAutocompleteQuery) {
196196
return; // Shrug; similar to `intent == null` case above.
@@ -212,7 +212,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
212212
Widget buildItem(BuildContext context, int index, ComposeAutocompleteResult option) {
213213
final child = switch (option) {
214214
MentionAutocompleteResult() => _MentionAutocompleteItem(option: option),
215-
EmojiAutocompleteResult() => throw UnimplementedError(), // TODO(#670)
215+
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
216216
};
217217
return InkWell(
218218
onTap: () {
@@ -247,6 +247,50 @@ class _MentionAutocompleteItem extends StatelessWidget {
247247
}
248248
}
249249

250+
class _EmojiAutocompleteItem extends StatelessWidget {
251+
const _EmojiAutocompleteItem({required this.option});
252+
253+
final EmojiAutocompleteResult option;
254+
255+
static const _size = 32.0;
256+
static const _notoColorEmojiTextSize = 25.7;
257+
258+
@override
259+
Widget build(BuildContext context) {
260+
final store = PerAccountStoreWidget.of(context);
261+
final candidate = option.candidate;
262+
263+
final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings);
264+
final Widget? glyph = switch (emojiDisplay) {
265+
ImageEmojiDisplay() =>
266+
ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay),
267+
UnicodeEmojiDisplay() =>
268+
UnicodeEmojiWidget(
269+
size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize,
270+
emojiDisplay: emojiDisplay),
271+
TextEmojiDisplay() => null, // The text is already shown separately.
272+
};
273+
274+
final label = candidate.aliases.isEmpty
275+
? candidate.emojiName
276+
: [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080)
277+
278+
return Padding(
279+
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
280+
child: Row(children: [
281+
if (glyph != null) ...[
282+
glyph,
283+
const SizedBox(width: 8),
284+
],
285+
Expanded(
286+
child: Text(
287+
maxLines: 2,
288+
overflow: TextOverflow.ellipsis,
289+
label)),
290+
]));
291+
}
292+
}
293+
250294
class TopicAutocomplete extends AutocompleteField<TopicAutocompleteQuery, TopicAutocompleteResult> {
251295
const TopicAutocomplete({
252296
super.key,

test/widgets/autocomplete_test.dart

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import 'package:checks/checks.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:flutter_checks/flutter_checks.dart';
34
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:zulip/api/model/events.dart';
46
import 'package:zulip/api/model/model.dart';
57
import 'package:zulip/api/route/messages.dart';
68
import 'package:zulip/api/route/channels.dart';
9+
import 'package:zulip/api/route/realm.dart';
710
import 'package:zulip/model/compose.dart';
11+
import 'package:zulip/model/emoji.dart';
812
import 'package:zulip/model/localizations.dart';
913
import 'package:zulip/model/narrow.dart';
1014
import 'package:zulip/model/store.dart';
@@ -28,7 +32,7 @@ import 'test_app.dart';
2832
/// The caller must set [debugNetworkImageHttpClientProvider] back to null
2933
/// before the end of the test.
3034
Future<Finder> setupToComposeInput(WidgetTester tester, {
31-
required List<User> users,
35+
List<User> users = const [],
3236
}) async {
3337
TypingNotifier.debugEnable = false;
3438
addTearDown(TypingNotifier.debugReset);
@@ -108,19 +112,20 @@ Future<Finder> setupToTopicInput(WidgetTester tester, {
108112
return finder;
109113
}
110114

111-
void main() {
112-
TestZulipBinding.ensureInitialized();
115+
Finder findNetworkImage(String url) {
116+
return find.byWidgetPredicate((widget) => switch(widget) {
117+
Image(image: NetworkImage(url: var imageUrl)) when imageUrl == url
118+
=> true,
119+
_ => false,
120+
});
121+
}
113122

114-
group('ComposeAutocomplete', () {
123+
typedef ExpectedEmoji = (String label, EmojiDisplay display);
115124

116-
Finder findNetworkImage(String url) {
117-
return find.byWidgetPredicate((widget) => switch(widget) {
118-
Image(image: NetworkImage(url: var imageUrl)) when imageUrl == url
119-
=> true,
120-
_ => false,
121-
});
122-
}
125+
void main() {
126+
TestZulipBinding.ensureInitialized();
123127

128+
group('@-mentions', () {
124129
void checkUserShown(User user, PerAccountStore store, {required bool expected}) {
125130
check(find.text(user.fullName).evaluate().length).equals(expected ? 1 : 0);
126131
final avatarFinder =
@@ -174,6 +179,98 @@ void main() {
174179
});
175180
});
176181

182+
group('emoji', () {
183+
void checkEmojiShown(ExpectedEmoji option, {required bool expected}) {
184+
final (label, display) = option;
185+
final labelSubject = check(find.text(label));
186+
expected ? labelSubject.findsOne() : labelSubject.findsNothing();
187+
188+
final Subject<Finder> displaySubject;
189+
switch (display) {
190+
case UnicodeEmojiDisplay():
191+
displaySubject = check(find.text(display.emojiUnicode));
192+
case ImageEmojiDisplay():
193+
displaySubject = check(findNetworkImage(display.resolvedUrl.toString()));
194+
case TextEmojiDisplay():
195+
// We test this case in the "text emoji" test below,
196+
// but that doesn't use this helper method.
197+
throw UnimplementedError();
198+
}
199+
expected ? displaySubject.findsOne(): displaySubject.findsNothing();
200+
}
201+
202+
testWidgets('show, update, choose', (tester) async {
203+
final composeInputFinder = await setupToComposeInput(tester);
204+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
205+
store.setServerEmojiData(ServerEmojiData(codeToNames: {
206+
'1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data)
207+
}));
208+
await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: {
209+
'1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'),
210+
}));
211+
212+
final zulipOption = ('zulip', store.emojiDisplayFor(
213+
emojiType: ReactionType.zulipExtraEmoji,
214+
emojiCode: 'zulip', emojiName: 'zulip'));
215+
final buzzingOption = ('buzzing', store.emojiDisplayFor(
216+
emojiType: ReactionType.realmEmoji,
217+
emojiCode: '1', emojiName: 'buzzing'));
218+
final zzzOption = ('zzz, sleepy', store.emojiDisplayFor(
219+
emojiType: ReactionType.unicodeEmoji,
220+
emojiCode: '1f4a4', emojiName: 'zzz'));
221+
222+
// Enter a query; options appear, of all three emoji types.
223+
// TODO(#226): Remove this extra edit when this bug is fixed.
224+
await tester.enterText(composeInputFinder, 'hi :');
225+
await tester.enterText(composeInputFinder, 'hi :z');
226+
await tester.pump();
227+
checkEmojiShown(expected: true, zzzOption);
228+
checkEmojiShown(expected: true, buzzingOption);
229+
checkEmojiShown(expected: true, zulipOption);
230+
231+
// Edit query; options change.
232+
await tester.enterText(composeInputFinder, 'hi :zz');
233+
await tester.pump();
234+
checkEmojiShown(expected: true, zzzOption);
235+
checkEmojiShown(expected: true, buzzingOption);
236+
checkEmojiShown(expected: false, zulipOption);
237+
238+
// Choosing an option enters result and closes autocomplete.
239+
await tester.tap(find.text('buzzing'));
240+
await tester.pump();
241+
check(tester.widget<TextField>(composeInputFinder).controller!.text)
242+
.equals('hi :buzzing:');
243+
checkEmojiShown(expected: false, zzzOption);
244+
checkEmojiShown(expected: false, buzzingOption);
245+
checkEmojiShown(expected: false, zulipOption);
246+
247+
debugNetworkImageHttpClientProvider = null;
248+
});
249+
250+
testWidgets('text emoji means just show text', (tester) async {
251+
final composeInputFinder = await setupToComposeInput(tester);
252+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
253+
await store.handleEvent(UserSettingsUpdateEvent(id: 1,
254+
property: UserSettingName.emojiset, value: Emojiset.text));
255+
256+
// TODO(#226): Remove this extra edit when this bug is fixed.
257+
await tester.enterText(composeInputFinder, 'hi :');
258+
await tester.enterText(composeInputFinder, 'hi :z');
259+
await tester.pump();
260+
261+
// The emoji's name appears. (And only once.)
262+
check(find.text('zulip')).findsOne();
263+
264+
// But no emoji image appears.
265+
check(find.byWidgetPredicate((widget) => switch(widget) {
266+
Image(image: NetworkImage()) => true,
267+
_ => false,
268+
})).findsNothing();
269+
270+
debugNetworkImageHttpClientProvider = null;
271+
});
272+
});
273+
177274
group('TopicAutocomplete', () {
178275
void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) {
179276
check(find.text(topic.name).evaluate().length).equals(expected ? 1 : 0);

0 commit comments

Comments
 (0)