diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 5b0522f42c..f03f26734f 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -1,10 +1,107 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import '../api/model/events.dart'; import '../api/model/model.dart'; +import '../widgets/compose_box.dart'; import 'narrow.dart'; import 'store.dart'; +extension Autocomplete on ContentTextEditingController { + AutocompleteIntent? autocompleteIntent() { + if (!selection.isValid || !selection.isNormalized) { + // We don't require [isCollapsed] to be true because we've seen that + // autocorrect and even backspace involve programmatically expanding the + // selection to the left. Once we know where the syntax starts, we can at + // least require that the selection doesn't extend leftward past that; + // see below. + return null; + } + final textUntilCursor = text.substring(0, selection.end); + for ( + int position = selection.end - 1; + position >= 0 && (selection.end - position <= 30); + position-- + ) { + if (textUntilCursor[position] != '@') { + continue; + } + final match = mentionAutocompleteMarkerRegex.matchAsPrefix(textUntilCursor, position); + if (match == null) { + continue; + } + if (selection.start < position) { + // See comment about [TextSelection.isCollapsed] above. + return null; + } + return AutocompleteIntent( + syntaxStart: position, + query: MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_'), + textEditingValue: value); + } + return null; + } +} + +final RegExp mentionAutocompleteMarkerRegex = (() { + // What's likely to come before an @-mention: the start of the string, + // whitespace, or punctuation. Letters are unlikely; in that case an email + // might be intended. (By punctuation, we mean *some* punctuation, like "(". + // We could refine this.) + const beforeAtSign = r'(?<=^|\s|\p{Punctuation})'; + + // Characters that would defeat searches in full_name and emails, since + // they're prohibited in both forms. These are all the characters prohibited + // in full_name except "@", which appears in emails. (For the form of + // full_name, find uses of UserProfile.NAME_INVALID_CHARS in zulip/zulip.) + const fullNameAndEmailCharExclusions = r'\*`\\>"\p{Other}'; + + return RegExp( + beforeAtSign + + r'@(_?)' // capture, so we can distinguish silent mentions + + r'(|' + // Reject on whitespace right after "@" or "@_". Emails can't start with + // it, and full_name can't either (it's run through Python's `.strip()`). + + r'[^\s' + fullNameAndEmailCharExclusions + r']' + + r'[^' + fullNameAndEmailCharExclusions + r']*' + + r')$', + unicode: true); +})(); + +/// The content controller's recognition that the user might want autocomplete UI. +class AutocompleteIntent { + AutocompleteIntent({ + required this.syntaxStart, + required this.query, + required this.textEditingValue, + }); + + /// At what index the intent's syntax starts. E.g., 3, in "Hi @chris". + /// + /// May be used with [textEditingValue] to make a new [TextEditingValue] with + /// the autocomplete interaction's result: e.g., one that replaces "Hi @chris" + /// with "Hi @**Chris Bobbe** ". (Assume [textEditingValue.selection.end] is + /// the end of the syntax.) + /// + /// Using this to index into something other than [textEditingValue] will give + /// undefined behavior and might cause a RangeError; it should be avoided. + // If a subclassed [TextEditingValue] could itself be the source of + // [syntaxStart], then the safe behavior would be accomplished more + // naturally, I think. But [TextEditingController] doesn't support subclasses + // that use a custom/subclassed [TextEditingValue], so that's not convenient. + final int syntaxStart; + + final MentionAutocompleteQuery query; // TODO other autocomplete query types + + /// The [TextEditingValue] whose text [syntaxStart] refers to. + final TextEditingValue textEditingValue; + + @override + String toString() { + return '${objectRuntimeType(this, 'AutocompleteIntent')}(syntaxStart: $syntaxStart, query: $query, textEditingValue: $textEditingValue})'; + } +} + /// A per-account manager for the view-models of autocomplete interactions. /// /// There should be exactly one of these per PerAccountStore. @@ -86,7 +183,9 @@ class MentionAutocompleteView extends ChangeNotifier { @override void dispose() { store.autocompleteViewManager.unregisterMentionAutocomplete(this); - // TODO cancel in-progress computations if possible + // We cancel in-progress computations by checking [hasListeners] between tasks. + // After [super.dispose] is called, [hasListeners] returns false. + // TODO test that logic (may involve detecting an unhandled Future rejection; how?) super.dispose(); } @@ -134,7 +233,7 @@ class MentionAutocompleteView extends ChangeNotifier { } if (newResults == null) { - // Query was old; new search is in progress. + // Query was old; new search is in progress. Or, no listeners to notify. return; } @@ -152,7 +251,7 @@ class MentionAutocompleteView extends ChangeNotifier { // CPU perf: End this task; enqueue a new one for resuming this work await Future(() {}); - if (query != _currentQuery) { + if (query != _currentQuery || !hasListeners) { // false if [dispose] has been called. return null; } @@ -173,11 +272,14 @@ class MentionAutocompleteView extends ChangeNotifier { } class MentionAutocompleteQuery { - MentionAutocompleteQuery(this.raw) + MentionAutocompleteQuery(this.raw, {this.silent = false}) : _lowercaseWords = raw.toLowerCase().split(' '); final String raw; + /// Whether the user wants a silent mention (@_query, vs. @query). + final bool silent; + final List _lowercaseWords; bool testUser(User user, AutocompleteDataCache cache) { @@ -203,13 +305,18 @@ class MentionAutocompleteQuery { } } + @override + String toString() { + return '${objectRuntimeType(this, 'MentionAutocompleteQuery')}(raw: $raw, silent: $silent})'; + } + @override bool operator ==(Object other) { - return other is MentionAutocompleteQuery && other.raw == raw; + return other is MentionAutocompleteQuery && other.raw == raw && other.silent == silent; } @override - int get hashCode => Object.hash('MentionAutocompleteQuery', raw); + int get hashCode => Object.hash('MentionAutocompleteQuery', raw, silent); } class AutocompleteDataCache { diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2eca896cc4..637cc777d8 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import '../api/route/messages.dart'; +import '../model/autocomplete.dart'; import '../model/narrow.dart'; import 'dialog.dart'; import 'store.dart'; @@ -152,12 +153,14 @@ class ContentTextEditingController extends TextEditingController { /// The content input for StreamComposeBox. class _StreamContentInput extends StatefulWidget { const _StreamContentInput({ + required this.narrow, required this.streamId, required this.controller, required this.topicController, required this.focusNode, }); + final Narrow narrow; final int streamId; final ContentTextEditingController controller; final TopicTextEditingController topicController; @@ -168,6 +171,8 @@ class _StreamContentInput extends StatefulWidget { } class _StreamContentInputState extends State<_StreamContentInput> { + MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types + late String _topicTextNormalized; _topicChanged() { @@ -176,16 +181,33 @@ class _StreamContentInputState extends State<_StreamContentInput> { }); } + _changed() { + final newAutocompleteIntent = widget.controller.autocompleteIntent(); + if (newAutocompleteIntent != null) { + final store = PerAccountStoreWidget.of(context); + _mentionAutocompleteView ??= MentionAutocompleteView.init( + store: store, narrow: widget.narrow); + _mentionAutocompleteView!.query = newAutocompleteIntent.query; + } else { + if (_mentionAutocompleteView != null) { + _mentionAutocompleteView!.dispose(); + _mentionAutocompleteView = null; + } + } + } + @override void initState() { super.initState(); _topicTextNormalized = widget.topicController.textNormalized(); widget.topicController.addListener(_topicChanged); + widget.controller.addListener(_changed); } @override void dispose() { widget.topicController.removeListener(_topicChanged); + widget.controller.removeListener(_changed); super.dispose(); } @@ -572,7 +594,10 @@ class _StreamSendButtonState extends State<_StreamSendButton> { /// The compose box for writing a stream message. class StreamComposeBox extends StatefulWidget { - const StreamComposeBox({super.key, required this.streamId}); + const StreamComposeBox({super.key, required this.narrow, required this.streamId}); + + /// The narrow on view in the message list. + final Narrow narrow; final int streamId; @@ -633,6 +658,7 @@ class _StreamComposeBoxState extends State { topicInput, const SizedBox(height: 8), _StreamContentInput( + narrow: widget.narrow, streamId: widget.streamId, topicController: _topicController, controller: _contentController, @@ -666,7 +692,7 @@ class ComposeBox extends StatelessWidget { Widget build(BuildContext context) { final narrow = this.narrow; if (narrow is StreamNarrow) { - return StreamComposeBox(streamId: narrow.streamId); + return StreamComposeBox(narrow: narrow, streamId: narrow.streamId); } else if (narrow is TopicNarrow) { return const SizedBox.shrink(); // TODO(#144): add a single-topic compose box } else if (narrow is AllMessagesNarrow) { diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index 33fdfbc9ff..f3317da586 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -1,5 +1,15 @@ import 'package:checks/checks.dart'; import 'package:zulip/model/autocomplete.dart'; +import 'package:zulip/widgets/compose_box.dart'; + +extension ContentTextEditingControllerChecks on Subject { + Subject get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent'); +} + +extension AutocompleteIntentChecks on Subject { + Subject get syntaxStart => has((i) => i.syntaxStart, 'syntaxStart'); + Subject get query => has((i) => i.query, 'query'); +} extension UserMentionAutocompleteResultChecks on Subject { Subject get userId => has((r) => r.userId, 'userId'); diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index d4432de9f5..9a6cd48c21 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -1,17 +1,170 @@ import 'dart:async'; +import 'dart:convert'; import 'package:checks/checks.dart'; import 'package:fake_async/fake_async.dart'; +import 'package:flutter/cupertino.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/autocomplete.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/compose_box.dart'; import '../example_data.dart' as eg; import 'test_store.dart'; import 'autocomplete_checks.dart'; void main() { + group('ContentTextEditingController.autocompleteIntent', () { + parseMarkedText(String markedText) { + final TextSelection selection; + int? expectedSyntaxStart; + final textBuffer = StringBuffer(); + final caretPositions = []; + int i = 0; + for (final char in markedText.codeUnits) { + if (char == 94 /* ^ */) { + caretPositions.add(i); + continue; + } else if (char == 126 /* ~ */) { + if (expectedSyntaxStart != null) { + throw Exception('Test error: too many ~ in input'); + } + expectedSyntaxStart = i; + continue; + } + textBuffer.writeCharCode(char); + i++; + } + switch (caretPositions.length) { + case 0: + selection = const TextSelection.collapsed(offset: -1); + case 1: + selection = TextSelection(baseOffset: caretPositions[0], extentOffset: caretPositions[0]); + case 2: + selection = TextSelection(baseOffset: caretPositions[0], extentOffset: caretPositions[1]); + default: + throw Exception('Test error: too many ^ in input'); + } + return ( + value: TextEditingValue(text: textBuffer.toString(), selection: selection), + expectedSyntaxStart: expectedSyntaxStart); + } + + /// Test the given input, in a convenient format. + /// + /// Represent selection handles as "^". For convenience, a single "^" can + /// represent a collapsed selection (cursor position). For a null selection, + /// as when the input has never been focused, just omit "^" from the string. + /// + /// Represent the expected syntax start index (the index of "@" in a + /// mention-autocomplete attempt) as "~". + /// + /// For example, "~@chris^" means the text is "@chris", the selection is + /// collapsed at index 6, and we expect the syntax to start at index 0. + doTest(String markedText, MentionAutocompleteQuery? expectedQuery) { + final description = expectedQuery != null + ? 'in ${jsonEncode(markedText)}, query ${jsonEncode(expectedQuery.raw)}' + : 'no query in ${jsonEncode(markedText)}'; + test(description, () { + final controller = ContentTextEditingController(); + final parsed = parseMarkedText(markedText); + assert((expectedQuery == null) == (parsed.expectedSyntaxStart == null)); + controller.value = parsed.value; + if (expectedQuery == null) { + check(controller).autocompleteIntent.isNull(); + } else { + check(controller).autocompleteIntent.isNotNull() + ..query.equals(expectedQuery) + ..syntaxStart.equals(parsed.expectedSyntaxStart!); + } + }); + } + + MentionAutocompleteQuery queryOf(String raw) => MentionAutocompleteQuery(raw, silent: false); + MentionAutocompleteQuery silentQueryOf(String raw) => MentionAutocompleteQuery(raw, silent: true); + + doTest('', null); + doTest('^', null); + + doTest('!@#\$%&*()_+', null); + + doTest('^@', null); doTest('^@_', null); + doTest('^@abc', null); doTest('^@_abc', null); + doTest('@abc', null); doTest('@_abc', null); // (no cursor) + + doTest('@ ^', null); // doTest('@_ ^', null); // (would fail, but OK… technically "_" could start a word in full_name) + doTest('@*^', null); doTest('@_*^', null); + doTest('@`^', null); doTest('@_`^', null); + doTest('@\\^', null); doTest('@_\\^', null); + doTest('@>^', null); doTest('@_>^', null); + doTest('@"^', null); doTest('@_"^', null); + doTest('@\n^', null); doTest('@_\n^', null); // control character + doTest('@\u0000^', null); doTest('@_\u0000^', null); // control + doTest('@\u061C^', null); doTest('@_\u061C^', null); // format character + doTest('@\u0600^', null); doTest('@_\u0600^', null); // format + doTest('@\uD834^', null); doTest('@_\uD834^', null); // leading surrogate + + doTest('email support@^', null); + doTest('email support@zulip^', null); + doTest('email support@zulip.com^', null); + doTest('support@zulip.com^', null); + doTest('email support@ with details of the issue^', null); + doTest('email support@^ with details of the issue', null); + + doTest('Ask @**Chris Bobbe**^', null); doTest('Ask @_**Chris Bobbe**^', null); + doTest('Ask @**Chris Bobbe^**', null); doTest('Ask @_**Chris Bobbe^**', null); + doTest('Ask @**Chris^ Bobbe**', null); doTest('Ask @_**Chris^ Bobbe**', null); + doTest('Ask @**^Chris Bobbe**', null); doTest('Ask @_**^Chris Bobbe**', null); + + doTest('`@chris^', null); doTest('`@_chris^', null); + + doTest('~@^_', queryOf('')); // Odd/unlikely, but should not crash + + doTest('~@__^', silentQueryOf('_')); + + doTest('~@^abc^', queryOf('abc')); doTest('~@_^abc^', silentQueryOf('abc')); + doTest('~@a^bc^', queryOf('abc')); doTest('~@_a^bc^', silentQueryOf('abc')); + doTest('~@ab^c^', queryOf('abc')); doTest('~@_ab^c^', silentQueryOf('abc')); + doTest('~^@^', queryOf('')); doTest('~^@_^', silentQueryOf('')); + // but: + doTest('^hello @chris^', null); doTest('^hello @_chris^', null); + + doTest('~@me@zulip.com^', queryOf('me@zulip.com')); doTest('~@_me@zulip.com^', silentQueryOf('me@zulip.com')); + doTest('~@me@^zulip.com^', queryOf('me@zulip.com')); doTest('~@_me@^zulip.com^', silentQueryOf('me@zulip.com')); + doTest('~@me^@zulip.com^', queryOf('me@zulip.com')); doTest('~@_me^@zulip.com^', silentQueryOf('me@zulip.com')); + doTest('~@^me@zulip.com^', queryOf('me@zulip.com')); doTest('~@_^me@zulip.com^', silentQueryOf('me@zulip.com')); + + doTest('~@abc^', queryOf('abc')); doTest('~@_abc^', silentQueryOf('abc')); + doTest(' ~@abc^', queryOf('abc')); doTest(' ~@_abc^', silentQueryOf('abc')); + doTest('(~@abc^', queryOf('abc')); doTest('(~@_abc^', silentQueryOf('abc')); + doTest('—~@abc^', queryOf('abc')); doTest('—~@_abc^', silentQueryOf('abc')); + doTest('"~@abc^', queryOf('abc')); doTest('"~@_abc^', silentQueryOf('abc')); + doTest('“~@abc^', queryOf('abc')); doTest('“~@_abc^', silentQueryOf('abc')); + doTest('。~@abc^', queryOf('abc')); doTest('。~@_abc^', silentQueryOf('abc')); + doTest('«~@abc^', queryOf('abc')); doTest('«~@_abc^', silentQueryOf('abc')); + + doTest('~@ab^c', queryOf('ab')); doTest('~@_ab^c', silentQueryOf('ab')); + doTest('~@a^bc', queryOf('a')); doTest('~@_a^bc', silentQueryOf('a')); + doTest('~@^abc', queryOf('')); doTest('~@_^abc', silentQueryOf('')); + doTest('~@^', queryOf('')); doTest('~@_^', silentQueryOf('')); + + doTest('~@abc ^', queryOf('abc ')); doTest('~@_abc ^', silentQueryOf('abc ')); + doTest('~@abc^ ^', queryOf('abc ')); doTest('~@_abc^ ^', silentQueryOf('abc ')); + doTest('~@ab^c ^', queryOf('abc ')); doTest('~@_ab^c ^', silentQueryOf('abc ')); + doTest('~@^abc ^', queryOf('abc ')); doTest('~@_^abc ^', silentQueryOf('abc ')); + + doTest('Please ask ~@chris^', queryOf('chris')); doTest('Please ask ~@_chris^', silentQueryOf('chris')); + doTest('Please ask ~@chris bobbe^', queryOf('chris bobbe')); doTest('Please ask ~@_chris bobbe^', silentQueryOf('chris bobbe')); + + doTest('~@Rodion Romanovich Raskolnikov^', queryOf('Rodion Romanovich Raskolnikov')); + doTest('~@_Rodion Romanovich Raskolniko^', silentQueryOf('Rodion Romanovich Raskolniko')); + doTest('~@Родион Романович Раскольников^', queryOf('Родион Романович Раскольников')); + doTest('~@_Родион Романович Раскольнико^', silentQueryOf('Родион Романович Раскольнико')); + doTest('If @chris is around, please ask him.^', null); // @ sign is too far away from cursor + doTest('If @_chris is around, please ask him.^', null); // @ sign is too far away from cursor + }); + test('MentionAutocompleteView misc', () async { const narrow = AllMessagesNarrow(); final store = eg.store()