From 0fd1d648bc08c708c8f18872f495f5877aac61b6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 20 Nov 2024 17:04:36 -0800 Subject: [PATCH] emoji: Finish emoji autocomplete for compose box Fixes: #670 --- lib/model/emoji.dart | 4 +- lib/widgets/autocomplete.dart | 54 +++++++++++-- test/widgets/autocomplete_test.dart | 119 +++++++++++++++++++++++++--- 3 files changed, 159 insertions(+), 18 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 3a92c7373d..d3fcaad67f 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -307,8 +307,8 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery { } @override - ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { - throw UnimplementedError(); // TODO(#670) + EmojiAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { + return EmojiAutocompleteView.init(store: store, query: this); } // Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts . diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 407be7f3f2..ba0d003a8f 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../model/emoji.dart'; import 'content.dart'; +import 'emoji.dart'; import 'store.dart'; import '../model/autocomplete.dart'; import '../model/compose.dart'; @@ -39,8 +40,7 @@ class _AutocompleteFieldState _MentionAutocompleteItem(option: option), - EmojiAutocompleteResult() => throw UnimplementedError(), // TODO(#670) + EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( onTap: () { @@ -247,6 +247,50 @@ class _MentionAutocompleteItem extends StatelessWidget { } } +class _EmojiAutocompleteItem extends StatelessWidget { + const _EmojiAutocompleteItem({required this.option}); + + final EmojiAutocompleteResult option; + + static const _size = 32.0; + static const _notoColorEmojiTextSize = 25.7; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final candidate = option.candidate; + + final emojiDisplay = candidate.emojiDisplay.resolve(store.userSettings); + final Widget? glyph = switch (emojiDisplay) { + ImageEmojiDisplay() => + ImageEmojiWidget(size: _size, emojiDisplay: emojiDisplay), + UnicodeEmojiDisplay() => + UnicodeEmojiWidget( + size: _size, notoColorEmojiTextSize: _notoColorEmojiTextSize, + emojiDisplay: emojiDisplay), + TextEmojiDisplay() => null, // The text is already shown separately. + }; + + final label = candidate.aliases.isEmpty + ? candidate.emojiName + : [candidate.emojiName, ...candidate.aliases].join(", "); // TODO(#1080) + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row(children: [ + if (glyph != null) ...[ + glyph, + const SizedBox(width: 8), + ], + Expanded( + child: Text( + maxLines: 2, + overflow: TextOverflow.ellipsis, + label)), + ])); + } +} + class TopicAutocomplete extends AutocompleteField { const TopicAutocomplete({ super.key, diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index c2de951aad..24ae2dba83 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -1,10 +1,14 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/api/route/realm.dart'; import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -28,7 +32,7 @@ import 'test_app.dart'; /// The caller must set [debugNetworkImageHttpClientProvider] back to null /// before the end of the test. Future setupToComposeInput(WidgetTester tester, { - required List users, + List users = const [], }) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); @@ -108,19 +112,20 @@ Future setupToTopicInput(WidgetTester tester, { return finder; } -void main() { - TestZulipBinding.ensureInitialized(); +Finder findNetworkImage(String url) { + return find.byWidgetPredicate((widget) => switch(widget) { + Image(image: NetworkImage(url: var imageUrl)) when imageUrl == url + => true, + _ => false, + }); +} - group('ComposeAutocomplete', () { +typedef ExpectedEmoji = (String label, EmojiDisplay display); - Finder findNetworkImage(String url) { - return find.byWidgetPredicate((widget) => switch(widget) { - Image(image: NetworkImage(url: var imageUrl)) when imageUrl == url - => true, - _ => false, - }); - } +void main() { + TestZulipBinding.ensureInitialized(); + group('@-mentions', () { void checkUserShown(User user, PerAccountStore store, {required bool expected}) { check(find.text(user.fullName).evaluate().length).equals(expected ? 1 : 0); final avatarFinder = @@ -174,6 +179,98 @@ void main() { }); }); + group('emoji', () { + void checkEmojiShown(ExpectedEmoji option, {required bool expected}) { + final (label, display) = option; + final labelSubject = check(find.text(label)); + expected ? labelSubject.findsOne() : labelSubject.findsNothing(); + + final Subject displaySubject; + switch (display) { + case UnicodeEmojiDisplay(): + displaySubject = check(find.text(display.emojiUnicode)); + case ImageEmojiDisplay(): + displaySubject = check(findNetworkImage(display.resolvedUrl.toString())); + case TextEmojiDisplay(): + // We test this case in the "text emoji" test below, + // but that doesn't use this helper method. + throw UnimplementedError(); + } + expected ? displaySubject.findsOne(): displaySubject.findsNothing(); + } + + testWidgets('show, update, choose', (tester) async { + final composeInputFinder = await setupToComposeInput(tester); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store.setServerEmojiData(ServerEmojiData(codeToNames: { + '1f4a4': ['zzz', 'sleepy'], // (just 'zzz' in real data) + })); + await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'buzzing'), + })); + + final zulipOption = ('zulip', store.emojiDisplayFor( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', emojiName: 'zulip')); + final buzzingOption = ('buzzing', store.emojiDisplayFor( + emojiType: ReactionType.realmEmoji, + emojiCode: '1', emojiName: 'buzzing')); + final zzzOption = ('zzz, sleepy', store.emojiDisplayFor( + emojiType: ReactionType.unicodeEmoji, + emojiCode: '1f4a4', emojiName: 'zzz')); + + // Enter a query; options appear, of all three emoji types. + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hi :'); + await tester.enterText(composeInputFinder, 'hi :z'); + await tester.pump(); + checkEmojiShown(expected: true, zzzOption); + checkEmojiShown(expected: true, buzzingOption); + checkEmojiShown(expected: true, zulipOption); + + // Edit query; options change. + await tester.enterText(composeInputFinder, 'hi :zz'); + await tester.pump(); + checkEmojiShown(expected: true, zzzOption); + checkEmojiShown(expected: true, buzzingOption); + checkEmojiShown(expected: false, zulipOption); + + // Choosing an option enters result and closes autocomplete. + await tester.tap(find.text('buzzing')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .equals('hi :buzzing:'); + checkEmojiShown(expected: false, zzzOption); + checkEmojiShown(expected: false, buzzingOption); + checkEmojiShown(expected: false, zulipOption); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('text emoji means just show text', (tester) async { + final composeInputFinder = await setupToComposeInput(tester); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + await store.handleEvent(UserSettingsUpdateEvent(id: 1, + property: UserSettingName.emojiset, value: Emojiset.text)); + + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hi :'); + await tester.enterText(composeInputFinder, 'hi :z'); + await tester.pump(); + + // The emoji's name appears. (And only once.) + check(find.text('zulip')).findsOne(); + + // But no emoji image appears. + check(find.byWidgetPredicate((widget) => switch(widget) { + Image(image: NetworkImage()) => true, + _ => false, + })).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('TopicAutocomplete', () { void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) { check(find.text(topic.name).evaluate().length).equals(expected ? 1 : 0);