Skip to content

Commit 2673b6a

Browse files
committed
autocomplete: Identify when the user intends an emoji autocomplete
The "forbid preceding ':'" wrinkle is one I discovered because of writing tests: I wrote down the '::^' test, was surprised to find that it failed, and then went back and extended the regexp to make it pass. For this commit we temporarily intercept the query at the AutocompleteField widget, to avoid invoking the widgets that are still unimplemented. That lets us defer those widgets' logic to a separate later commit.
1 parent 31d7122 commit 2673b6a

File tree

3 files changed

+104
-1
lines changed

3 files changed

+104
-1
lines changed

lib/model/autocomplete.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
3939
if (charAtPos == '@') {
4040
final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos);
4141
if (match == null) continue;
42+
} else if (charAtPos == ':') {
43+
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
44+
if (match == null) continue;
4245
} else {
4346
continue;
4447
}
@@ -53,6 +56,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
5356
final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos);
5457
if (match == null) continue;
5558
query = MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_');
59+
} else if (charAtPos == ':') {
60+
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
61+
if (match == null) continue;
62+
query = EmojiAutocompleteQuery(match[1]!);
5663
} else {
5764
continue;
5865
}
@@ -98,6 +105,30 @@ final RegExp _mentionIntentRegex = (() {
98105
unicode: true);
99106
})();
100107

108+
final RegExp _emojiIntentRegex = (() {
109+
// Similar reasoning as in _mentionIntentRegex.
110+
// Specifically forbid a preceding ":", though, to make "::" not a query.
111+
const before = r'(?<=^|\s|\p{Punctuation})(?<!:)';
112+
// TODO(dart-future): Regexps in ES 2024 have a /v aka unicodeSets flag;
113+
// if Dart matches that, we could combine into one character class
114+
// meaning "whitespace and punctuation, except not `:`":
115+
// r'(?<=^|[[\s\p{Punctuation}]--[:]])'
116+
117+
/// Characters that might be meant as part of (a query for) an emoji's name,
118+
/// other than whitespace.
119+
const nameCharacters = r'_\p{Letter}\p{Number}';
120+
121+
return RegExp(unicode: true,
122+
before
123+
+ r':'
124+
+ r'(|'
125+
// Reject on whitespace right after ':'; interpret that
126+
// as the user choosing to get out of the emoji autocomplete.
127+
+ r'[' + nameCharacters + r']'
128+
+ r'[\s' + nameCharacters + r']*'
129+
+ r')$');
130+
})();
131+
101132
/// The text controller's recognition that the user might want autocomplete UI.
102133
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
103134
AutocompleteIntent({

lib/widgets/autocomplete.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22

3+
import '../model/emoji.dart';
34
import 'content.dart';
45
import 'store.dart';
56
import '../model/autocomplete.dart';
@@ -38,7 +39,8 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
3839
}
3940

4041
void _handleControllerChange() {
41-
final newQuery = widget.autocompleteIntent()?.query;
42+
var newQuery = widget.autocompleteIntent()?.query;
43+
if (newQuery is EmojiAutocompleteQuery) newQuery = null; // TODO(#670)
4244
// First, tear down the old view-model if necessary.
4345
if (_viewModel != null
4446
&& (newQuery == null

test/model/autocomplete_test.dart

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:zulip/api/model/initial_snapshot.dart';
88
import 'package:zulip/api/model/model.dart';
99
import 'package:zulip/api/route/channels.dart';
1010
import 'package:zulip/model/autocomplete.dart';
11+
import 'package:zulip/model/emoji.dart';
1112
import 'package:zulip/model/narrow.dart';
1213
import 'package:zulip/model/store.dart';
1314
import 'package:zulip/widgets/compose_box.dart';
@@ -89,12 +90,15 @@ void main() {
8990

9091
MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false);
9192
MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true);
93+
EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw);
9294

9395
doTest('', null);
9496
doTest('^', null);
9597

9698
doTest('!@#\$%&*()_+', null);
9799

100+
// @-mentions.
101+
98102
doTest('^@', null); doTest('^@_', null);
99103
doTest('^@abc', null); doTest('^@_abc', null);
100104
doTest('@abc', null); doTest('@_abc', null); // (no cursor)
@@ -169,6 +173,72 @@ void main() {
169173
doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико'));
170174
doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor
171175
doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor
176+
177+
// Emoji (":smile:").
178+
179+
// Basic positive examples, to contrast with all the negative examples below.
180+
doTest('~:^', emoji(''));
181+
doTest('~:a^', emoji('a'));
182+
doTest('~:a ^', emoji('a '));
183+
doTest('~:a_^', emoji('a_'));
184+
doTest('~:a b^', emoji('a b'));
185+
doTest('ok ~:s^', emoji('s'));
186+
doTest('this: ~:s^', emoji('s'));
187+
188+
doTest('^:', null);
189+
doTest('^:abc', null);
190+
doTest(':abc', null); // (no cursor)
191+
192+
// Avoid interpreting colons in normal prose as queries.
193+
doTest(': ^', null);
194+
doTest(':\n^', null);
195+
doTest('this:^', null);
196+
doTest('this: ^', null);
197+
doTest('là ~:^', emoji('')); // ambiguous in French prose, tant pis
198+
doTest('là : ^', null);
199+
doTest('8:30^', null);
200+
201+
// Avoid interpreting already-entered `:foo:` syntax as queries.
202+
doTest(':smile:^', null);
203+
204+
// Avoid interpreting emoticons as queries.
205+
doTest(':-^', null);
206+
doTest(':)^', null); doTest(':-)^', null);
207+
doTest(':(^', null); doTest(':-(^', null);
208+
doTest(':/^', null); doTest(':-/^', null);
209+
doTest('~:p^', emoji('p')); // ambiguously an emoticon
210+
doTest(':-p^', null);
211+
212+
// Avoid interpreting as queries some ways colons appear in source code.
213+
doTest('::^', null);
214+
doTest(':<^', null);
215+
doTest(':=^', null);
216+
217+
// Emoji names may have letters and numbers in various scripts.
218+
// (A few appear in the server's list of Unicode emoji;
219+
// many more might be in a given realm's custom emoji.)
220+
doTest('~:コ^', emoji('コ'));
221+
doTest('~:空^', emoji('空'));
222+
doTest('~:φ^', emoji('φ'));
223+
doTest('~:100^', emoji('100'));
224+
doTest('~:1^', emoji('1')); // U+FF11 FULLWIDTH DIGIT ONE
225+
doTest('~:٢^', emoji('٢')); // U+0662 ARABIC-INDIC DIGIT TWO
226+
227+
// Accept punctuation before the emoji: opening…
228+
doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a'));
229+
doTest('[~:^', emoji('')); doTest('[~:a^', emoji('a'));
230+
doTest('«~:^', emoji('')); doTest('«~:a^', emoji('a'));
231+
doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a'));
232+
// … closing…
233+
doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a'));
234+
doTest(']~:^', emoji('')); doTest(']~:a^', emoji('a'));
235+
doTest('»~:^', emoji('')); doTest('»~:a^', emoji('a'));
236+
doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a'));
237+
// … and other.
238+
doTest('.~:^', emoji('')); doTest('.~:a^', emoji('a'));
239+
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
240+
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
241+
doTest('。~:^', emoji('')); doTest('。~:a^', emoji('a'));
172242
});
173243

174244
test('MentionAutocompleteView misc', () async {

0 commit comments

Comments
 (0)