Skip to content

Commit

Permalink
wiplink emoji: Finish emoji autocomplete for compose box
Browse files Browse the repository at this point in the history
Fixes: 670
  • Loading branch information
gnprice committed Nov 21, 2024
1 parent 58a3906 commit e638dd8
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 18 deletions.
4 changes: 2 additions & 2 deletions lib/model/emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
54 changes: 49 additions & 5 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,8 +40,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
}

void _handleControllerChange() {
var newQuery = widget.autocompleteIntent()?.query;
if (newQuery is EmojiAutocompleteQuery) newQuery = null; // TODO(#670)
final newQuery = widget.autocompleteIntent()?.query;
// First, tear down the old view-model if necessary.
if (_viewModel != null
&& (newQuery == null
Expand Down Expand Up @@ -189,8 +189,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
final store = PerAccountStoreWidget.of(context);
final String replacementString;
switch (option) {
case EmojiAutocompleteResult():
throw UnimplementedError(); // TODO(#670)
case EmojiAutocompleteResult(:var candidate):
replacementString = ':${candidate.emojiName}:';
case UserMentionAutocompleteResult(:var userId):
if (query is! MentionAutocompleteQuery) {
return; // Shrug; similar to `intent == null` case above.
Expand All @@ -212,7 +212,7 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
Widget buildItem(BuildContext context, int index, ComposeAutocompleteResult option) {
final child = switch (option) {
MentionAutocompleteResult() => _MentionAutocompleteItem(option: option),
EmojiAutocompleteResult() => throw UnimplementedError(), // TODO(#670)
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
};
return InkWell(
onTap: () {
Expand Down Expand Up @@ -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(", ");

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<TopicAutocompleteQuery, TopicAutocompleteResult> {
const TopicAutocomplete({
super.key,
Expand Down
119 changes: 108 additions & 11 deletions test/widgets/autocomplete_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -28,7 +32,7 @@ import 'test_app.dart';
/// The caller must set [debugNetworkImageHttpClientProvider] back to null
/// before the end of the test.
Future<Finder> setupToComposeInput(WidgetTester tester, {
required List<User> users,
List<User> users = const [],
}) async {
TypingNotifier.debugEnable = false;
addTearDown(TypingNotifier.debugReset);
Expand Down Expand Up @@ -108,19 +112,20 @@ Future<Finder> 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 =
Expand Down Expand Up @@ -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<Finder> 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<TextField>(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);
Expand Down

0 comments on commit e638dd8

Please sign in to comment.