feat(mobile): add #channel autocomplete to compose bar (#411)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-27 21:42:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ed5dad788f
commit f1cef90376
3 changed files with 439 additions and 48 deletions
+224 -47
View File
@@ -9,7 +9,9 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
import 'emoji_picker.dart';
/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown
@@ -65,6 +67,11 @@ class ComposeBar extends HookConsumerWidget {
// Used to pass resolved pubkeys directly to onSend, avoiding regex.
final mentionMap = useRef(<String, String>{});
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
final channelStartIdx = useState(-1);
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(channelId));
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
@@ -94,33 +101,42 @@ class ComposeBar extends HookConsumerWidget {
if (!sel.isValid || !sel.isCollapsed) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
final cursor = sel.baseOffset;
if (cursor < 1) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
// Walk backward from cursor looking for a bare `@` at a word boundary.
int? atPos;
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (ch == '@') {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
atPos = i;
}
break;
}
}
// Walk backward from cursor looking for trigger characters.
// stopAtSpace: false — @mentions support multi-word display names.
final atPos = findTrigger(text, cursor, '@', stopAtSpace: false);
if (atPos != null) {
mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase();
mentionStartIdx.value = atPos;
channelQuery.value = null;
} else {
mentionQuery.value = null;
}
// Channel autocomplete detection — only when no @mention is active.
if (mentionQuery.value == null) {
final hashPos = findTrigger(text, cursor, '#');
if (hashPos != null) {
channelQuery.value = text
.substring(hashPos + 1, cursor)
.toLowerCase();
channelStartIdx.value = hashPos;
} else {
channelQuery.value = null;
}
} else {
channelQuery.value = null;
}
}
controller.addListener(listener);
@@ -135,58 +151,52 @@ class ComposeBar extends HookConsumerWidget {
currentPubkey,
);
// Filter channels against the query.
final channels = channelsAsync.asData?.value ?? <Channel>[];
final channelSuggestions = filterChannels(channels, channelQuery.value);
// Insert a selected mention into the text field.
void insertMention(ChannelMember member) {
final name = member.displayName?.trim().isNotEmpty == true
? member.displayName!.trim()
: member.pubkey.substring(0, 8);
final text = controller.text;
// Clamp indices to text bounds to guard against stale state.
final start = mentionStartIdx.value.clamp(0, text.length);
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
final mention = '@$name ';
// Track the resolved pubkey so we can pass it at send time.
mentionMap.value[name] = member.pubkey;
controller.text = '$before$mention$after';
controller.selection = TextSelection.collapsed(
offset: start + mention.length,
final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
mentionQuery.value = null;
focusNode.requestFocus();
}
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
channelQuery.value = null;
}
// Insert `@` at the cursor to manually trigger mention mode.
void triggerMention() {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' @' : '@';
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@');
// Insert `#` at the cursor to manually trigger channel mode.
void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#');
void clearComposer() {
controller.clear();
attachments.value = [];
mentionMap.value.clear();
mentionQuery.value = null;
channelQuery.value = null;
showFormatting.value = false;
uploadError.value = null;
focusNode.requestFocus();
@@ -290,9 +300,19 @@ class ComposeBar extends HookConsumerWidget {
// ----- Widget tree ----------------------------------------------------
final hasSuggestions =
suggestions.isNotEmpty || channelSuggestions.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Channel suggestions (above the compose chrome).
if (channelSuggestions.isNotEmpty)
_ChannelSuggestions(
suggestions: channelSuggestions,
onSelect: insertChannel,
),
// Mention suggestions (above the compose chrome).
if (suggestions.isNotEmpty)
_MentionSuggestions(
@@ -306,12 +326,12 @@ class ComposeBar extends HookConsumerWidget {
Container(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: suggestions.isEmpty
borderRadius: !hasSuggestions
? const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
)
: BorderRadius.zero,
boxShadow: suggestions.isEmpty
boxShadow: !hasSuggestions
? [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
@@ -437,6 +457,7 @@ class ComposeBar extends HookConsumerWidget {
icon: LucideIcons.atSign,
onTap: triggerMention,
),
_ComposeAction(icon: LucideIcons.hash, onTap: triggerChannel),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
active: showFormatting.value,
@@ -464,6 +485,84 @@ class ComposeBar extends HookConsumerWidget {
const _typingThrottleMs = 3000;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
@@ -612,6 +711,84 @@ class _MentionSuggestions extends StatelessWidget {
}
}
// ---------------------------------------------------------------------------
// Channel suggestions
// ---------------------------------------------------------------------------
@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
final q = query.toLowerCase();
return channels
.where((c) => c.channelType != 'dm')
.where((c) {
if (q.isEmpty) return true;
return c.name.toLowerCase().contains(q);
})
.take(8)
.toList();
}
class _ChannelSuggestions extends StatelessWidget {
final List<Channel> suggestions;
final void Function(Channel) onSelect;
const _ChannelSuggestions({
required this.suggestions,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 240),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final channel = suggestions[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(
channel.isForum ? LucideIcons.messageSquare : LucideIcons.hash,
size: 18,
color: context.colors.onSurfaceVariant,
),
title: Text(
'#${channel.name}',
style: context.textTheme.bodyMedium,
),
trailing: Text(
channel.channelType,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
onTap: () => onSelect(channel),
);
},
),
);
}
}
// ---------------------------------------------------------------------------
// Formatting toolbar
// ---------------------------------------------------------------------------
@@ -903,7 +903,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.byIcon(LucideIcons.hash), findsOneWidget);
// The hash icon appears in the app bar and in the compose bar toolbar.
expect(find.byIcon(LucideIcons.hash), findsAtLeastNWidgets(1));
});
testWidgets('shows lock icon for private channel', (tester) async {
@@ -10,6 +10,7 @@ import 'package:http/testing.dart' as http_testing;
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:sprout_mobile/features/channels/channel.dart';
import 'package:sprout_mobile/features/channels/channel_management_provider.dart';
import 'package:sprout_mobile/features/channels/compose_bar.dart';
import 'package:sprout_mobile/shared/relay/relay.dart';
@@ -478,4 +479,216 @@ void main() {
}
});
});
// ---------------------------------------------------------------------------
// Unit tests for extracted helpers
// ---------------------------------------------------------------------------
group('findTrigger', () {
test('finds @ at start of text', () {
expect(findTrigger('@alice', 6, '@', stopAtSpace: false), 0);
});
test('finds @ after a space', () {
expect(findTrigger('hello @bob', 10, '@', stopAtSpace: false), 6);
});
test('finds @ after a newline', () {
expect(findTrigger('line1\n@bob', 10, '@', stopAtSpace: false), 6);
});
test('returns null when @ is mid-word (no word boundary)', () {
expect(findTrigger('foo@bar', 7, '@', stopAtSpace: false), isNull);
});
test('@ with stopAtSpace:false walks through spaces', () {
// "@Alice Smith" — cursor at end, should find @ at index 0.
expect(findTrigger('@Alice Smith', 12, '@', stopAtSpace: false), 0);
});
test('@ with stopAtSpace:false walks through spaces after prefix', () {
expect(findTrigger('hey @Alice Smith', 16, '@', stopAtSpace: false), 4);
});
test('finds # at start of text', () {
expect(findTrigger('#general', 8, '#'), 0);
});
test('finds # after a space', () {
expect(findTrigger('hello #general', 14, '#'), 6);
});
test('# with stopAtSpace:true stops at space', () {
// "hello #chan name" with cursor at end — space stops the walk before #.
expect(findTrigger('hello #chan name', 15, '#'), isNull);
});
test('# stops at newline', () {
expect(findTrigger('line1\n#foo', 10, '#'), 6);
});
test('returns null when # is mid-word', () {
expect(findTrigger('foo#bar', 7, '#'), isNull);
});
test('returns null when cursor is 0', () {
expect(findTrigger('@hello', 0, '@'), isNull);
});
test('returns null on empty text', () {
expect(findTrigger('', 0, '@'), isNull);
});
test('finds trigger right at cursor boundary', () {
// cursor=1, text="#", should find # at index 0.
expect(findTrigger('#', 1, '#'), 0);
});
});
group('filterChannels', () {
final channels = [
_makeChannel(name: 'general', channelType: 'stream'),
_makeChannel(name: 'random', channelType: 'stream'),
_makeChannel(name: 'announcements', channelType: 'forum'),
_makeChannel(name: 'design-team', channelType: 'stream'),
_makeChannel(name: 'dm-alice-bob', channelType: 'dm'),
];
test('returns empty list when query is null', () {
expect(filterChannels(channels, null), isEmpty);
});
test('returns all non-DM channels when query is empty string', () {
final result = filterChannels(channels, '');
expect(result.length, 4);
expect(result.every((c) => c.channelType != 'dm'), isTrue);
});
test('filters by substring match', () {
final result = filterChannels(channels, 'gen');
expect(result.length, 1);
expect(result.first.name, 'general');
});
test('is case-insensitive', () {
final result = filterChannels(channels, 'RANDOM');
expect(result.length, 1);
expect(result.first.name, 'random');
});
test('excludes DM channels', () {
final result = filterChannels(channels, 'dm');
expect(result, isEmpty);
});
test('matches partial channel names', () {
final result = filterChannels(channels, 'design');
expect(result.length, 1);
expect(result.first.name, 'design-team');
});
test('limits to 8 results', () {
final manyChannels = List.generate(
15,
(i) => _makeChannel(name: 'channel-$i', channelType: 'stream'),
);
final result = filterChannels(manyChannels, '');
expect(result.length, 8);
});
});
group('spliceAndMoveCursor', () {
test('replaces text range and moves cursor', () {
// Simulates "@ali|" with cursor right after the query (no trailing space
// in the query portion). The replacement includes a trailing space, so
// the original space before "world" is preserved as-is.
final controller = TextEditingController(text: 'hello @ali world');
controller.selection = const TextSelection.collapsed(offset: 10);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 6,
replacement: '@Alice ',
);
// [start=6, cursor=10) → "hello " + "@Alice " + " world"
expect(controller.text, 'hello @Alice world');
expect(controller.selection.baseOffset, 13); // after "@Alice "
});
test('replaces #channel query with channel name', () {
final controller = TextEditingController(text: 'see #gen for details');
controller.selection = const TextSelection.collapsed(offset: 8);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 4,
replacement: '#general ',
);
expect(controller.text, 'see #general for details');
expect(controller.selection.baseOffset, 13); // after "#general "
});
test('handles replacement at start of text', () {
final controller = TextEditingController(text: '@bo rest');
controller.selection = const TextSelection.collapsed(offset: 3);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 0,
replacement: '@Bob ',
);
expect(controller.text, '@Bob rest');
expect(controller.selection.baseOffset, 5);
});
test('handles replacement at end of text', () {
final controller = TextEditingController(text: 'hello #gen');
controller.selection = const TextSelection.collapsed(offset: 10);
spliceAndMoveCursor(
controller,
FocusNode(),
start: 6,
replacement: '#general ',
);
expect(controller.text, 'hello #general ');
expect(controller.selection.baseOffset, 15);
});
test('clamps start to text bounds', () {
final controller = TextEditingController(text: 'hi');
controller.selection = const TextSelection.collapsed(offset: 2);
// start beyond text length should be clamped
spliceAndMoveCursor(
controller,
FocusNode(),
start: 0,
replacement: '@Name ',
);
expect(controller.text, '@Name ');
expect(controller.selection.baseOffset, 6);
});
});
}
Channel _makeChannel({required String name, required String channelType}) {
return Channel(
id: 'id-$name',
name: name,
channelType: channelType,
visibility: 'open',
description: '',
createdBy: 'pubkey123',
createdAt: DateTime(2024),
memberCount: 5,
);
}