Skip to content

Commit f5f9947

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 6593a7e commit f5f9947

File tree

3 files changed

+158
-6
lines changed

3 files changed

+158
-6
lines changed

lib/model/autocomplete.dart

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

2020
extension ComposeContentAutocomplete on ComposeContentController {
21+
int get _maxLookbackForAutocompleteIntent {
22+
return 1 // intent character i.e., "#"
23+
+ 2 // some optional characters i.e., "_" for silent mention or "**"
24+
+ store.maxChannelNameLength;
25+
}
26+
2127
AutocompleteIntent<ComposeAutocompleteQuery>? autocompleteIntent() {
2228
if (!selection.isValid || !selection.isNormalized) {
2329
// We don't require [isCollapsed] to be true because we've seen that
@@ -30,7 +36,7 @@ extension ComposeContentAutocomplete on ComposeContentController {
3036

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

3541
if (selection.start < earliest) {
3642
// The selection extends to before any position we'd consider
@@ -48,6 +54,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
4854
} else if (charAtPos == ':') {
4955
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
5056
if (match == null) continue;
57+
} else if (charAtPos == '#') {
58+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
59+
if (match == null) continue;
5160
} else {
5261
continue;
5362
}
@@ -66,6 +75,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
6675
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
6776
if (match == null) continue;
6877
query = EmojiAutocompleteQuery(match[1]!);
78+
} else if (charAtPos == '#') {
79+
final match = _channelLinkIntentRegex.matchAsPrefix(textUntilCursor, pos);
80+
if (match == null) continue;
81+
query = ChannelLinkAutocompleteQuery(match[1] ?? match[2]!);
6982
} else {
7083
continue;
7184
}
@@ -166,6 +179,48 @@ final RegExp _emojiIntentRegex = (() {
166179
+ r')$');
167180
})();
168181

182+
final RegExp _channelLinkIntentRegex = () {
183+
// What's likely to come just before #channel syntax: the start of the string,
184+
// whitespace, or punctuation. Letters are unlikely; in that case a GitHub-
185+
// style "zulip/zulip-flutter#124" link might be intended (as on CZO where
186+
// there's a custom linkifier for that).
187+
//
188+
// By punctuation, we mean *some* punctuation, like "(". We make "#" and "@"
189+
// exceptions, to support typing "##channel" for the channel query "#channel",
190+
// and typing "@#user" for the mention query "#user", because in 2025-11
191+
// channel and user name words can start with "#". (They can also contain "#"
192+
// anywhere else in the name; we don't handle that specially.)
193+
const before = r'(?<=^|\s|\p{Punctuation})(?<![#@])';
194+
// TODO(dart-future): Regexps in ES 2024 have a /v aka unicodeSets flag;
195+
// if Dart matches that, we could combine into one character class
196+
// meaning "whitespace and punctuation, except not `#` or `@`":
197+
// r'(?<=^|[[\s\p{Punctuation}]--[#@]])'
198+
199+
// TODO(upstream): maybe use duplicate-named capture groups for better readability?
200+
// https://github.com/dart-lang/sdk/issues/61337
201+
return RegExp(unicode: true,
202+
before
203+
+ r'#'
204+
// As Web, match both '#channel' and '#**channel'. In both cases, the raw
205+
// query is going to be 'channel'. Matching the second case ('#**channel')
206+
// is useful when the user selects a channel from the autocomplete list, but
207+
// then starts pressing "backspace" to edit the query and choose another
208+
// option, instead of clearing the entire query and starting from scratch.
209+
210+
// Also, web doesn't seem to have any sort of limitations for the type of
211+
// characters the channel name can contain.
212+
+ r'(?:'
213+
// Case '#channel': right after '#', reject whitespace as well as '**'.
214+
+ r'(?!\s|\*\*)(.*)'
215+
+ r'|'
216+
// Case '#**channel': right after '#**', reject whitespace.
217+
// Also, make sure that the remaining query doesn't contain '**',
218+
// otherwise '#**channel**' (which is a complete channel link syntax) and
219+
// any text followed by that will always match.
220+
+ r'\*\*(?!\s)((?:(?!\*\*).)*)'
221+
+ r')$');
222+
}();
223+
169224
/// The text controller's recognition that the user might want autocomplete UI.
170225
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
171226
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: 100 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-
store = eg.store();
87+
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,100 @@ 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 + maxChannelName) characters away to the left of cursor.
189+
doTest('If ~@chris^ is around, please ask him.', mention('chris'), maxChannelName: 20);
190+
doTest('If ~@_chris is^ around, please ask him.', silentMention('chris is'), maxChannelName: 20);
191+
doTest('If @chris is around, please ask him.^', null, maxChannelName: 20);
192+
doTest('If @_chris is around, please ask him.^', null, maxChannelName: 20);
193+
194+
// #channel link.
195+
196+
doTest('^#', null);
197+
doTest('^#abc', null);
198+
doTest('#abc', null); // (no cursor)
199+
200+
doTest('~#^', channelLink(''));
201+
doTest('~##^', channelLink('#'));
202+
doTest('~#abc^', channelLink('abc'));
203+
doTest('~#abc ^', channelLink('abc '));
204+
doTest('~#abc def^', channelLink('abc def'));
205+
206+
// Accept space before channel link syntax.
207+
doTest(' ~#abc^', channelLink('abc'));
208+
doTest('xyz ~#abc^', channelLink('abc'));
209+
210+
// Accept punctuations before channel link syntax.
211+
doTest(':~#abc^', channelLink('abc'));
212+
doTest('!~#abc^', channelLink('abc'));
213+
doTest(',~#abc^', channelLink('abc'));
214+
doTest('.~#abc^', channelLink('abc'));
215+
doTest('(~#abc^', channelLink('abc')); 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+
// … and other punctuations except '#' and '@':
221+
doTest('~##abc^', channelLink('#abc'));
222+
doTest('~@#abc^', mention('#abc'));
223+
224+
// Avoid other characters before channel link syntax.
225+
doTest('+#abc^', null);
226+
doTest('=#abc^', null);
227+
doTest('\$#abc^', null);
228+
doTest('zulip/zulip-flutter#124^', null);
229+
doTest('XYZ#abc^', null);
230+
doTest('xyz#abc^', null);
231+
// … but
232+
doTest('~#xyz#abc^', channelLink('xyz#abc'));
233+
234+
// Avoid leading space character in query.
235+
doTest('# ^', null);
236+
doTest('# abc^', null);
237+
238+
// Avoid line-break characters in query.
239+
doTest('#\n^', null); doTest('#a\n^', null); doTest('#\na^', null); doTest('#a\nb^', null);
240+
doTest('#\r^', null); doTest('#a\r^', null); doTest('#\ra^', null); doTest('#a\rb^', null);
241+
doTest('#\r\n^', null); doTest('#a\r\n^', null); doTest('#\r\na^', null); doTest('#a\r\nb^', null);
242+
243+
// Allow all other sorts of characters in query.
244+
doTest('~#\u0000^', channelLink('\u0000')); // control
245+
doTest('~#\u061C^', channelLink('\u061C')); // format character
246+
doTest('~#\u0600^', channelLink('\u0600')); // format
247+
doTest('~#\uD834^', channelLink('\uD834')); // leading surrogate
248+
doTest('~#`^', channelLink('`')); doTest('~#a`b^', channelLink('a`b'));
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+
256+
// Two leading stars ('**') in the query are omitted.
257+
doTest('~#**^', channelLink(''));
258+
doTest('~#**abc^', channelLink('abc'));
259+
doTest('~#**abc ^', channelLink('abc '));
260+
doTest('~#**abc def^', channelLink('abc def'));
261+
doTest('#** ^', null);
262+
doTest('#** abc^', null);
263+
264+
doTest('~#**abc*^', channelLink('abc*'));
265+
266+
// Query with leading '**' should not contain other '**'.
267+
doTest('#**abc**^', null);
268+
doTest('#**abc** ^', null);
269+
doTest('#**abc** def^', null);
270+
271+
// Query without leading '**' can contain other '**'.
272+
doTest('~#abc**^', channelLink('abc**'));
273+
doTest('~#abc** ^', channelLink('abc** '));
274+
doTest('~#abc** def^', channelLink('abc** def'));
275+
276+
// "#" sign can be (3 + maxChannelName) characters away to the left of cursor.
277+
doTest('check ~#**mobile dev^ team', channelLink('mobile dev'), maxChannelName: 10);
278+
doTest('check ~#mobile dev t^eam', channelLink('mobile dev t'), maxChannelName: 10);
279+
doTest('check #mobile dev te^am', null, maxChannelName: 10);
280+
doTest('check #mobile dev team for more info^', null, maxChannelName: 10);
185281

186282
// Emoji (":smile:").
187283

0 commit comments

Comments
 (0)