Skip to content

Commit 4f6af63

Browse files
committed
autocomplete: Identify when the user intends a channel link autocomplete
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 92a3b7f commit 4f6af63

File tree

3 files changed

+113
-1
lines changed

3 files changed

+113
-1
lines changed

lib/model/autocomplete.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
4949
} else if (charAtPos == ':') {
5050
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
5151
if (match == null) continue;
52+
} else if (charAtPos == '#') {
53+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
54+
if (match == null) continue;
5255
} else {
5356
continue;
5457
}
@@ -67,6 +70,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
6770
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
6871
if (match == null) continue;
6972
query = EmojiAutocompleteQuery(match[1]!);
73+
} else if (charAtPos == '#') {
74+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
75+
if (match == null) continue;
76+
query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!);
7077
} else {
7178
continue;
7279
}
@@ -166,6 +173,32 @@ final RegExp _emojiIntentRegex = (() {
166173
+ r')$');
167174
})();
168175

176+
final RegExp _channelLinkIntentRegex = () {
177+
// Similar reasoning as in _mentionIntentRegex.
178+
const before = r'(?<=^|\s|\p{Punctuation})';
179+
180+
// TODO(upstream): maybe use duplicate-named capture groups for better readability?
181+
// https://github.com/dart-lang/sdk/issues/61337
182+
return RegExp(unicode: true,
183+
before
184+
+ r'#'
185+
// As Web, match both '#channel' and '#**channel'. In both cases, the raw
186+
// query is going to be 'channel'. Matching the second case ('#**channel')
187+
// is useful when the user selects a channel from the autocomplete list, but
188+
// then starts pressing "backspace" to edit the query and choose another
189+
// option, instead of clearing the entire query and starting from scratch.
190+
+ r'(?:'
191+
// Case '#channel': right after '#', reject whitespace as well as '**'.
192+
+ r'(?!\s|\*\*)(.*)'
193+
+ r'|'
194+
// Case '#**channel': right after '#**', reject whitespace.
195+
// Also, make sure that the remaining query doesn't contain '**',
196+
// otherwise '#**channel**' (which is a complete channel link syntax) and
197+
// any text followed by that will always match.
198+
+ r'\*\*(?!\s)((?:(?!\*\*).)*)'
199+
+ r')$');
200+
}();
201+
169202
/// The text controller's recognition that the user might want autocomplete UI.
170203
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
171204
AutocompleteIntent({

lib/widgets/autocomplete.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
4545
}
4646

4747
void _handleControllerChange() {
48-
final newQuery = widget.autocompleteIntent()?.query;
48+
var newQuery = widget.autocompleteIntent()?.query;
49+
if (newQuery is ChannelLinkAutocompleteQuery) newQuery = null; // TODO(#124)
4950
// First, tear down the old view-model if necessary.
5051
if (_viewModel != null
5152
&& (newQuery == null

test/model/autocomplete_test.dart

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ void main() {
9898

9999
MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false);
100100
MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true);
101+
ChannelLinkAutocompleteQuery channelLink(String raw) => ChannelLinkAutocompleteQuery(raw);
101102
EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw);
102103

103104
doTest('', null);
@@ -182,6 +183,83 @@ void main() {
182183
doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor
183184
doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor
184185

186+
// #channel link.
187+
188+
doTest('^#', null);
189+
doTest('^#abc', null);
190+
doTest('#abc', null); // (no cursor)
191+
192+
doTest('~#^', channelLink(''));
193+
doTest('~#abc^', channelLink('abc'));
194+
doTest('~#abc ^', channelLink('abc '));
195+
doTest('~#abc def^', channelLink('abc def'));
196+
197+
// Accept space before channel link syntax.
198+
doTest(' ~#abc^', channelLink('abc'));
199+
doTest('xyz ~#abc^', channelLink('abc'));
200+
201+
// Accept punctuations before channel link syntax.
202+
doTest('#~#abc^', channelLink('abc'));
203+
doTest('@~#abc^', channelLink('abc'));
204+
doTest(':~#abc^', channelLink('abc'));
205+
doTest('!~#abc^', channelLink('abc'));
206+
doTest(',~#abc^', channelLink('abc'));
207+
doTest('.~#abc^', channelLink('abc'));
208+
doTest('(~#abc^', channelLink('abc')); doTest(')~#abc^', channelLink('abc'));
209+
doTest('{~#abc^', channelLink('abc')); doTest('}~#abc^', channelLink('abc'));
210+
doTest('[~#abc^', channelLink('abc')); doTest(']~#abc^', channelLink('abc'));
211+
// … and other punctuations.
212+
213+
// Avoid other characters before channel link syntax.
214+
doTest('\$#abc^', null);
215+
doTest('+#abc^', null);
216+
doTest('=#abc^', null);
217+
doTest('XYZ#abc^', null);
218+
doTest('xyz#abc^', null);
219+
// … but
220+
doTest('~#xyz#abc^', channelLink('xyz#abc'));
221+
222+
// Avoid leading space character in query.
223+
doTest('# ^', null);
224+
doTest('# abc^', null);
225+
226+
// Avoid line-break characters in query.
227+
doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null);
228+
doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null);
229+
doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null);
230+
231+
// Allow all other sorts of characters in query.
232+
doTest('~#\u0000^', channelLink('\u0000')); // control
233+
doTest('~#\u061C^', channelLink('\u061C')); // format character
234+
doTest('~#\u0600^', channelLink('\u0600')); // format
235+
doTest('~#\uD834^', channelLink('\uD834')); // leading surrogate
236+
doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b'));
237+
doTest('~#\\^', channelLink('\\')); doTest('~#a\\b^', channelLink('a\\b'));
238+
doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b'));
239+
doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b'));
240+
doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b'));
241+
doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b'));
242+
doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b'));
243+
244+
// Two leading stars ('**') in query are omitted.
245+
doTest('~#**^', channelLink(''));
246+
doTest('~#**abc^', channelLink('abc'));
247+
doTest('~#**abc ^', channelLink('abc '));
248+
doTest('~#**abc def^', channelLink('abc def'));
249+
doTest('#** ^', null);
250+
doTest('#** abc^', null);
251+
doTest('~#**abc*^', channelLink('abc*'));
252+
253+
// Query with leading '**' should not contain other '**'.
254+
doTest('#**abc**^', null);
255+
doTest('#**abc** ^', null);
256+
doTest('#**abc** def^', null);
257+
258+
// Query without leading '**' can contain other '**'.
259+
doTest('~#abc**^', channelLink('abc**'));
260+
doTest('~#abc** ^', channelLink('abc** '));
261+
doTest('~#abc** def^', channelLink('abc** def'));
262+
185263
// Emoji (":smile:").
186264

187265
// Basic positive examples, to contrast with all the negative examples below.

0 commit comments

Comments
 (0)