diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 9222a817a5..6007dd6730 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -5,11 +7,12 @@ import '../api/model/events.dart'; import '../api/model/model.dart'; import '../api/route/channels.dart'; import '../widgets/compose_box.dart'; +import 'emoji.dart'; import 'narrow.dart'; import 'store.dart'; extension ComposeContentAutocomplete on ComposeContentController { - AutocompleteIntent? autocompleteIntent() { + AutocompleteIntent? autocompleteIntent() { if (!selection.isValid || !selection.isNormalized) { // We don't require [isCollapsed] to be true because we've seen that // autocorrect and even backspace involve programmatically expanding the @@ -18,28 +21,52 @@ extension ComposeContentAutocomplete on ComposeContentController { // see below. return null; } + + // To avoid spending a lot of time searching for autocomplete intents + // in long messages, we bound how far back we look for the intent's start. + final earliest = max(0, selection.end - 30); + + if (selection.start < earliest) { + // The selection extends to before any position we'd consider + // for the start of the intent. So there can't be a match. + return null; + } + final textUntilCursor = text.substring(0, selection.end); - for ( - int position = selection.end - 1; - position >= 0 && (selection.end - position <= 30); - position-- - ) { - if (textUntilCursor[position] != '@') { + int pos; + for (pos = selection.end - 1; pos > selection.start; pos--) { + final charAtPos = textUntilCursor[pos]; + if (charAtPos == '@') { + final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + } else if (charAtPos == ':') { + final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + } else { continue; } - final match = mentionAutocompleteMarkerRegex.matchAsPrefix(textUntilCursor, position); - if (match == null) { + // See comment about [TextSelection.isCollapsed] above. + return null; + } + + for (; pos >= earliest; pos--) { + final charAtPos = textUntilCursor[pos]; + final ComposeAutocompleteQuery query; + if (charAtPos == '@') { + final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + query = MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_'); + } else if (charAtPos == ':') { + final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); + if (match == null) continue; + query = EmojiAutocompleteQuery(match[1]!); + } else { continue; } - if (selection.start < position) { - // See comment about [TextSelection.isCollapsed] above. - return null; - } - return AutocompleteIntent( - syntaxStart: position, - query: MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_'), - textEditingValue: value); + return AutocompleteIntent(syntaxStart: pos, textEditingValue: value, + query: query); } + return null; } } @@ -53,7 +80,7 @@ extension ComposeTopicAutocomplete on ComposeTopicController { } } -final RegExp mentionAutocompleteMarkerRegex = (() { +final RegExp _mentionIntentRegex = (() { // What's likely to come before an @-mention: the start of the string, // whitespace, or punctuation. Letters are unlikely; in that case an email // might be intended. (By punctuation, we mean *some* punctuation, like "(". @@ -78,6 +105,60 @@ final RegExp mentionAutocompleteMarkerRegex = (() { unicode: true); })(); +final RegExp _emojiIntentRegex = (() { + // Similar reasoning as in _mentionIntentRegex. + // Specifically forbid a preceding ":", though, to make "::" not a query. + const before = r'(?<=^|\s|\p{Punctuation})(? { AutocompleteIntent({ @@ -123,6 +204,7 @@ class AutocompleteIntent { class AutocompleteViewManager { final Set _mentionAutocompleteViews = {}; final Set _topicAutocompleteViews = {}; + final Set _emojiAutocompleteViews = {}; AutocompleteDataCache autocompleteDataCache = AutocompleteDataCache(); @@ -146,6 +228,16 @@ class AutocompleteViewManager { assert(removed); } + void registerEmojiAutocomplete(EmojiAutocompleteView view) { + final added = _emojiAutocompleteViews.add(view); + assert(added); + } + + void unregisterEmojiAutocomplete(EmojiAutocompleteView view) { + final removed = _emojiAutocompleteViews.remove(view); + assert(removed); + } + void handleRealmUserRemoveEvent(RealmUserRemoveEvent event) { autocompleteDataCache.invalidateUser(event.userId); } @@ -177,39 +269,71 @@ class AutocompleteViewManager { /// A view-model for an autocomplete interaction. /// +/// Subclasses correspond to subclasses of [AutocompleteQuery], +/// i.e. different types of autocomplete interaction initiated by the user. +/// Each subclass specifies the corresponding [AutocompleteQuery] subclass +/// as `QueryT`, +/// and the [AutocompleteResult] subclass in `ResultT` describes the +/// possible results that the user might choose in the autocomplete interaction. +/// +/// When an [AutocompleteView] is created, its constructor begins the search +/// for results corresponding to the initial [query]. +/// The query may later be updated, causing a new search. +/// /// The owner of one of these objects must call [dispose] when the object /// will no longer be used, in order to free resources on the [PerAccountStore]. /// /// Lifecycle: -/// * Create an instance of a concrete subtype. +/// * Create an instance of a concrete subtype, beginning a search /// * Add listeners with [addListener]. -/// * Use the [query] setter to start a search for a query. +/// * When the user edits the query, use the [query] setter to update the search. /// * On reassemble, call [reassemble]. /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. abstract class AutocompleteView extends ChangeNotifier { - AutocompleteView({required this.store}); + /// Construct a view-model for an autocomplete interaction, + /// and begin the search for the initial query. + AutocompleteView({required this.store, required QueryT query}) + : _query = query { + _startSearch(); + } final PerAccountStore store; - QueryT? get query => _query; - QueryT? _query; - set query(QueryT? query) { + /// True just if this [AutocompleteView] is of the appropriate type + /// to handle the given query. + bool acceptsQuery(AutocompleteQuery query) => query is QueryT; + + /// The last query this [AutocompleteView] was asked to perform for the user. + /// + /// If this view-model is currently performing a search, + /// the search is for this query. + /// If not, then [results] reflect this query. + /// + /// When set, the existing query is aborted if still in progress, + /// and a search for the new query is begun. + /// Any existing value in [results] remains until the new query finishes. + QueryT get query => _query; + QueryT _query; + set query(QueryT query) { _query = query; - if (query != null) { - _startSearch(); - } + _startSearch(); } /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo the search from scratch for the current query, if any. void reassemble() { - if (_query != null) { - _startSearch(); - } + _startSearch(); } + /// The latest set of results available for any value of [query]. + /// + /// When this changes, listeners will be notified with [notifyListeners]. + /// + /// These results might not correspond to the current value of [query], + /// if a search is still in progress. + /// Before any search completes, [results] will be empty. Iterable get results => _results; List _results = []; @@ -264,8 +388,7 @@ abstract class AutocompleteView candidates, required List results, }) async { - assert(_query != null); - final query = _query!; + final query = _query; final iterator = candidates.iterator; outer: while (true) { @@ -284,9 +407,16 @@ abstract class AutocompleteView; + +/// An [AutocompleteView] for an @-mention autocomplete interaction, +/// an example of a [ComposeAutocompleteView]. class MentionAutocompleteView extends AutocompleteView { MentionAutocompleteView._({ required super.store, + required super.query, required this.narrow, required this.sortedUsers, }); @@ -294,9 +424,11 @@ class MentionAutocompleteView extends AutocompleteView _lowercaseWords; /// Whether all of this query's words have matches in [words] that appear in order. @@ -523,12 +673,27 @@ abstract class AutocompleteQuery { } } -class MentionAutocompleteQuery extends AutocompleteQuery { +/// Any autocomplete query in the compose box's content input. +abstract class ComposeAutocompleteQuery extends AutocompleteQuery { + ComposeAutocompleteQuery(super.raw); + + /// Construct an [AutocompleteView] initialized with this query + /// and ready to handle queries of the same type. + ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow); +} + +/// A @-mention autocomplete query, used by [MentionAutocompleteView]. +class MentionAutocompleteQuery extends ComposeAutocompleteQuery { MentionAutocompleteQuery(super.raw, {this.silent = false}); /// Whether the user wants a silent mention (@_query, vs. @query). final bool silent; + @override + MentionAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { + return MentionAutocompleteView.init(store: store, narrow: narrow, query: this); + } + bool testUser(User user, AutocompleteDataCache cache) { // TODO(#236) test email too, not just name @@ -555,6 +720,10 @@ class MentionAutocompleteQuery extends AutocompleteQuery { int get hashCode => Object.hash('MentionAutocompleteQuery', raw, silent); } +/// Cached data that is used for autocomplete +/// but kept around in between autocomplete interactions. +/// +/// An instance of this class is managed by [AutocompleteViewManager]. class AutocompleteDataCache { final Map _normalizedNamesByUser = {}; @@ -575,10 +744,34 @@ class AutocompleteDataCache { } } +/// A result the user chose, or might choose, from an autocomplete interaction. +/// +/// Different subclasses of [AutocompleteView], +/// representing different types of autocomplete interaction, +/// have corresponding subclasses of [AutocompleteResult] they might produce. class AutocompleteResult {} -sealed class MentionAutocompleteResult extends AutocompleteResult {} +/// A result from some autocomplete interaction in +/// the compose box's content input, initiated by a [ComposeAutocompleteQuery] +/// and managed by some [ComposeAutocompleteView]. +sealed class ComposeAutocompleteResult extends AutocompleteResult {} + +/// An emoji chosen in an autocomplete interaction, via [EmojiAutocompleteView]. +class EmojiAutocompleteResult extends ComposeAutocompleteResult { + EmojiAutocompleteResult(this.candidate); + + final EmojiCandidate candidate; +} + +/// A result from an @-mention autocomplete interaction, +/// managed by a [MentionAutocompleteView]. +/// +/// This is abstract because there are several kinds of result +/// that can all be offered in the same @-mention autocomplete interaction: +/// a user, a wildcard, or a user group. +sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {} +/// An autocomplete result for an @-mention of an individual user. class UserMentionAutocompleteResult extends MentionAutocompleteResult { UserMentionAutocompleteResult({required this.userId}); @@ -589,17 +782,29 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult { // TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { +/// An autocomplete interaction for choosing a topic for a message. class TopicAutocompleteView extends AutocompleteView { - TopicAutocompleteView._({required super.store, required this.streamId}); + TopicAutocompleteView._({ + required super.store, + required super.query, + required this.streamId, + }); - factory TopicAutocompleteView.init({required PerAccountStore store, required int streamId}) { - final view = TopicAutocompleteView._(store: store, streamId: streamId); + factory TopicAutocompleteView.init({ + required PerAccountStore store, + required int streamId, + required TopicAutocompleteQuery query, + }) { + final view = TopicAutocompleteView._( + store: store, streamId: streamId, query: query); store.autocompleteViewManager.registerTopicAutocomplete(view); view._fetch(); return view; } + /// The channel/stream the eventual message will be sent to. final int streamId; + Iterable _topics = []; bool _isFetching = false; @@ -615,7 +820,7 @@ class TopicAutocompleteView extends AutocompleteView e.name); _isFetching = false; - if (_query != null) return _startSearch(); + return _startSearch(); } @override @@ -642,6 +847,8 @@ class TopicAutocompleteView extends AutocompleteView Object.hash('TopicAutocompleteQuery', raw); } +/// A topic chosen in an autocomplete interaction, via a [TopicAutocompleteView]. class TopicAutocompleteResult extends AutocompleteResult { final String topic; diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 2eea7fcde2..d3fcaad67f 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -1,7 +1,13 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/realm.dart'; +import 'autocomplete.dart'; +import 'narrow.dart'; +import 'store.dart'; /// An emoji, described by how to display it in the UI. sealed class EmojiDisplay { @@ -51,6 +57,37 @@ class TextEmojiDisplay extends EmojiDisplay { TextEmojiDisplay({required super.emojiName}); } +/// An emoji that might be offered in an emoji picker UI. +final class EmojiCandidate { + /// The Zulip "emoji type" for this emoji. + final ReactionType emojiType; + + /// The Zulip "emoji code" for this emoji. + /// + /// This is the value that would appear in [Reaction.emojiCode]. + final String emojiCode; + + /// The Zulip "emoji name" to use for this emoji. + /// + /// This might not be the only name this emoji has; see [aliases]. + final String emojiName; + + /// Additional Zulip "emoji name" values for this emoji, + /// to show in the emoji picker UI. + Iterable get aliases => _aliases ?? const []; + final List? _aliases; + + final EmojiDisplay emojiDisplay; + + EmojiCandidate({ + required this.emojiType, + required this.emojiCode, + required this.emojiName, + required List? aliases, + required this.emojiDisplay, + }) : _aliases = aliases; +} + /// The portion of [PerAccountStore] describing what emoji exist. mixin EmojiStore { /// The realm's custom emoji (for [ReactionType.realmEmoji], @@ -63,6 +100,8 @@ mixin EmojiStore { required String emojiName, }); + Iterable allEmojiCandidates(); + // TODO cut debugServerEmojiData once we can query for lists of emoji; // have tests make those queries end-to-end Map>? get debugServerEmojiData; @@ -148,12 +187,168 @@ class EmojiStoreImpl with EmojiStore { /// retrieving the data. Map>? _serverEmojiData; + List? _allEmojiCandidates; + + EmojiCandidate _emojiCandidateFor({ + required ReactionType emojiType, + required String emojiCode, + required String emojiName, + required List? aliases, + }) { + return EmojiCandidate( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName, + aliases: aliases, + emojiDisplay: emojiDisplayFor( + emojiType: emojiType, emojiCode: emojiCode, emojiName: emojiName)); + } + + List _generateAllCandidates() { + final results = []; + + final namesOverridden = { + for (final emoji in realmEmoji.values) emoji.name, + 'zulip', + }; + // TODO(log) if _serverEmojiData missing + for (final entry in (_serverEmojiData ?? {}).entries) { + final allNames = entry.value; + final String emojiName; + final List? aliases; + if (allNames.any(namesOverridden.contains)) { + final names = allNames.whereNot(namesOverridden.contains).toList(); + if (names.isEmpty) continue; + emojiName = names.removeAt(0); + aliases = names; + } else { + // Most emoji aren't overridden, so avoid copying the list. + emojiName = allNames.first; + aliases = allNames.length > 1 ? allNames.sublist(1) : null; + } + results.add(_emojiCandidateFor( + emojiType: ReactionType.unicodeEmoji, + emojiCode: entry.key, emojiName: emojiName, + aliases: aliases)); + } + + for (final entry in realmEmoji.entries) { + final emojiName = entry.value.name; + if (emojiName == 'zulip') { + // TODO does 'zulip' really override realm emoji? + // (This is copied from zulip-mobile's behavior.) + continue; + } + results.add(_emojiCandidateFor( + emojiType: ReactionType.realmEmoji, + emojiCode: entry.key, emojiName: emojiName, + aliases: null)); + } + + results.add(_emojiCandidateFor( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', emojiName: 'zulip', + aliases: null)); + + return results; + } + + @override + Iterable allEmojiCandidates() { + return _allEmojiCandidates ??= _generateAllCandidates(); + } + @override void setServerEmojiData(ServerEmojiData data) { _serverEmojiData = data.codeToNames; + _allEmojiCandidates = null; } void handleRealmEmojiUpdateEvent(RealmEmojiUpdateEvent event) { realmEmoji = event.realmEmoji; + _allEmojiCandidates = null; } } + +class EmojiAutocompleteView extends AutocompleteView { + EmojiAutocompleteView._({required super.store, required super.query}); + + factory EmojiAutocompleteView.init({ + required PerAccountStore store, + required EmojiAutocompleteQuery query, + }) { + final view = EmojiAutocompleteView._(store: store, query: query); + store.autocompleteViewManager.registerEmojiAutocomplete(view); + return view; + } + + @override + Future?> computeResults() async { + // TODO(#1068): rank emoji results (popular, realm, other; exact match, prefix, other) + final results = []; + if (await filterCandidates(filter: _testCandidate, + candidates: store.allEmojiCandidates(), results: results)) { + return null; + } + return results; + } + + EmojiAutocompleteResult? _testCandidate(EmojiAutocompleteQuery query, EmojiCandidate candidate) { + return query.matches(candidate) ? EmojiAutocompleteResult(candidate) : null; + } +} + +class EmojiAutocompleteQuery extends ComposeAutocompleteQuery { + EmojiAutocompleteQuery(super.raw) + : _adjusted = _adjustQuery(raw); + + final String _adjusted; + + static String _adjustQuery(String raw) { + return raw.toLowerCase().replaceAll(' ', '_'); // TODO(#1067) remove diacritics too + } + + @override + EmojiAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { + return EmojiAutocompleteView.init(store: store, query: this); + } + + // Compare get_emoji_matcher in Zulip web:shared/src/typeahead.ts . + bool matches(EmojiCandidate candidate) { + if (candidate.emojiDisplay case UnicodeEmojiDisplay(:var emojiUnicode)) { + if (_adjusted == emojiUnicode) return true; + } + return _nameMatches(candidate.emojiName) + || candidate.aliases.any((alias) => _nameMatches(alias)); + } + + // Compare query_matches_string_in_order in Zulip web:shared/src/typeahead.ts . + bool _nameMatches(String emojiName) { + // TODO(#1067) this assumes emojiName is already lower-case (and no diacritics) + const String separator = '_'; + + if (!_adjusted.contains(separator)) { + // If the query is a single token (doesn't contain a separator), + // the match can be anywhere in the string. + return emojiName.contains(_adjusted); + } + + // If there is a separator in the query, then we + // require the match to start at the start of a token. + // (E.g. for 'ab_cd_ef', query could be 'ab_c' or 'cd_ef', + // but not 'b_cd_ef'.) + return emojiName.startsWith(_adjusted) + || emojiName.contains(separator + _adjusted); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'EmojiAutocompleteQuery')}($raw)'; + } + + @override + bool operator ==(Object other) { + return other is EmojiAutocompleteQuery && other.raw == raw; + } + + @override + int get hashCode => Object.hash('EmojiAutocompleteQuery', raw); +} diff --git a/lib/model/store.dart b/lib/model/store.dart index 7e230a6fa9..05178142e3 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -408,6 +408,9 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess notifyListeners(); } + @override + Iterable allEmojiCandidates() => _emoji.allEmojiCandidates(); + EmojiStoreImpl _emoji; //////////////////////////////// diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 277048f6a9..ba0d003a8f 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -1,6 +1,8 @@ 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'; @@ -23,7 +25,7 @@ abstract class AutocompleteField initViewModel(BuildContext context); + AutocompleteView initViewModel(BuildContext context, QueryT query); @override State> createState() => _AutocompleteFieldState(); @@ -32,23 +34,30 @@ abstract class AutocompleteField extends State> with PerAccountStoreAwareStateMixin> { AutocompleteView? _viewModel; - void _initViewModel() { - _viewModel = widget.initViewModel(context) + void _initViewModel(QueryT query) { + _viewModel = widget.initViewModel(context, query) ..addListener(_viewModelChanged); } void _handleControllerChange() { - final newAutocompleteIntent = widget.autocompleteIntent(); - if (newAutocompleteIntent != null) { + final newQuery = widget.autocompleteIntent()?.query; + // First, tear down the old view-model if necessary. + if (_viewModel != null + && (newQuery == null + || !_viewModel!.acceptsQuery(newQuery))) { + // The autocomplete interaction has ended, or has switched to a + // different kind of autocomplete (e.g. @-mention vs. emoji). + _viewModel!.dispose(); // removes our listener + _viewModel = null; + _resultsToDisplay = []; + } + // Then, update the view-model or build a new one. + if (newQuery != null) { if (_viewModel == null) { - _initViewModel(); - } - _viewModel!.query = newAutocompleteIntent.query; - } else { - if (_viewModel != null) { - _viewModel!.dispose(); // removes our listener - _viewModel = null; - _resultsToDisplay = []; + _initViewModel(newQuery); + } else { + assert(_viewModel!.acceptsQuery(newQuery)); + _viewModel!.query = newQuery; } } } @@ -64,7 +73,7 @@ class _AutocompleteFieldState { +class ComposeAutocomplete extends AutocompleteField { const ComposeAutocomplete({ super.key, required this.narrow, @@ -159,15 +168,15 @@ class ComposeAutocomplete extends AutocompleteField super.controller as ComposeContentController; @override - AutocompleteIntent? autocompleteIntent() => controller.autocompleteIntent(); + AutocompleteIntent? autocompleteIntent() => controller.autocompleteIntent(); @override - MentionAutocompleteView initViewModel(BuildContext context) { + ComposeAutocompleteView initViewModel(BuildContext context, ComposeAutocompleteQuery query) { final store = PerAccountStoreWidget.of(context); - return MentionAutocompleteView.init(store: store, narrow: narrow); + return query.initViewModel(store, narrow); } - void _onTapOption(BuildContext context, MentionAutocompleteResult option) { + void _onTapOption(BuildContext context, ComposeAutocompleteResult option) { // Probably the same intent that brought up the option that was tapped. // If not, it still shouldn't be off by more than the time it takes // to compute the autocomplete results, which we do asynchronously. @@ -175,14 +184,20 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem(option: option), + EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), + }; + return InkWell( + onTap: () { + _onTapOption(context, option); + }, + child: child); + } +} + +class _MentionAutocompleteItem extends StatelessWidget { + const _MentionAutocompleteItem({required this.option}); + + final MentionAutocompleteResult option; + + @override + Widget build(BuildContext context) { Widget avatar; String label; switch (option) { @@ -202,18 +236,58 @@ class ComposeAutocomplete extends AutocompleteField + 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)), + ])); } } @@ -238,9 +312,9 @@ class TopicAutocomplete extends AutocompleteField? autocompleteIntent() => controller.autocompleteIntent(); @override - TopicAutocompleteView initViewModel(BuildContext context) { + TopicAutocompleteView initViewModel(BuildContext context, TopicAutocompleteQuery query) { final store = PerAccountStoreWidget.of(context); - return TopicAutocompleteView.init(store: store, streamId: streamId); + return TopicAutocompleteView.init(store: store, streamId: streamId, query: query); } void _onTapOption(BuildContext context, TopicAutocompleteResult option) { diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart new file mode 100644 index 0000000000..d8af9827d7 --- /dev/null +++ b/lib/widgets/emoji.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../model/emoji.dart'; +import 'content.dart'; + +class UnicodeEmojiWidget extends StatelessWidget { + const UnicodeEmojiWidget({ + super.key, + required this.emojiDisplay, + required this.size, + required this.notoColorEmojiTextSize, + this.textScaler = TextScaler.noScaling, + }); + + final UnicodeEmojiDisplay emojiDisplay; + + /// The base width and height to use for the emoji. + /// + /// This will be scaled by [textScaler]. + final double size; + + /// A font size that, with Noto Color Emoji and our line-height config, + /// causes a Unicode emoji to occupy a square of size [size] in the layout. + /// + /// This has to be determined experimentally, as far as we know. + final double notoColorEmojiTextSize; + + /// The text scaler to apply to [size]. + /// + /// Defaults to [TextScaler.noScaling]. + final TextScaler textScaler; + + @override + Widget build(BuildContext context) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return Text( + textScaler: textScaler, + style: TextStyle( + fontFamily: 'Noto Color Emoji', + fontSize: notoColorEmojiTextSize, + ), + strutStyle: StrutStyle( + fontSize: notoColorEmojiTextSize, forceStrutHeight: true), + emojiDisplay.emojiUnicode); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // We expect the font "Apple Color Emoji" to be used. There are some + // surprises in how Flutter ends up rendering emojis in this font: + // - With a font size of 17px, the emoji visually seems to be about 17px + // square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font + // size gives an emoji that looks 17px square.) See: + // + // - The emoji doesn't fill the space taken by the [Text] in the layout. + // There's whitespace above, below, and on the right. See: + // + // + // That extra space would be problematic, except we've used a [Stack] to + // make the [Text] "positioned" so the space doesn't add margins around the + // visible part. Key points that enable the [Stack] workaround: + // - The emoji seems approximately vertically centered (this is + // accomplished with help from a [StrutStyle]; see below). + // - There seems to be approximately no space on its left. + final boxSize = textScaler.scale(size); + return Stack(alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [ + SizedBox(height: boxSize, width: boxSize), + PositionedDirectional(start: 0, child: Text( + textScaler: textScaler, + style: TextStyle(fontSize: size), + strutStyle: StrutStyle(fontSize: size, forceStrutHeight: true), + emojiDisplay.emojiUnicode)), + ]); + } + } +} + + +class ImageEmojiWidget extends StatelessWidget { + const ImageEmojiWidget({ + super.key, + required this.emojiDisplay, + required this.size, + this.textScaler = TextScaler.noScaling, + this.errorBuilder, + }); + + final ImageEmojiDisplay emojiDisplay; + + /// The base width and height to use for the emoji. + /// + /// This will be scaled by [textScaler]. + final double size; + + /// The text scaler to apply to [size]. + /// + /// Defaults to [TextScaler.noScaling]. + final TextScaler textScaler; + + final ImageErrorWidgetBuilder? errorBuilder; + + @override + Widget build(BuildContext context) { + // Some people really dislike animated emoji. + final doNotAnimate = + // From reading code, this doesn't actually get set on iOS: + // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 + MediaQuery.disableAnimationsOf(context) + || (defaultTargetPlatform == TargetPlatform.iOS + // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely + // relevant setting than "reduce motion". It's called "auto-play + // animated images", and we should file an issue to expose it. + // See GitHub comment linked above. + && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion); + + final size = textScaler.scale(this.size); + + final resolvedUrl = doNotAnimate + ? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl) + : emojiDisplay.resolvedUrl; + + return RealmContentNetworkImage( + width: size, height: size, + errorBuilder: errorBuilder, + resolvedUrl); + } +} diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index d92a050b05..39264eb3b6 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -1,11 +1,10 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/emoji.dart'; import 'color.dart'; -import 'content.dart'; +import 'emoji.dart'; import 'store.dart'; import 'text.dart'; @@ -183,7 +182,7 @@ class ReactionChip extends StatelessWidget { final emoji = switch (emojiDisplay) { UnicodeEmojiDisplay() => _UnicodeEmoji( - emojiDisplay: emojiDisplay, selected: selfVoted), + emojiDisplay: emojiDisplay), ImageEmojiDisplay() => _ImageEmoji( emojiDisplay: emojiDisplay, emojiName: emojiName, selected: selfVoted), TextEmojiDisplay() => _TextEmoji( @@ -294,57 +293,17 @@ TextScaler _labelTextScalerClamped(BuildContext context) => MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2); class _UnicodeEmoji extends StatelessWidget { - const _UnicodeEmoji({ - required this.emojiDisplay, - required this.selected, - }); + const _UnicodeEmoji({required this.emojiDisplay}); final UnicodeEmojiDisplay emojiDisplay; - final bool selected; @override Widget build(BuildContext context) { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - return Text( - textScaler: _squareEmojiScalerClamped(context), - style: const TextStyle( - fontFamily: 'Noto Color Emoji', - fontSize: _notoColorEmojiTextSize, - ), - strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true), - emojiDisplay.emojiUnicode); - case TargetPlatform.iOS: - case TargetPlatform.macOS: - // We expect the font "Apple Color Emoji" to be used. There are some - // surprises in how Flutter ends up rendering emojis in this font: - // - With a font size of 17px, the emoji visually seems to be about 17px - // square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font - // size gives an emoji that looks 17px square.) See: - // - // - The emoji doesn't fill the space taken by the [Text] in the layout. - // There's whitespace above, below, and on the right. See: - // - // - // That extra space would be problematic, except we've used a [Stack] to - // make the [Text] "positioned" so the space doesn't add margins around the - // visible part. Key points that enable the [Stack] workaround: - // - The emoji seems approximately vertically centered (this is - // accomplished with help from a [StrutStyle]; see below). - // - There seems to be approximately no space on its left. - final boxSize = _squareEmojiScalerClamped(context).scale(_squareEmojiSize); - return Stack(alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [ - SizedBox(height: boxSize, width: boxSize), - PositionedDirectional(start: 0, child: Text( - textScaler: _squareEmojiScalerClamped(context), - style: const TextStyle(fontSize: _squareEmojiSize), - strutStyle: const StrutStyle(fontSize: _squareEmojiSize, forceStrutHeight: true), - emojiDisplay.emojiUnicode)), - ]); - } + return UnicodeEmojiWidget( + size: _squareEmojiSize, + notoColorEmojiTextSize: _notoColorEmojiTextSize, + textScaler: _squareEmojiScalerClamped(context), + emojiDisplay: emojiDisplay); } } @@ -361,29 +320,11 @@ class _ImageEmoji extends StatelessWidget { @override Widget build(BuildContext context) { - // Some people really dislike animated emoji. - final doNotAnimate = - // From reading code, this doesn't actually get set on iOS: - // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 - MediaQuery.disableAnimationsOf(context) - || (defaultTargetPlatform == TargetPlatform.iOS - // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely - // relevant setting than "reduce motion". It's called "auto-play - // animated images", and we should file an issue to expose it. - // See GitHub comment linked above. - && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion); - - final resolvedUrl = doNotAnimate - ? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl) - : emojiDisplay.resolvedUrl; - - // Unicode and text emoji get scaled; it would look weird if image emoji didn't. - final size = _squareEmojiScalerClamped(context).scale(_squareEmojiSize); - - return RealmContentNetworkImage( - resolvedUrl, - width: size, - height: size, + return ImageEmojiWidget( + size: _squareEmojiSize, + // Unicode and text emoji get scaled; it would look weird if image emoji didn't. + textScaler: _squareEmojiScalerClamped(context), + emojiDisplay: emojiDisplay, errorBuilder: (context, _, __) => _TextEmoji( emojiDisplay: TextEmojiDisplay(emojiName: emojiName), selected: selected), ); diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index 93c4dbe196..cb94735894 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -3,7 +3,7 @@ import 'package:zulip/model/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; extension ComposeContentControllerChecks on Subject { - Subject?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent'); + Subject?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent'); } extension ComposeTopicControllerChecks on Subject { diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 9830b2a734..9d6667ca3f 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -2,19 +2,20 @@ import 'dart:async'; import 'dart:convert'; import 'package:checks/checks.dart'; -import 'package:fake_async/fake_async.dart'; import 'package:flutter/widgets.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/compose_box.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; +import '../fake_async.dart'; import 'test_store.dart'; import 'autocomplete_checks.dart'; @@ -68,7 +69,7 @@ void main() { /// /// For example, "~@chris^" means the text is "@chris", the selection is /// collapsed at index 6, and we expect the syntax to start at index 0. - void doTest(String markedText, MentionAutocompleteQuery? expectedQuery) { + void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery) { final description = expectedQuery != null ? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}' : 'no query in ${jsonEncode(markedText)}'; @@ -87,14 +88,17 @@ void main() { }); } - MentionAutocompleteQuery queryOf(String raw) => MentionAutocompleteQuery(raw, silent: false); - MentionAutocompleteQuery silentQueryOf(String raw) => MentionAutocompleteQuery(raw, silent: true); + MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false); + MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true); + EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw); doTest('', null); doTest('^', null); doTest('!@#\$%&*()_+', null); + // @-mentions. + doTest('^@', null); doTest('^@_', null); doTest('^@abc', null); doTest('^@_abc', null); doTest('@abc', null); doTest('@_abc', null); // (no cursor) @@ -125,61 +129,139 @@ void main() { doTest('`@chris^', null); doTest('`@_chris^', null); - doTest('~@^_', queryOf('')); // Odd/unlikely, but should not crash + doTest('~@^_', mention('')); // Odd/unlikely, but should not crash - doTest('~@__^', silentQueryOf('_')); + doTest('~@__^', silentMention('_')); - doTest('~@^abc^', queryOf('abc')); doTest('~@_^abc^', silentQueryOf('abc')); - doTest('~@a^bc^', queryOf('abc')); doTest('~@_a^bc^', silentQueryOf('abc')); - doTest('~@ab^c^', queryOf('abc')); doTest('~@_ab^c^', silentQueryOf('abc')); - doTest('~^@^', queryOf('')); doTest('~^@_^', silentQueryOf('')); + doTest('~@^abc^', mention('abc')); doTest('~@_^abc^', silentMention('abc')); + doTest('~@a^bc^', mention('abc')); doTest('~@_a^bc^', silentMention('abc')); + doTest('~@ab^c^', mention('abc')); doTest('~@_ab^c^', silentMention('abc')); + doTest('~^@^', mention('')); doTest('~^@_^', silentMention('')); // but: doTest('^hello @chris^', null); doTest('^hello @_chris^', null); - doTest('~@me@zulip.com^', queryOf('me@zulip.com')); doTest('~@_me@zulip.com^', silentQueryOf('me@zulip.com')); - doTest('~@me@^zulip.com^', queryOf('me@zulip.com')); doTest('~@_me@^zulip.com^', silentQueryOf('me@zulip.com')); - doTest('~@me^@zulip.com^', queryOf('me@zulip.com')); doTest('~@_me^@zulip.com^', silentQueryOf('me@zulip.com')); - doTest('~@^me@zulip.com^', queryOf('me@zulip.com')); doTest('~@_^me@zulip.com^', silentQueryOf('me@zulip.com')); - - doTest('~@abc^', queryOf('abc')); doTest('~@_abc^', silentQueryOf('abc')); - doTest(' ~@abc^', queryOf('abc')); doTest(' ~@_abc^', silentQueryOf('abc')); - doTest('(~@abc^', queryOf('abc')); doTest('(~@_abc^', silentQueryOf('abc')); - doTest('—~@abc^', queryOf('abc')); doTest('—~@_abc^', silentQueryOf('abc')); - doTest('"~@abc^', queryOf('abc')); doTest('"~@_abc^', silentQueryOf('abc')); - doTest('“~@abc^', queryOf('abc')); doTest('“~@_abc^', silentQueryOf('abc')); - doTest('。~@abc^', queryOf('abc')); doTest('。~@_abc^', silentQueryOf('abc')); - doTest('«~@abc^', queryOf('abc')); doTest('«~@_abc^', silentQueryOf('abc')); - - doTest('~@ab^c', queryOf('ab')); doTest('~@_ab^c', silentQueryOf('ab')); - doTest('~@a^bc', queryOf('a')); doTest('~@_a^bc', silentQueryOf('a')); - doTest('~@^abc', queryOf('')); doTest('~@_^abc', silentQueryOf('')); - doTest('~@^', queryOf('')); doTest('~@_^', silentQueryOf('')); - - doTest('~@abc ^', queryOf('abc ')); doTest('~@_abc ^', silentQueryOf('abc ')); - doTest('~@abc^ ^', queryOf('abc ')); doTest('~@_abc^ ^', silentQueryOf('abc ')); - doTest('~@ab^c ^', queryOf('abc ')); doTest('~@_ab^c ^', silentQueryOf('abc ')); - doTest('~@^abc ^', queryOf('abc ')); doTest('~@_^abc ^', silentQueryOf('abc ')); - - doTest('Please ask ~@chris^', queryOf('chris')); doTest('Please ask ~@_chris^', silentQueryOf('chris')); - doTest('Please ask ~@chris bobbe^', queryOf('chris bobbe')); doTest('Please ask ~@_chris bobbe^', silentQueryOf('chris bobbe')); - - doTest('~@Rodion Romanovich Raskolnikov^', queryOf('Rodion Romanovich Raskolnikov')); - doTest('~@_Rodion Romanovich Raskolniko^', silentQueryOf('Rodion Romanovich Raskolniko')); - doTest('~@Родион Романович Раскольников^', queryOf('Родион Романович Раскольников')); - doTest('~@_Родион Романович Раскольнико^', silentQueryOf('Родион Романович Раскольнико')); + doTest('~@me@zulip.com^', mention('me@zulip.com')); doTest('~@_me@zulip.com^', silentMention('me@zulip.com')); + doTest('~@me@^zulip.com^', mention('me@zulip.com')); doTest('~@_me@^zulip.com^', silentMention('me@zulip.com')); + doTest('~@me^@zulip.com^', mention('me@zulip.com')); doTest('~@_me^@zulip.com^', silentMention('me@zulip.com')); + doTest('~@^me@zulip.com^', mention('me@zulip.com')); doTest('~@_^me@zulip.com^', silentMention('me@zulip.com')); + + doTest('~@abc^', mention('abc')); doTest('~@_abc^', silentMention('abc')); + doTest(' ~@abc^', mention('abc')); doTest(' ~@_abc^', silentMention('abc')); + doTest('(~@abc^', mention('abc')); doTest('(~@_abc^', silentMention('abc')); + doTest('—~@abc^', mention('abc')); doTest('—~@_abc^', silentMention('abc')); + doTest('"~@abc^', mention('abc')); doTest('"~@_abc^', silentMention('abc')); + doTest('“~@abc^', mention('abc')); doTest('“~@_abc^', silentMention('abc')); + doTest('。~@abc^', mention('abc')); doTest('。~@_abc^', silentMention('abc')); + doTest('«~@abc^', mention('abc')); doTest('«~@_abc^', silentMention('abc')); + + doTest('~@ab^c', mention('ab')); doTest('~@_ab^c', silentMention('ab')); + doTest('~@a^bc', mention('a')); doTest('~@_a^bc', silentMention('a')); + doTest('~@^abc', mention('')); doTest('~@_^abc', silentMention('')); + doTest('~@^', mention('')); doTest('~@_^', silentMention('')); + + doTest('~@abc ^', mention('abc ')); doTest('~@_abc ^', silentMention('abc ')); + doTest('~@abc^ ^', mention('abc ')); doTest('~@_abc^ ^', silentMention('abc ')); + doTest('~@ab^c ^', mention('abc ')); doTest('~@_ab^c ^', silentMention('abc ')); + doTest('~@^abc ^', mention('abc ')); doTest('~@_^abc ^', silentMention('abc ')); + + doTest('Please ask ~@chris^', mention('chris')); doTest('Please ask ~@_chris^', silentMention('chris')); + doTest('Please ask ~@chris bobbe^', mention('chris bobbe')); doTest('Please ask ~@_chris bobbe^', silentMention('chris bobbe')); + + doTest('~@Rodion Romanovich Raskolnikov^', mention('Rodion Romanovich Raskolnikov')); + doTest('~@_Rodion Romanovich Raskolniko^', silentMention('Rodion Romanovich Raskolniko')); + doTest('~@Родион Романович Раскольников^', mention('Родион Романович Раскольников')); + doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико')); doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor + + // Emoji (":smile:"). + + // Basic positive examples, to contrast with all the negative examples below. + doTest('~:^', emoji('')); + doTest('~:a^', emoji('a')); + doTest('~:a ^', emoji('a ')); + doTest('~:a_^', emoji('a_')); + doTest('~:a b^', emoji('a b')); + doTest('ok ~:s^', emoji('s')); + doTest('this: ~:s^', emoji('s')); + + doTest('^:', null); + doTest('^:abc', null); + doTest(':abc', null); // (no cursor) + + // Avoid interpreting colons in normal prose as queries. + doTest(': ^', null); + doTest(':\n^', null); + doTest('this:^', null); + doTest('this: ^', null); + doTest('là ~:^', emoji('')); // ambiguous in French prose, tant pis + doTest('là : ^', null); + doTest('8:30^', null); + + // Avoid interpreting already-entered `:foo:` syntax as queries. + doTest(':smile:^', null); + + // Avoid interpreting emoticons as queries. + doTest(':-^', null); + doTest(':)^', null); doTest(':-)^', null); + doTest(':(^', null); doTest(':-(^', null); + doTest(':/^', null); doTest(':-/^', null); + doTest('~:p^', emoji('p')); // ambiguously an emoticon + doTest(':-p^', null); + + // Avoid interpreting as queries some ways colons appear in source code. + doTest('::^', null); + doTest(':<^', null); + doTest(':=^', null); + + // Emoji names may have letters and numbers in various scripts. + // (A few appear in the server's list of Unicode emoji; + // many more might be in a given realm's custom emoji.) + doTest('~:コ^', emoji('コ')); + doTest('~:空^', emoji('空')); + doTest('~:φ^', emoji('φ')); + doTest('~:100^', emoji('100')); + doTest('~:1^', emoji('1')); // U+FF11 FULLWIDTH DIGIT ONE + doTest('~:٢^', emoji('٢')); // U+0662 ARABIC-INDIC DIGIT TWO + + // Emoji names may have dashes '-'. + doTest('~:e-m^', emoji('e-m')); + doTest('~:jack-o-l^', emoji('jack-o-l')); + + // Just one emoji has a '+' in its name, namely ':+1:'. + doTest('~:+^', emoji('+')); + doTest('~:+1^', emoji('+1')); + doTest(':+2^', null); + doTest(':+100^', null); + doTest(':+1 ^', null); + doTest(':1+1^', null); + + // Accept punctuation before the emoji: opening… + doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a')); + doTest('[~:^', emoji('')); doTest('[~:a^', emoji('a')); + doTest('«~:^', emoji('')); doTest('«~:a^', emoji('a')); + doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a')); + // … closing… + doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a')); + doTest(']~:^', emoji('')); doTest(']~:a^', emoji('a')); + doTest('»~:^', emoji('')); doTest('»~:a^', emoji('a')); + doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a')); + // … and other. + doTest('.~:^', emoji('')); doTest('.~:a^', emoji('a')); + doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a')); + doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a')); + doTest('。~:^', emoji('')); doTest('。~:a^', emoji('a')); }); test('MentionAutocompleteView misc', () async { const narrow = ChannelNarrow(1); final store = eg.store(); await store.addUsers([eg.selfUser, eg.otherUser, eg.thirdUser]); - final view = MentionAutocompleteView.init(store: store, narrow: narrow); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('Third')); bool done = false; view.addListener(() { done = true; }); - view.query = MentionAutocompleteQuery('Third'); await Future(() {}); await Future(() {}); check(done).isTrue(); @@ -189,16 +271,12 @@ void main() { }); test('MentionAutocompleteView not starve timers', () { - fakeAsync((binding) async { + return awaitFakeAsync((binding) async { const narrow = ChannelNarrow(1); final store = eg.store(); await store.addUsers([eg.selfUser, eg.otherUser, eg.thirdUser]); - final view = MentionAutocompleteView.init(store: store, narrow: narrow); bool searchDone = false; - view.addListener(() { - searchDone = true; - }); // Schedule a timer event with zero delay. // This stands in for a user interaction, or frame rendering timer, @@ -210,9 +288,11 @@ void main() { check(searchDone).isFalse(); }); - view.query = MentionAutocompleteQuery('Third'); - check(timerDone).isFalse(); - check(searchDone).isFalse(); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('Third')); + view.addListener(() { + searchDone = true; + }); binding.elapse(const Duration(seconds: 1)); @@ -230,11 +310,11 @@ void main() { for (int i = 1; i <= 2500; i++) { await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i')); } - final view = MentionAutocompleteView.init(store: store, narrow: narrow); bool done = false; + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('User 2222')); view.addListener(() { done = true; }); - view.query = MentionAutocompleteQuery('User 2222'); await Future(() {}); check(done).isFalse(); @@ -253,11 +333,11 @@ void main() { for (int i = 1; i <= 1500; i++) { await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i')); } - final view = MentionAutocompleteView.init(store: store, narrow: narrow); bool done = false; + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('User 1111')); view.addListener(() { done = true; }); - view.query = MentionAutocompleteQuery('User 1111'); await Future(() {}); check(done).isFalse(); @@ -288,11 +368,11 @@ void main() { for (int i = 1; i <= 2500; i++) { await store.addUser(eg.user(userId: i, email: 'user$i@example.com', fullName: 'User $i')); } - final view = MentionAutocompleteView.init(store: store, narrow: narrow); bool done = false; + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('User 110')); view.addListener(() { done = true; }); - view.query = MentionAutocompleteQuery('User 110'); await Future(() {}); check(done).isFalse(); @@ -544,7 +624,8 @@ void main() { group('ranking across signals', () { void checkPrecedes(Narrow narrow, User userA, Iterable usersB) { - final view = MentionAutocompleteView.init(store: store, narrow: narrow); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('')); for (final userB in usersB) { check(view.debugCompareUsers(userA, userB)).isLessThan(0); check(view.debugCompareUsers(userB, userA)).isGreaterThan(0); @@ -552,7 +633,8 @@ void main() { } void checkRankEqual(Narrow narrow, List users) { - final view = MentionAutocompleteView.init(store: store, narrow: narrow); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery('')); for (int i = 0; i < users.length; i++) { for (int j = i + 1; j < users.length; j++) { check(view.debugCompareUsers(users[i], users[j])).equals(0); @@ -669,21 +751,24 @@ void main() { test('CombinedFeedNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = CombinedFeedNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow)) + check(() => MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery(''))) .throws(); }); test('MentionsNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = MentionsNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow)) + check(() => MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery(''))) .throws(); }); test('StarredMessagesNarrow gives error', () async { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = StarredMessagesNarrow(); - check(() => MentionAutocompleteView.init(store: store, narrow: narrow)) + check(() => MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery(''))) .throws(); }); }); @@ -692,9 +777,9 @@ void main() { Future> getResults( Narrow narrow, MentionAutocompleteQuery query) async { bool done = false; - final view = MentionAutocompleteView.init(store: store, narrow: narrow); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: query); view.addListener(() { done = true; }); - view.query = query; await Future(() {}); check(done).isTrue(); final results = view.results @@ -777,13 +862,14 @@ void main() { final third = eg.getStreamTopicsEntry(maxId: 3, name: 'Third Topic'); connection.prepare(json: GetStreamTopicsResult( topics: [first, second, third]).toJson()); + final view = TopicAutocompleteView.init( store: store, - streamId: eg.stream().streamId); - + streamId: eg.stream().streamId, + query: TopicAutocompleteQuery('Third')); bool done = false; view.addListener(() { done = true; }); - view.query = TopicAutocompleteQuery('Third'); + // those are here to wait for topics to be loaded await Future(() {}); await Future(() {}); @@ -802,11 +888,10 @@ void main() { final view = TopicAutocompleteView.init( store: store, - streamId: eg.stream().streamId); - + streamId: eg.stream().streamId, + query: TopicAutocompleteQuery('te')); bool done = false; view.addListener(() { done = true; }); - view.query = TopicAutocompleteQuery('te'); check(done).isFalse(); await Future(() {}); diff --git a/test/model/emoji_test.dart b/test/model/emoji_test.dart index f3fdbf3b0a..3a0ddc5e95 100644 --- a/test/model/emoji_test.dart +++ b/test/model/emoji_test.dart @@ -1,7 +1,11 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/model/autocomplete.dart'; import 'package:zulip/model/emoji.dart'; +import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; @@ -73,6 +77,345 @@ void main() { ..resolvedStillUrl.isNull(); }); }); + + Condition isUnicodeCandidate(String? emojiCode, List? names) { + return (it_) { + final it = it_.isA(); + it.emojiType.equals(ReactionType.unicodeEmoji); + if (emojiCode != null) it.emojiCode.equals(emojiCode); + if (names != null) { + it.emojiName.equals(names.first); + it.aliases.deepEquals(names.sublist(1)); + } + }; + } + + Condition isRealmCandidate({String? emojiCode, String? emojiName}) { + return (it_) { + final it = it_.isA(); + it.emojiType.equals(ReactionType.realmEmoji); + if (emojiCode != null) it.emojiCode.equals(emojiCode); + if (emojiName != null) it.emojiName.equals(emojiName); + it.aliases.isEmpty(); + }; + } + + Condition isZulipCandidate() { + return (it) => it.isA() + ..emojiType.equals(ReactionType.zulipExtraEmoji) + ..emojiCode.equals('zulip') + ..emojiName.equals('zulip') + ..aliases.isEmpty(); + } + + group('allEmojiCandidates', () { + // TODO test emojiDisplay of candidates matches emojiDisplayFor + + PerAccountStore prepare({ + Map realmEmoji = const {}, + Map>? unicodeEmoji, + }) { + final store = eg.store( + initialSnapshot: eg.initialSnapshot(realmEmoji: realmEmoji)); + if (unicodeEmoji != null) { + store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); + } + return store; + } + + test('realm emoji overrides Unicode emoji', () { + final store = prepare(realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'smiley'), + }, unicodeEmoji: { + '1f642': ['smile'], + '1f603': ['smiley'], + }); + check(store.allEmojiCandidates()).deepEquals([ + isUnicodeCandidate('1f642', ['smile']), + isRealmCandidate(emojiCode: '1', emojiName: 'smiley'), + isZulipCandidate(), + ]); + }); + + test('Unicode emoji with overridden aliases survives with remaining names', () { + final store = prepare(realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'tangerine'), + }, unicodeEmoji: { + '1f34a': ['orange', 'tangerine', 'mandarin'], + }); + check(store.allEmojiCandidates()).deepEquals([ + isUnicodeCandidate('1f34a', ['orange', 'mandarin']), + isRealmCandidate(emojiCode: '1', emojiName: 'tangerine'), + isZulipCandidate(), + ]); + }); + + test('Unicode emoji with overridden primary name survives with remaining names', () { + final store = prepare(realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'orange'), + }, unicodeEmoji: { + '1f34a': ['orange', 'tangerine', 'mandarin'], + }); + check(store.allEmojiCandidates()).deepEquals([ + isUnicodeCandidate('1f34a', ['tangerine', 'mandarin']), + isRealmCandidate(emojiCode: '1', emojiName: 'orange'), + isZulipCandidate(), + ]); + }); + + test('updates on setServerEmojiData', () { + final store = prepare(); + check(store.allEmojiCandidates()).deepEquals([ + isZulipCandidate(), + ]); + + store.setServerEmojiData(ServerEmojiData(codeToNames: { + '1f642': ['smile'], + })); + check(store.allEmojiCandidates()).deepEquals([ + isUnicodeCandidate('1f642', ['smile']), + isZulipCandidate(), + ]); + }); + + test('updates on RealmEmojiUpdateEvent', () { + final store = prepare(); + check(store.allEmojiCandidates()).deepEquals([ + isZulipCandidate(), + ]); + + store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'), + })); + check(store.allEmojiCandidates()).deepEquals([ + isRealmCandidate(emojiCode: '1', emojiName: 'happy'), + isZulipCandidate(), + ]); + }); + + test('memoizes result', () { + final store = prepare(realmEmoji: { + '1': eg.realmEmojiItem(emojiCode: '1', emojiName: 'happy'), + }, unicodeEmoji: { + '1f642': ['smile'], + }); + final candidates = store.allEmojiCandidates(); + check(store.allEmojiCandidates()).identicalTo(candidates); + }); + }); + + group('EmojiAutocompleteView', () { + Condition isUnicodeResult({String? emojiCode, List? names}) { + return (it) => it.isA().candidate.which( + isUnicodeCandidate(emojiCode, names)); + } + + Condition isRealmResult({String? emojiCode, String? emojiName}) { + return (it) => it.isA().candidate.which( + isRealmCandidate(emojiCode: emojiCode, emojiName: emojiName)); + } + + Condition isZulipResult() { + return (it) => it.isA().candidate.which( + isZulipCandidate()); + } + + PerAccountStore prepare({ + Map realmEmoji = const {}, + Map>? unicodeEmoji, + }) { + final store = eg.store( + initialSnapshot: eg.initialSnapshot(realmEmoji: { + for (final MapEntry(:key, :value) in realmEmoji.entries) + key: eg.realmEmojiItem(emojiCode: key, emojiName: value), + })); + if (unicodeEmoji != null) { + store.setServerEmojiData(ServerEmojiData(codeToNames: unicodeEmoji)); + } + return store; + } + + test('results can include all three emoji types', () async { + final store = prepare( + realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']}); + final view = EmojiAutocompleteView.init(store: store, + query: EmojiAutocompleteQuery('')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + check(view.results).deepEquals([ + isUnicodeResult(names: ['smile']), + isRealmResult(emojiName: 'happy'), + isZulipResult(), + ]); + }); + + test('results update after query change', () async { + final store = prepare( + realmEmoji: {'1': 'happy'}, unicodeEmoji: {'1f642': ['smile']}); + final view = EmojiAutocompleteView.init(store: store, + query: EmojiAutocompleteQuery('h')); + bool done = false; + view.addListener(() { done = true; }); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which( + isRealmResult(emojiName: 'happy')); + + done = false; + view.query = EmojiAutocompleteQuery('s'); + await Future(() {}); + check(done).isTrue(); + check(view.results).single.which( + isUnicodeResult(names: ['smile'])); + }); + }); + + group('EmojiAutocompleteQuery.matches', () { + EmojiCandidate unicode(List names, {String? emojiCode}) { + emojiCode ??= '10ffff'; + return EmojiCandidate(emojiType: ReactionType.unicodeEmoji, + emojiCode: emojiCode, + emojiName: names.first, aliases: names.sublist(1), + emojiDisplay: UnicodeEmojiDisplay( + emojiName: names.first, + emojiUnicode: tryParseEmojiCodeToUnicode(emojiCode)!)); + } + + bool matchesName(String query, String emojiName) { + return EmojiAutocompleteQuery(query).matches(unicode([emojiName])); + } + + test('one-word query matches anywhere in name', () { + check(matchesName('', 'smile')).isTrue(); + check(matchesName('s', 'smile')).isTrue(); + check(matchesName('sm', 'smile')).isTrue(); + check(matchesName('smile', 'smile')).isTrue(); + check(matchesName('m', 'smile')).isTrue(); + check(matchesName('mile', 'smile')).isTrue(); + check(matchesName('e', 'smile')).isTrue(); + + check(matchesName('smiley', 'smile')).isFalse(); + check(matchesName('a', 'smile')).isFalse(); + + check(matchesName('o', 'open_book')).isTrue(); + check(matchesName('open', 'open_book')).isTrue(); + check(matchesName('pe', 'open_book')).isTrue(); + check(matchesName('boo', 'open_book')).isTrue(); + check(matchesName('ok', 'open_book')).isTrue(); + }); + + test('multi-word query matches from start of a word', () { + check(matchesName('open_', 'open_book')).isTrue(); + check(matchesName('open_b', 'open_book')).isTrue(); + check(matchesName('open_book', 'open_book')).isTrue(); + + check(matchesName('pen_', 'open_book')).isFalse(); + check(matchesName('n_b', 'open_book')).isFalse(); + + check(matchesName('blue_dia', 'large_blue_diamond')).isTrue(); + }); + + test('spaces in query behave as underscores', () { + check(matchesName('open ', 'open_book')).isTrue(); + check(matchesName('open b', 'open_book')).isTrue(); + check(matchesName('open book', 'open_book')).isTrue(); + + check(matchesName('pen ', 'open_book')).isFalse(); + check(matchesName('n b', 'open_book')).isFalse(); + + check(matchesName('blue dia', 'large_blue_diamond')).isTrue(); + }); + + test('query is lower-cased', () { + check(matchesName('Smi', 'smile')).isTrue(); + }); + + test('query matches aliases same way as primary name', () { + bool matchesNames(String query, List names) { + return EmojiAutocompleteQuery(query).matches(unicode(names)); + } + + check(matchesNames('a', ['a', 'b'])).isTrue(); + check(matchesNames('b', ['a', 'b'])).isTrue(); + check(matchesNames('c', ['a', 'b'])).isFalse(); + + check(matchesNames('pe', ['x', 'open_book'])).isTrue(); + check(matchesNames('ok', ['x', 'open_book'])).isTrue(); + + check(matchesNames('open_', ['x', 'open_book'])).isTrue(); + check(matchesNames('open b', ['x', 'open_book'])).isTrue(); + check(matchesNames('pen_', ['x', 'open_book'])).isFalse(); + + check(matchesNames('Smi', ['x', 'smile'])).isTrue(); + }); + + test('query matches literal Unicode value', () { + bool matchesLiteral(String query, String emojiCode, {required String aka}) { + assert(aka == query); + return EmojiAutocompleteQuery(query) + .matches(unicode(['asdf'], emojiCode: emojiCode)); + } + + // Matching the code, in hex, doesn't count. + check(matchesLiteral('1f642', aka: '1f642', '1f642')).isFalse(); + + // Matching the Unicode value the code describes does count… + check(matchesLiteral('🙂', aka: '\u{1f642}', '1f642')).isTrue(); + // … and failing to match it doesn't make a match. + check(matchesLiteral('🙁', aka: '\u{1f641}', '1f642')).isFalse(); + + // Multi-code-point emoji work fine. + check(matchesLiteral('🏳‍🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}', + '1f3f3-200d-1f308')).isTrue(); + // Only exact matches count; no partial matches. + check(matchesLiteral('🏳', aka: '\u{1f3f3}', + '1f3f3-200d-1f308')).isFalse(); + check(matchesLiteral('‍🌈', aka: '\u{200d}\u{1f308}', + '1f3f3-200d-1f308')).isFalse(); + check(matchesLiteral('🏳‍🌈', aka: '\u{1f3f3}\u{200d}\u{1f308}', + '1f3f3')).isFalse(); + }); + + test('can match realm emoji', () { + EmojiCandidate realmCandidate(String emojiName) { + return EmojiCandidate( + emojiType: ReactionType.realmEmoji, + emojiCode: '1', emojiName: emojiName, aliases: null, + emojiDisplay: ImageEmojiDisplay( + emojiName: emojiName, + resolvedUrl: eg.realmUrl.resolve('/emoji/1.png'), + resolvedStillUrl: eg.realmUrl.resolve('/emoji/1-still.png'))); + } + + check(EmojiAutocompleteQuery('eqeq') + .matches(realmCandidate('eqeq'))).isTrue(); + check(EmojiAutocompleteQuery('open_') + .matches(realmCandidate('open_book'))).isTrue(); + check(EmojiAutocompleteQuery('n_b') + .matches(realmCandidate('open_book'))).isFalse(); + check(EmojiAutocompleteQuery('blue dia') + .matches(realmCandidate('large_blue_diamond'))).isTrue(); + check(EmojiAutocompleteQuery('Smi') + .matches(realmCandidate('smile'))).isTrue(); + }); + + test('can match Zulip extra emoji', () { + final store = eg.store(); + final zulipCandidate = EmojiCandidate( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', emojiName: 'zulip', aliases: null, + emojiDisplay: store.emojiDisplayFor( + emojiType: ReactionType.zulipExtraEmoji, + emojiCode: 'zulip', emojiName: 'zulip')); + + check(EmojiAutocompleteQuery('z').matches(zulipCandidate)).isTrue(); + check(EmojiAutocompleteQuery('Zulip').matches(zulipCandidate)).isTrue(); + check(EmojiAutocompleteQuery('p').matches(zulipCandidate)).isTrue(); + check(EmojiAutocompleteQuery('x').matches(zulipCandidate)).isFalse(); + }); + }); } extension EmojiDisplayChecks on Subject { @@ -87,3 +430,15 @@ extension ImageEmojiDisplayChecks on Subject { Subject get resolvedUrl => has((x) => x.resolvedUrl, 'resolvedUrl'); Subject get resolvedStillUrl => has((x) => x.resolvedStillUrl, 'resolvedStillUrl'); } + +extension EmojiCandidateChecks on Subject { + Subject get emojiType => has((x) => x.emojiType, 'emojiType'); + Subject get emojiCode => has((x) => x.emojiCode, 'emojiCode'); + Subject get emojiName => has((x) => x.emojiName, 'emojiName'); + Subject> get aliases => has((x) => x.aliases, 'aliases'); + Subject get emojiDisplay => has((x) => x.emojiDisplay, 'emojiDisplay'); +} + +extension EmojiAutocompleteResultChecks on Subject { + Subject get candidate => has((x) => x.candidate, 'candidate'); +} diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 34d0885967..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,8 +32,11 @@ 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); + addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); @@ -105,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 = @@ -132,9 +140,6 @@ void main() { final composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); - // Options are filtered correctly for query // TODO(#226): Remove this extra edit when this bug is fixed. await tester.enterText(composeInputFinder, 'hello @user '); @@ -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); @@ -186,9 +283,6 @@ void main() { final topicInputFinder = await setupToTopicInput(tester, topics: [topic1, topic2, topic3]); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); - // Options are filtered correctly for query // TODO(#226): Remove this extra edit when this bug is fixed. await tester.enterText(topicInputFinder, 'Topic');