Skip to content

Commit 1faf1d5

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 be3da8a commit 1faf1d5

File tree

3 files changed

+174
-8
lines changed

3 files changed

+174
-8
lines changed

lib/model/autocomplete.dart

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ import 'narrow.dart';
1818
import 'store.dart';
1919

2020
extension ComposeContentAutocomplete on ComposeContentController {
21+
// To avoid spending a lot of time searching for autocomplete intents
22+
// in long messages, we bound how far back we look for the intent's start.
23+
int get _maxLookbackForAutocompleteIntent {
24+
return 1 // intent character, e.g. "#"
25+
+ 2 // some optional characters e.g., "_" for silent mention or "**"
26+
27+
// Per the API doc, maxChannelNameLength is in Unicode code points.
28+
// We walk the string by UTF-16 code units, and there might be one or two
29+
// of those encoding each Unicode code point.
30+
+ 2 * store.maxChannelNameLength;
31+
}
32+
2133
AutocompleteIntent<ComposeAutocompleteQuery>? autocompleteIntent() {
2234
if (!selection.isValid || !selection.isNormalized) {
2335
// We don't require [isCollapsed] to be true because we've seen that
@@ -28,9 +40,7 @@ extension ComposeContentAutocomplete on ComposeContentController {
2840
return null;
2941
}
3042

31-
// To avoid spending a lot of time searching for autocomplete intents
32-
// in long messages, we bound how far back we look for the intent's start.
33-
final earliest = max(0, selection.end - 30);
43+
final earliest = max(0, selection.end - _maxLookbackForAutocompleteIntent);
3444

3545
if (selection.start < earliest) {
3646
// The selection extends to before any position we'd consider
@@ -48,6 +58,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
4858
} else if (charAtPos == ':') {
4959
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
5060
if (match == null) continue;
61+
} else if (charAtPos == '#') {
62+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
63+
if (match == null) continue;
5164
} else {
5265
continue;
5366
}
@@ -66,6 +79,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
6679
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
6780
if (match == null) continue;
6881
query = EmojiAutocompleteQuery(match[1]!);
82+
} else if (charAtPos == '#') {
83+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
84+
if (match == null) continue;
85+
query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!);
6986
} else {
7087
continue;
7188
}
@@ -165,6 +182,61 @@ final RegExp _emojiIntentRegex = (() {
165182
+ r')$');
166183
})();
167184

185+
final RegExp _channelLinkIntentRegex = () {
186+
// What's likely to come just before #channel syntax: the start of the string,
187+
// whitespace, or punctuation. Letters are unlikely; in that case a GitHub-
188+
// style "zulip/zulip-flutter#124" link might be intended (as on CZO where
189+
// there's a custom linkifier for that).
190+
//
191+
// Only some punctuation, like "(", is actually likely here. We don't
192+
// currently try to be specific about that, except we exclude "#" and "@"
193+
// in order to allow typing "##channel" for the channel query "#channel"
194+
// and "@#user" for the mention query "#user". See discussion:
195+
// https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/channel.20autocomplete.3A.20channels.20with.20.22.23.22.20in.20name/near/2288883
196+
const before = r'(?<=^|\s|\p{Punctuation})(?<![#@])';
197+
// TODO(dart-future): Regexps in ES 2024 have a /v aka unicodeSets flag;
198+
// if Dart matches that, we could combine into one character class
199+
// meaning "whitespace and punctuation, except not `#` or `@`":
200+
// r'(?<=^|[[\s\p{Punctuation}]--[#@]])'
201+
202+
// For a channel name, server excludes a wide range of characters and
203+
// code points in `\p{C}` major category, namely the minor catories `\p{Cc}`,
204+
// `\p{Cs}`, and part of `\p{Cn}`.
205+
// - https://github.com/zulip/zulip/blob/1c28e96d0/zerver/lib/string_validation.py#L38-L55
206+
// - https://github.com/zulip/zulip/blob/1c28e96d0/zerver/lib/string_validation.py#L8-L26
207+
//
208+
// TODO: match the server constraints
209+
const nameCharExclusions = r'\r\n';
210+
211+
// TODO(upstream): maybe use duplicate-named capture groups for better readability?
212+
// https://github.com/dart-lang/sdk/issues/61337
213+
return RegExp(unicode: true,
214+
before
215+
+ r'#'
216+
// As Web, match both '#channel' and '#**channel'. In both cases, the raw
217+
// query is going to be 'channel'. Matching the second case ('#**channel')
218+
// is useful when the user selects a channel from the autocomplete list, but
219+
// then starts pressing "backspace" to edit the query and choose another
220+
// option, instead of clearing the entire query and starting from scratch.
221+
+ r'(?:'
222+
// Case '#channel': right after '#', reject whitespace as well as '**'.
223+
+ r'(?!\s|\*\*)([^' + nameCharExclusions + r']*)'
224+
+ r'|'
225+
// Case '#**channel': right after '#**', reject whitespace.
226+
// Also, make sure that the remaining query doesn't contain '**',
227+
// otherwise '#**channel**' (which is a completed channel link syntax) and
228+
// any text followed by that will always match.
229+
+ r'\*\*(?!\s)'
230+
+ r'((?:'
231+
+ r'[^*' + nameCharExclusions + r']'
232+
+ r'|'
233+
+ r'\*[^*' + nameCharExclusions + r']'
234+
+ r'|'
235+
+ r'\*$'
236+
+ r')*)'
237+
+ r')$');
238+
}();
239+
168240
/// The text controller's recognition that the user might want autocomplete UI.
169241
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
170242
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: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,15 @@ void main() {
7676
///
7777
/// For example, "~@chris^" means the text is "@chris", the selection is
7878
/// collapsed at index 6, and we expect the syntax to start at index 0.
79-
void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery) {
79+
void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery, {
80+
int? maxChannelName,
81+
}) {
8082
final description = expectedQuery != null
8183
? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}'
8284
: 'no query in ${jsonEncode(markedText)}';
8385
test(description, () {
84-
final store = eg.store();
86+
final store = eg.store(initialSnapshot:
87+
eg.initialSnapshot(maxChannelNameLength: maxChannelName));
8588
final controller = ComposeContentController(store: store);
8689
final parsed = parseMarkedText(markedText);
8790
assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null));
@@ -98,6 +101,7 @@ void main() {
98101

99102
MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false);
100103
MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true);
104+
ChannelLinkAutocompleteQuery channelLink(String raw) => ChannelLinkAutocompleteQuery(raw);
101105
EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw);
102106

103107
doTest('', null);
@@ -179,8 +183,13 @@ void main() {
179183
doTest('~@_Rodion Romanovich Raskolniko^', silentMention('Rodion Romanovich Raskolniko'));
180184
doTest('~@Родион Романович Раскольников^', mention('Родион Романович Раскольников'));
181185
doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико'));
182-
doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor
183-
doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor
186+
187+
// "@" sign can be (3 + 2 * maxChannelName) utf-16 code units
188+
// away to the left of the cursor.
189+
doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 10);
190+
doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 10);
191+
doTest('If @chris is around, please ask him.^', null, maxChannelName: 10);
192+
doTest('If @_chris is around, please ask him.^', null, maxChannelName: 10);
184193

185194
// Emoji (":smile:").
186195

@@ -259,6 +268,90 @@ void main() {
259268
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
260269
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
261270
doTest('。~:^', emoji('')); doTest('。~:a^', emoji('a'));
271+
272+
// #channel links.
273+
274+
doTest('^#', null);
275+
doTest('^#abc', null);
276+
doTest('#abc', null); // (no cursor)
277+
278+
// Link syntax can be at the start of a string.
279+
doTest('~#^', channelLink(''));
280+
doTest('~##^', channelLink('#'));
281+
doTest('~#abc^', channelLink('abc'));
282+
283+
// Link syntax can contain multiple words.
284+
doTest('~#abc ^', channelLink('abc '));
285+
doTest('~#abc def^', channelLink('abc def'));
286+
287+
// Link syntax can come after a word or space.
288+
doTest('xyz ~#abc^', channelLink('abc'));
289+
doTest(' ~#abc^', channelLink('abc'));
290+
291+
// Link syntax can come after punctuation…
292+
doTest(':~#abc^', channelLink('abc'));
293+
doTest('!~#abc^', channelLink('abc'));
294+
doTest(',~#abc^', channelLink('abc'));
295+
doTest('.~#abc^', channelLink('abc'));
296+
doTest('(~#abc^', channelLink('abc')); doTest(')~#abc^', channelLink('abc'));
297+
doTest('{~#abc^', channelLink('abc')); doTest('}~#abc^', channelLink('abc'));
298+
doTest('[~#abc^', channelLink('abc')); doTest(']~#abc^', channelLink('abc'));
299+
doTest('“~#abc^', channelLink('abc')); doTest('”~#abc^', channelLink('abc'));
300+
doTest('«~#abc^', channelLink('abc')); doTest('»~#abc^', channelLink('abc'));
301+
// … except for '#' and '@', because they start
302+
// channel link and mention syntaxes, respectively.
303+
doTest('~##abc^', channelLink('#abc'));
304+
doTest('~@#abc^', mention('#abc'));
305+
306+
// Avoid interpreting as queries a URL or a common linkifier syntax.
307+
doTest('https://example.com/docs#install^', null);
308+
doTest('zulip/zulip-flutter#124^', null);
309+
310+
// Query can't start with a space; channel names don't.
311+
doTest('# ^', null);
312+
doTest('# abc^', null);
313+
314+
// Query shouldn't be multiple lines.
315+
doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null);
316+
doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null);
317+
doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null);
318+
319+
// Query can contain a wide range of characters.
320+
doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b'));
321+
doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b'));
322+
doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b'));
323+
doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b'));
324+
doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b'));
325+
doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b'));
326+
327+
// Avoid interpreting already-entered `#**foo**` syntax as queries.
328+
doTest('#**abc**^', null);
329+
doTest('#**abc** ^', null);
330+
doTest('#**abc** def^', null);
331+
332+
// Accept syntax like "#**foo" (as from the user finishing an autocomplete
333+
// and then hitting backspace to edit it), but leave the "**" out of the query.
334+
doTest('~#**^', channelLink(''));
335+
doTest('~#**abc^', channelLink('abc'));
336+
doTest('~#**abc ^', channelLink('abc '));
337+
doTest('~#**abc def^', channelLink('abc def'));
338+
doTest('~#**ab*c^', channelLink('ab*c'));
339+
doTest('~#**abc*^', channelLink('abc*'));
340+
doTest('#** ^', null);
341+
doTest('#** abc^', null);
342+
doTest('#**a\n^', null); doTest('#**\na^', null); doTest('#**a\nb^', null);
343+
doTest('#**a\r^', null); doTest('#**\ra^', null); doTest('#**a\rb^', null);
344+
doTest('#**a\r\n^', null); doTest('#**\r\na^', null); doTest('#**a\r\nb^', null);
345+
346+
// "#" sign can be (3 + 2 * maxChannelName) utf-16 code units
347+
// away to the left of the cursor.
348+
doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 5);
349+
doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 5);
350+
doTest('check #mobile dev te^am', null, maxChannelName: 5);
351+
doTest('check #mobile dev team for more info^', null, maxChannelName: 5);
352+
// '🙂' is 2 utf-16 code units.
353+
doTest('check ~#**🙂🙂🙂🙂🙂^', channelLink('🙂🙂🙂🙂🙂'), maxChannelName: 5);
354+
doTest('check #**🙂🙂🙂🙂🙂🙂^', null, maxChannelName: 5);
262355
});
263356

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

0 commit comments

Comments
 (0)