Skip to content

Commit 0df9845

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 7df5dab commit 0df9845

File tree

3 files changed

+116
-1
lines changed

3 files changed

+116
-1
lines changed

lib/model/autocomplete.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
4848
} else if (charAtPos == ':') {
4949
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
5050
if (match == null) continue;
51+
} else if (charAtPos == '#') {
52+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
53+
if (match == null) continue;
5154
} else {
5255
continue;
5356
}
@@ -66,6 +69,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
6669
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
6770
if (match == null) continue;
6871
query = EmojiAutocompleteQuery(match[1]!);
72+
} else if (charAtPos == '#') {
73+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
74+
if (match == null) continue;
75+
query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!);
6976
} else {
7077
continue;
7178
}
@@ -165,6 +172,35 @@ final RegExp _emojiIntentRegex = (() {
165172
+ r')$');
166173
})();
167174

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