feat(#5): :shortcode: emoji autocomplete in the composer

The maintainer's keyboard can't type emoji, so the composer now accepts
:shortcode: input with an inline autocomplete:
- Type `:` to open a bottom-anchored picker; arrows/Tab/click select, Esc
  dismisses. Best/exact match sits at the bottom, nearest the caret.
- Enter completes an unambiguous emoji (single OR exact match) then sends,
  so `:rofl` + Enter ships the glyph, not the literal text. Ambiguous
  matches still send raw; Tab picks one deliberately.
- A completed `:name:` (closing colon) auto-converts in place.
- 1913 gemoji shortcodes bundled (lib/helpers/emoji_shortcodes.dart, MIT).
- Byte-count aware via the existing ByteCountedTextField; shares the
  MentionAutocompleteField with @-mention input (both composers wired).

Version 9.0.0+12 -> 9.1.0+14 (MINOR feature; build kept monotonic after
the rebrand's +13 on the parallel chore/offband-rebrand branch).

Grid/tap picker deferred (#5 follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/batch1-ux
Strycher 1 month ago
parent 10efc42c53
commit 501432c819

File diff suppressed because it is too large Load Diff

@ -27,6 +27,7 @@ import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/mention_autocomplete.dart'; import '../widgets/mention_autocomplete.dart';
import '../helpers/emoji_shortcodes.dart';
import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
@ -1248,6 +1249,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode, focusNode: _textFieldFocusNode,
candidates: _buildMentionCandidates(connector), candidates: _buildMentionCandidates(connector),
emojiShortcodes: emojiShortcodes,
hintText: context.l10n.chat_typeMessage, hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(), onSubmitted: (_) => _sendMessage(),
encoder: encoder:

@ -30,7 +30,8 @@ import '../services/path_history_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/elements_ui.dart'; import '../widgets/elements_ui.dart';
import '../widgets/byte_count_input.dart'; import '../widgets/mention_autocomplete.dart';
import '../helpers/emoji_shortcodes.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
@ -621,10 +622,12 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
); );
} }
return ByteCountedTextField( return MentionAutocompleteField(
maxBytes: maxBytes, maxBytes: maxBytes,
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode, focusNode: _textFieldFocusNode,
candidates: const [],
emojiShortcodes: emojiShortcodes,
hintText: context.l10n.chat_typeMessage, hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector), onSubmitted: (_) => _sendMessage(connector),
encoder: encoder:

@ -20,19 +20,33 @@ class MentionCandidate {
}); });
} }
/// Wraps [ByteCountedTextField] with an `@`-mention autocomplete dropdown. /// One row in the autocomplete dropdown.
class _Entry {
final String label;
final String? emoji; // leading glyph (emoji rows)
final IconData? icon; // leading icon (mention rows)
final String insert; // replacement text for the trigger token
const _Entry({required this.label, this.emoji, this.icon, required this.insert});
}
/// Wraps [ByteCountedTextField] with autocomplete for `@`-mentions and
/// `:emoji:` shortcodes.
/// ///
/// Typing `@` at the start of a word opens a dropdown above the field listing /// - Typing `@` at the start of a word lists matching [candidates] (recent
/// matching [candidates]. Recent channel senders sit at the bottom (most recent /// senders at the bottom, contacts above) and inserts `@[ExactName] `.
/// closest to the input and auto-highlighted); other contacts stack above in /// - Typing `:` at the start of a word lists matching [emojiShortcodes] and
/// alpha order with A nearest the input. Up/Down move the highlight, Tab or /// inserts the emoji character. Typing a full `:name:` auto-converts it.
/// click selects, Esc dismisses, Enter sends. Selecting replaces the `@query` ///
/// with `@[ExactName] ` at the cursor. /// Up/Down move the highlight, Tab or click selects, Esc dismisses. Enter
/// sends, and first completes an unambiguous emoji (single or exact shortcode
/// match) so it ships the glyph rather than the literal text. Either feature
/// is disabled by leaving its data empty/null.
class MentionAutocompleteField extends StatefulWidget { class MentionAutocompleteField extends StatefulWidget {
final int maxBytes; final int maxBytes;
final TextEditingController controller; final TextEditingController controller;
final FocusNode focusNode; final FocusNode focusNode;
final List<MentionCandidate> candidates; final List<MentionCandidate> candidates;
final Map<String, String>? emojiShortcodes;
final String? hintText; final String? hintText;
final ValueChanged<String>? onSubmitted; final ValueChanged<String>? onSubmitted;
final String Function(String)? encoder; final String Function(String)? encoder;
@ -43,7 +57,8 @@ class MentionAutocompleteField extends StatefulWidget {
required this.maxBytes, required this.maxBytes,
required this.controller, required this.controller,
required this.focusNode, required this.focusNode,
required this.candidates, this.candidates = const [],
this.emojiShortcodes,
this.hintText, this.hintText,
this.onSubmitted, this.onSubmitted,
this.encoder, this.encoder,
@ -59,17 +74,14 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
final LayerLink _link = LayerLink(); final LayerLink _link = LayerLink();
OverlayEntry? _overlay; OverlayEntry? _overlay;
/// Match list ordered top-to-bottom: contacts (Z..A) then recent (old..new). List<_Entry> _matches = const [];
List<MentionCandidate> _matches = const [];
/// Index in [_matches] where the recent section begins.
int _recentStart = 0; int _recentStart = 0;
/// Currently highlighted index (bottom-most by default).
int _highlighted = -1; int _highlighted = -1;
int _tokenStart = -1;
/// Index of the triggering `@` in the controller text. /// Active emoji query while the dropdown shows emoji matches (null for
int _atIndex = -1; /// mentions or when closed). Lets Enter complete an unambiguous emoji + send.
String? _emojiQuery;
@override @override
void initState() { void initState() {
@ -94,7 +106,30 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
if (!widget.focusNode.hasFocus) _close(); if (!widget.focusNode.hasFocus) _close();
} }
/// A "word" char (ASCII letter/digit). A trigger is only suppressed when it
/// sits immediately after one of these (e.g. `http://`, `3:30`), so a colon
/// right after an emoji or punctuation still opens the picker.
static bool _isWordChar(String c) {
final u = c.codeUnitAt(0);
return (u >= 0x61 && u <= 0x7a) || // a-z
(u >= 0x41 && u <= 0x5a) || // A-Z
(u >= 0x30 && u <= 0x39); // 0-9
}
static bool _isShortcodeChar(String c) {
final u = c.codeUnitAt(0);
return (u >= 0x61 && u <= 0x7a) || // a-z
(u >= 0x30 && u <= 0x39) || // 0-9
c == '_' ||
c == '+' ||
c == '-';
}
void _onChanged() { void _onChanged() {
// Cursor-driven trigger detection (same shape as multi_trigger_autocomplete's
// invokingTrigger). It does not special-case the IME composing region
// fine for hardware keyboards; a known limitation for CJK/IME input, which
// the reference package doesn't handle either.
final value = widget.controller.value; final value = widget.controller.value;
final sel = value.selection; final sel = value.selection;
final text = value.text; final text = value.text;
@ -107,35 +142,75 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
_close(); _close();
return; return;
} }
// Walk back from the caret to an `@` that begins a word, with no
// whitespace (or a `]`, which would break the bracket form) in between. // Auto-convert a completed :name: when the closing colon is typed.
final shortcodes = widget.emojiShortcodes;
if (shortcodes != null && caret >= 2 && text[caret - 1] == ':') {
int j = caret - 2;
while (j >= 0 && _isShortcodeChar(text[j])) {
j--;
}
if (j >= 0 && text[j] == ':' && (j == 0 || !_isWordChar(text[j - 1]))) {
final name = text.substring(j + 1, caret - 1);
final glyph = shortcodes[name];
if (name.isNotEmpty && glyph != null) {
final newText = text.substring(0, j) + glyph + text.substring(caret);
widget.controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: j + glyph.length),
);
_close();
return;
}
}
}
// Walk back to a trigger char (@ or :) that begins a word.
int i = caret - 1; int i = caret - 1;
String? trigger;
while (i >= 0) { while (i >= 0) {
final ch = text[i]; final ch = text[i];
if (ch == '@') break; if (ch == '@') {
trigger = '@';
break;
}
if (ch == ':') {
trigger = ':';
break;
}
if (ch == ' ' || ch == '\n' || ch == '\t' || ch == ']') { if (ch == ' ' || ch == '\n' || ch == '\t' || ch == ']') {
_close(); break;
return;
} }
i--; i--;
} }
if (i < 0 || text[i] != '@') { if (trigger == null || i < 0) {
_close(); _close();
return; return;
} }
// `@` must begin a word: preceded by start-of-text or whitespace. if (i > 0 && _isWordChar(text[i - 1])) {
if (i > 0) {
final prev = text[i - 1];
if (prev != ' ' && prev != '\n' && prev != '\t') {
_close(); _close();
return; return;
} }
final query = text.substring(i + 1, caret);
// No real shortcode or name is this long; avoid pathological scans.
if (query.length > 64) {
_close();
return;
}
_tokenStart = i;
if (trigger == '@') {
_filterMentions(query);
} else {
_filterEmoji(query);
} }
_atIndex = i;
_filter(text.substring(i + 1, caret));
} }
void _filter(String query) { void _filterMentions(String query) {
_emojiQuery = null;
if (widget.candidates.isEmpty) {
_close();
return;
}
final q = query.toLowerCase(); final q = query.toLowerCase();
final matched = widget.candidates.where( final matched = widget.candidates.where(
(c) => c.name.toLowerCase().startsWith(q), (c) => c.name.toLowerCase().startsWith(q),
@ -152,12 +227,9 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
} }
} }
final recent = recentByName.values.toList(); final recent = recentByName.values.toList();
// Contacts: alpha with Z at top, A at bottom (descending).
contacts.sort( contacts.sort(
(a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()), (a, b) => b.name.toLowerCase().compareTo(a.name.toLowerCase()),
); );
// Recent: oldest at top, most recent at bottom.
recent.sort((a, b) { recent.sort((a, b) {
final ta = a.lastSeen; final ta = a.lastSeen;
final tb = b.lastSeen; final tb = b.lastSeen;
@ -174,9 +246,52 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
_close(); _close();
return; return;
} }
_matches = combined; _matches = combined
.map(
(c) => _Entry(
label: c.name,
icon: c.recent ? Icons.history : Icons.person_outline,
insert: '@[${c.name}] ',
),
)
.toList();
_recentStart = contacts.length; _recentStart = contacts.length;
_highlighted = combined.length - 1; _highlighted = _matches.length - 1;
_show();
}
void _filterEmoji(String query) {
final shortcodes = widget.emojiShortcodes;
if (shortcodes == null || query.isEmpty) {
_close();
return;
}
for (final ch in query.split('')) {
if (!_isShortcodeChar(ch)) {
_close();
return;
}
}
final names = shortcodes.keys.where((k) => k.startsWith(query)).toList();
if (names.isEmpty) {
_close();
return;
}
names.sort((a, b) {
if (a == query && b != query) return -1;
if (b == query && a != query) return 1;
final byLen = a.length.compareTo(b.length);
if (byLen != 0) return byLen;
return a.compareTo(b);
});
// Best 8, then reversed so the best sits at the bottom (nearest the input).
final top = names.take(8).toList().reversed.toList();
_matches = top
.map((k) => _Entry(label: ':$k:', emoji: shortcodes[k], insert: shortcodes[k]!))
.toList();
_recentStart = _matches.length; // no divider for emoji
_highlighted = _matches.length - 1;
_emojiQuery = query;
_show(); _show();
} }
@ -199,7 +314,8 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
_remove(); _remove();
_matches = const []; _matches = const [];
_highlighted = -1; _highlighted = -1;
_atIndex = -1; _tokenStart = -1;
_emojiQuery = null;
} }
} }
@ -227,25 +343,38 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
} }
if (key == LogicalKeyboardKey.enter || if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) { key == LogicalKeyboardKey.numpadEnter) {
// For an unambiguous emoji shortcode (a single match, or the exact name),
// Enter completes the glyph first, then sends so `:rofl` + Enter ships
// the emoji, not the literal text. Ambiguous matches still send raw; use
// Tab to pick one deliberately.
final q = _emojiQuery;
if (q != null && _matches.isNotEmpty) {
final best = _matches[_highlighted];
if (_matches.length == 1 || best.label == ':$q:') {
_select(best); // inserts the glyph and closes the dropdown
widget.onSubmitted?.call(widget.controller.text);
return KeyEventResult.handled;
}
}
final text = widget.controller.text; final text = widget.controller.text;
_close(); _close();
widget.onSubmitted?.call(text); widget.onSubmitted?.call(text);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
void _select(MentionCandidate c) { void _select(_Entry entry) {
final text = widget.controller.text; final text = widget.controller.text;
final caret = widget.controller.selection.baseOffset; final caret = widget.controller.selection.baseOffset;
if (_atIndex < 0 || caret < _atIndex || caret > text.length) { if (_tokenStart < 0 || caret < _tokenStart || caret > text.length) {
_close(); _close();
return; return;
} }
final insertion = '@[${c.name}] ';
final newText = final newText =
text.substring(0, _atIndex) + insertion + text.substring(caret); text.substring(0, _tokenStart) + entry.insert + text.substring(caret);
final cursor = _atIndex + insertion.length; final cursor = _tokenStart + entry.insert.length;
widget.controller.value = TextEditingValue( widget.controller.value = TextEditingValue(
text: newText, text: newText,
selection: TextSelection.collapsed(offset: cursor), selection: TextSelection.collapsed(offset: cursor),
@ -264,11 +393,11 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
if (i == _recentStart && _recentStart > 0 && _recentStart < _matches.length) { if (i == _recentStart && _recentStart > 0 && _recentStart < _matches.length) {
children.add(_recentDivider(theme)); children.add(_recentDivider(theme));
} }
final c = _matches[i]; final e = _matches[i];
final selected = i == _highlighted; final selected = i == _highlighted;
children.add( children.add(
InkWell( InkWell(
onTap: () => _select(c), onTap: () => _select(e),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
color: selected color: selected
@ -277,15 +406,18 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
children: [ children: [
if (e.emoji != null)
Text(e.emoji!, style: const TextStyle(fontSize: 16))
else
Icon( Icon(
c.recent ? Icons.history : Icons.person_outline, e.icon,
size: 15, size: 15,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
c.name, e.label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@ -319,7 +451,10 @@ class _MentionAutocompleteFieldState extends State<MentionAutocompleteField> {
constraints: const BoxConstraints(maxHeight: 220), constraints: const BoxConstraints(maxHeight: 220),
child: SingleChildScrollView( child: SingleChildScrollView(
reverse: true, reverse: true,
child: Column(mainAxisSize: MainAxisSize.min, children: children), child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
), ),
), ),
), ),

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 9.0.0+12 version: 9.1.0+14
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

Loading…
Cancel
Save

Powered by TurnKey Linux.