Skip to content

Commit

Permalink
emoji: Identify when the user intends an emoji autocomplete
Browse files Browse the repository at this point in the history
The "forbid preceding ':'" wrinkle is one I discovered because of
writing tests: I wrote down the '::^' test, was surprised to find
that it failed, and then went back and extended the regexp to
make it pass.

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.
  • Loading branch information
gnprice committed Nov 21, 2024
1 parent 792dd3a commit 58a3906
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 1 deletion.
31 changes: 31 additions & 0 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ extension ComposeContentAutocomplete on ComposeContentController {
if (charAtPos == '@') {
final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos);
if (match == null) continue;
} else if (charAtPos == ':') {
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
if (match == null) continue;
} else {
continue;
}
Expand All @@ -53,6 +56,10 @@ extension ComposeContentAutocomplete on ComposeContentController {
final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos);
if (match == null) continue;
query = MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_');
} else if (charAtPos == ':') {
final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos);
if (match == null) continue;
query = EmojiAutocompleteQuery(match[1]!);
} else {
continue;
}
Expand Down Expand Up @@ -98,6 +105,30 @@ final RegExp _mentionIntentRegex = (() {
unicode: true);
})();

final RegExp _emojiIntentRegex = (() {
// Similar reasoning as in _mentionIntentRegex.
// Specifically forbid a preceding ":", though, to make "::" not a query.
const before = r'(?<=^|\s|\p{Punctuation})(?<!:)';
// TODO(dart-future): Regexps in ES 2024 have a /v aka unicodeSets flag;
// if Dart matches that, we could combine into one character class
// meaning "whitespace and punctuation, except not `:`":
// r'(?<=^|[[\s\p{Punctuation}]--[:]])'

/// Characters that might be meant as part of (a query for) an emoji's name,
/// other than whitespace.
const nameCharacters = r'_\p{Letter}\p{Number}';

return RegExp(unicode: true,
before
+ r':'
+ r'(|'
// Reject on whitespace right after `:`; interpret that
// as the user choosing to get out of the emoji autocomplete.
+ r'[' + nameCharacters + r']'
+ r'[\s' + nameCharacters + r']*'
+ r')$');
})();

/// The text controller's recognition that the user might want autocomplete UI.
class AutocompleteIntent<QueryT extends AutocompleteQuery> {
AutocompleteIntent({
Expand Down
4 changes: 3 additions & 1 deletion lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';

import '../model/emoji.dart';
import 'content.dart';
import 'store.dart';
import '../model/autocomplete.dart';
Expand Down Expand Up @@ -38,7 +39,8 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
}

void _handleControllerChange() {
final newQuery = widget.autocompleteIntent()?.query;
var newQuery = widget.autocompleteIntent()?.query;
if (newQuery is EmojiAutocompleteQuery) newQuery = null; // TODO(#670)
// First, tear down the old view-model if necessary.
if (_viewModel != null
&& (newQuery == null
Expand Down
67 changes: 67 additions & 0 deletions test/model/autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/channels.dart';
import 'package:zulip/model/autocomplete.dart';
import 'package:zulip/model/emoji.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/compose_box.dart';
Expand Down Expand Up @@ -89,12 +90,15 @@ void main() {

MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false);
MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true);
EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw);

doTest('', null);
doTest('^', null);

doTest('!@#\$%&*()_+', null);

// @-mentions.

doTest('^@', null); doTest('^@_', null);
doTest('^@abc', null); doTest('^@_abc', null);
doTest('@abc', null); doTest('@_abc', null); // (no cursor)
Expand Down Expand Up @@ -169,6 +173,69 @@ void main() {
doTest('~@_Родион Романович Раскольнико^', silentMention('Родион Романович Раскольнико'));
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

// Emoji (":-mentions").

// Basic positive examples, to contrast with all the negative examples below.
doTest('~:^', emoji(''));
doTest('~:a^', emoji('a'));
doTest('~:a ^', emoji('a '));
doTest('~:a_^', emoji('a_'));
doTest('ok ~:s^', emoji('s'));

doTest('^:', null);
doTest('^:abc', null);
doTest(':abc', null); // (no cursor)

// Avoid interpreting colons in normal prose as queries.
doTest(': ^', null);
doTest(':\n^', null);
doTest('this:^', null);
doTest('this: ^', null);
doTest('là ~:^', emoji('')); // ambiguous in French prose, tant pis
doTest('là : ^', null);

// Avoid interpreting already-entered `:foo:` syntax as queries.
doTest(':smile:^', null);

// Avoid interpreting emoticons as queries.
doTest(':-^', null);
doTest(':)^', null); doTest(':-)^', null);
doTest(':(^', null); doTest(':-(^', null);
doTest(':/^', null); doTest(':-/^', null);
doTest('~:p^', emoji('p')); // ambiguously an emoticon
doTest(':-p^', null);

// Avoid interpreting as queries some ways colons appear in source code.
doTest('::^', null);
doTest(':<^', null);
doTest(':=^', null);

// Emoji names may have letters and numbers in many scripts.
// (A few even appear in the server's list of Unicode emoji;
// many more might be in a given realm's custom emoji.)
doTest('~:コ^', emoji('コ'));
doTest('~:空^', emoji('空'));
doTest('~:φ^', emoji('φ'));
doTest('~:100^', emoji('100'));
doTest('~:1^', emoji('1')); // U+FF11 FULLWIDTH DIGIT ONE
doTest('~:٢^', emoji('٢')); // U+0662 ARABIC-INDIC DIGIT TWO

// Accept punctuation before the emoji: opening…
doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a'));
doTest('[~:^', emoji('')); doTest('[~:a^', emoji('a'));
doTest('«~:^', emoji('')); doTest('«~:a^', emoji('a'));
doTest('(~:^', emoji('')); doTest('(~:a^', emoji('a'));
// … closing…
doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a'));
doTest(']~:^', emoji('')); doTest(']~:a^', emoji('a'));
doTest('»~:^', emoji('')); doTest('»~:a^', emoji('a'));
doTest(')~:^', emoji('')); doTest(')~:a^', emoji('a'));
// … and other.
doTest('.~:^', emoji('')); doTest('.~:a^', emoji('a'));
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
doTest(',~:^', emoji('')); doTest(',~:a^', emoji('a'));
doTest('。~:^', emoji('')); doTest('。~:a^', emoji('a'));
});

test('MentionAutocompleteView misc', () async {
Expand Down

0 comments on commit 58a3906

Please sign in to comment.