Skip to content

Commit ed7fda0

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 6b2ef9c commit ed7fda0

File tree

3 files changed

+168
-6
lines changed

3 files changed

+168
-6
lines changed

lib/model/autocomplete.dart

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ import 'narrow.dart';
1818
import 'store.dart';
1919

2020
extension ComposeContentAutocomplete on ComposeContentController {
21+
int get _maxLookbackForAutocompleteIntent {
22+
return 1 // intent character e.g., "#"
23+
+ 2 // some optional characters e.g., "_" for silent mention or "**"
24+
25+
// Per the API doc, maxChannelNameLength is in Unicode code points.
26+
// We walk the string by UTF-16 code units, and there might be one or two
27+
// of those encoding each Unicode code point.
28+
+ 2 * store.maxChannelNameLength;
29+
}
30+
2131
AutocompleteIntent<ComposeAutocompleteQuery>? autocompleteIntent() {
2232
if (!selection.isValid || !selection.isNormalized) {
2333
// We don't require [isCollapsed] to be true because we've seen that
@@ -30,7 +40,7 @@ extension ComposeContentAutocomplete on ComposeContentController {
3040

3141
// To avoid spending a lot of time searching for autocomplete intents
3242
// 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,48 @@ 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+
// By punctuation, we mean *some* punctuation, like "(". We make "#" and "@"
192+
// exceptions, to support typing "##channel" for the channel query "#channel",
193+
// and typing "@#user" for the mention query "#user", because in 2025-11
194+
// channel and user name words can start with "#". (They can also contain "#"
195+
// anywhere else in the name; we don't handle that specially.)
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+
// TODO(upstream): maybe use duplicate-named capture groups for better readability?
203+
// https://github.com/dart-lang/sdk/issues/61337
204+
return RegExp(unicode: true,
205+
before
206+
+ r'#'
207+
// As Web, match both '#channel' and '#**channel'. In both cases, the raw
208+
// query is going to be 'channel'. Matching the second case ('#**channel')
209+
// is useful when the user selects a channel from the autocomplete list, but
210+
// then starts pressing "backspace" to edit the query and choose another
211+
// option, instead of clearing the entire query and starting from scratch.
212+
213+
// Also, web doesn't seem to have any sort of limitations for the type of
214+
// characters the channel name can contain.
215+
+ r'(?:'
216+
// Case '#channel': right after '#', reject whitespace as well as '**'.
217+
+ r'(?!\s|\*\*)(.*)'
218+
+ r'|'
219+
// Case '#**channel': right after '#**', reject whitespace.
220+
// Also, make sure that the remaining query doesn't contain '**',
221+
// otherwise '#**channel**' (which is a complete channel link syntax) and
222+
// any text followed by that will always match.
223+
+ r'\*\*(?!\s)((?:(?!\*\*).)*)'
224+
+ r')$');
225+
}();
226+
168227
/// The text controller's recognition that the user might want autocomplete UI.
169228
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
170229
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: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,15 @@ void main() {
7777
///
7878
/// For example, "~@chris^" means the text is "@chris", the selection is
7979
/// collapsed at index 6, and we expect the syntax to start at index 0.
80-
void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery) {
80+
void doTest(String markedText, ComposeAutocompleteQuery? expectedQuery, {
81+
int? maxChannelName,
82+
}) {
8183
final description = expectedQuery != null
8284
? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}'
8385
: 'no query in ${jsonEncode(markedText)}';
8486
test(description, () {
85-
final store = eg.store();
87+
final store = eg.store(initialSnapshot:
88+
eg.initialSnapshot(maxChannelNameLength: maxChannelName));
8689
final controller = ComposeContentController(store: store);
8790
final parsed = parseMarkedText(markedText);
8891
assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null));
@@ -99,6 +102,7 @@ void main() {
99102

100103
MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false);
101104
MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true);
105+
ChannelLinkAutocompleteQuery channelLink(String raw) => ChannelLinkAutocompleteQuery(raw);
102106
EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw);
103107

104108
doTest('', null);
@@ -180,8 +184,106 @@ void main() {
180184
doTest('~@_Rodion Romanovich Raskolniko^', silentMention('Rodion Romanovich Raskolniko'));
181185
doTest('~@Родион Романович Раскольников^', mention('Родион Романович Раскольников'));
182186
doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико'));
183-
doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor
184-
doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor
187+
188+
// "#" sign can be (3 + 2 * maxChannelName) utf-16 code units
189+
// away to the left of cursor.
190+
doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 10);
191+
doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 10);
192+
doTest('If @chris is around, please ask him.^', null, maxChannelName: 10);
193+
doTest('If @_chris is around, please ask him.^', null, maxChannelName: 10);
194+
195+
// #channel link.
196+
197+
doTest('^#', null);
198+
doTest('^#abc', null);
199+
doTest('#abc', null); // (no cursor)
200+
201+
doTest('~#^', channelLink(''));
202+
doTest('~##^', channelLink('#'));
203+
doTest('~#abc^', channelLink('abc'));
204+
doTest('~#abc ^', channelLink('abc '));
205+
doTest('~#abc def^', channelLink('abc def'));
206+
207+
// Accept space before channel link syntax.
208+
doTest(' ~#abc^', channelLink('abc'));
209+
doTest('xyz ~#abc^', channelLink('abc'));
210+
211+
// Accept punctuations before channel link syntax.
212+
doTest(':~#abc^', channelLink('abc'));
213+
doTest('!~#abc^', channelLink('abc'));
214+
doTest(',~#abc^', channelLink('abc'));
215+
doTest('.~#abc^', channelLink('abc'));
216+
doTest('(~#abc^', channelLink('abc')); doTest(')~#abc^', channelLink('abc'));
217+
doTest('{~#abc^', channelLink('abc')); doTest('}~#abc^', channelLink('abc'));
218+
doTest('[~#abc^', channelLink('abc')); doTest(']~#abc^', channelLink('abc'));
219+
doTest('“~#abc^', channelLink('abc')); doTest('”~#abc^', channelLink('abc'));
220+
doTest('«~#abc^', channelLink('abc')); doTest('»~#abc^', channelLink('abc'));
221+
// … and other punctuations except '#' and '@':
222+
doTest('~##abc^', channelLink('#abc'));
223+
doTest('~@#abc^', mention('#abc'));
224+
225+
// Avoid other characters before channel link syntax.
226+
doTest('+#abc^', null);
227+
doTest('=#abc^', null);
228+
doTest('\$#abc^', null);
229+
doTest('zulip/zulip-flutter#124^', null);
230+
doTest('XYZ#abc^', null);
231+
doTest('xyz#abc^', null);
232+
// … but
233+
doTest('~#xyz#abc^', channelLink('xyz#abc'));
234+
235+
// Avoid leading space character in query.
236+
doTest('# ^', null);
237+
doTest('# abc^', null);
238+
239+
// Avoid line-break characters in query.
240+
doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null);
241+
doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null);
242+
doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null);
243+
244+
// Allow all other sorts of characters in query.
245+
doTest('~#\u0000^', channelLink('\u0000')); // control
246+
doTest('~#\u061C^', channelLink('\u061C')); // format character
247+
doTest('~#\u0600^', channelLink('\u0600')); // format
248+
doTest('~#\uD834^', channelLink('\uD834')); // leading surrogate
249+
doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b'));
250+
doTest('~#\\^', channelLink('\\')); doTest('~#a\\b^', channelLink('a\\b'));
251+
doTest('~#"^', channelLink('"')); doTest('~#a"b^', channelLink('a"b'));
252+
doTest('~#>^', channelLink('>')); doTest('~#a>b^', channelLink('a>b'));
253+
doTest('~#&^', channelLink('&')); doTest('~#a&b^', channelLink('a&b'));
254+
doTest('~#_^', channelLink('_')); doTest('~#a_b^', channelLink('a_b'));
255+
doTest('~#*^', channelLink('*')); doTest('~#a*b^', channelLink('a*b'));
256+
257+
// Two leading stars ('**') in the query are omitted.
258+
doTest('~#**^', channelLink(''));
259+
doTest('~#**abc^', channelLink('abc'));
260+
doTest('~#**abc ^', channelLink('abc '));
261+
doTest('~#**abc def^', channelLink('abc def'));
262+
doTest('#** ^', null);
263+
doTest('#** abc^', null);
264+
265+
doTest('~#**abc*^', channelLink('abc*'));
266+
267+
// Query with leading '**' should not contain other '**'.
268+
doTest('#**abc**^', null);
269+
doTest('#**abc** ^', null);
270+
doTest('#**abc** def^', null);
271+
272+
// Query without leading '**' can contain other '**'.
273+
doTest('~#abc**^', channelLink('abc**'));
274+
doTest('~#abc** ^', channelLink('abc** '));
275+
doTest('~#abc** def^', channelLink('abc** def'));
276+
doTest('~#*abc**^', channelLink('*abc**'));
277+
278+
// "#" sign can be (3 + 2 * maxChannelName) utf-16 code units
279+
// away to the left of cursor.
280+
doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 5);
281+
doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 5);
282+
doTest('check #mobile dev te^am', null, maxChannelName: 5);
283+
doTest('check #mobile dev team for more info^', null, maxChannelName: 5);
284+
// '🙂' is 2 utf-16 code units.
285+
doTest('check ~#**🙂🙂🙂🙂🙂^', channelLink('🙂🙂🙂🙂🙂'), maxChannelName: 5);
286+
doTest('check #**🙂🙂🙂🙂🙂🙂^', null, maxChannelName: 5);
185287

186288
// Emoji (":smile:").
187289

0 commit comments

Comments
 (0)