mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add #channel autocomplete to compose bar (#411)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user