diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 94fd17706a..b9db29b113 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -17,6 +17,7 @@ import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channels_provider.dart'; +import 'compose_bar.dart'; import 'message_content.dart'; import 'send_message_provider.dart'; import 'thread_detail_page.dart'; @@ -178,7 +179,17 @@ class ChannelDetailPage extends HookConsumerWidget { if (!resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived) - _ComposeBar(channelId: channel.id) + ComposeBar( + channelId: channel.id, + channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, + onSend: (content, mentionPubkeys) => ref + .read(sendMessageProvider) + .call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + ), + ) else if (!resolvedChannel.isForum && !resolvedChannel.isDm && (!resolvedChannel.isMember || resolvedChannel.isArchived)) @@ -1717,98 +1728,6 @@ class _TypingIndicator extends ConsumerWidget { // --------------------------------------------------------------------------- // Compose bar -// --------------------------------------------------------------------------- - -class _ComposeBar extends HookConsumerWidget { - final String channelId; - - const _ComposeBar({required this.channelId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final isSending = useState(false); - - Future send() async { - final text = controller.text.trim(); - if (text.isEmpty || isSending.value) return; - - isSending.value = true; - try { - await ref - .read(sendMessageProvider) - .call(channelId: channelId, content: text); - if (context.mounted) controller.clear(); - } finally { - if (context.mounted) isSending.value = false; - } - } - - return Container( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: context.colors.outlineVariant)), - color: context.colors.surface, - ), - padding: EdgeInsets.only( - left: Grid.xs, - right: Grid.xxs, - top: Grid.xxs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - textInputAction: TextInputAction.send, - onSubmitted: (_) => send(), - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: 'Message…', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.primary), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, - ), - isDense: true, - ), - ), - ), - const SizedBox(width: Grid.half), - IconButton( - onPressed: isSending.value ? null : send, - icon: isSending.value - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.primary, - ), - ) - : Icon( - LucideIcons.sendHorizontal, - color: context.colors.primary, - ), - ), - ], - ), - ); - } -} - // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index b2665c921a..851673eb8b 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import 'channel_management_provider.dart'; /// Provides the message list for a specific channel. Fetches history on init, /// then subscribes to live events via the websocket session. @@ -65,6 +66,19 @@ class ChannelMessagesNotifier extends Notifier>> { void _handleLiveEvent(NostrEvent event) { state = state.whenData((events) => _mergeEvent(events, event)); + + // When a membership system event arrives, refresh the channel member list + // so the @mention autocomplete picks up new members without a restart. + if (event.kind == EventKind.systemMessage && + _isMembershipEvent(event.content)) { + ref.invalidate(channelMembersProvider(channelId)); + } + } + + static bool _isMembershipEvent(String content) { + return content.contains('member_joined') || + content.contains('member_left') || + content.contains('member_removed'); } /// Merge a new event into the sorted list, deduplicating by ID. diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index cbfb0e82c6..744dc3a1cf 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; @@ -13,6 +14,14 @@ class ChannelsNotifier extends AsyncNotifier> { ref.watch(relayClientProvider); final sessionState = ref.watch(relaySessionProvider); + // Re-fetch when the app returns to foreground so channels created on + // another device while mobile was backgrounded appear immediately. + ref.listen(appLifecycleProvider, (prev, next) { + if (next == AppLifecycleState.resumed) { + refresh(); + } + }); + ref.onDispose(() { _unsubscribe?.call(); _unsubscribe = null; diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart new file mode 100644 index 0000000000..93d715db29 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar.dart @@ -0,0 +1,945 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel_management_provider.dart'; + +/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown +/// formatting toolbar. Used in both channel and thread views — the caller +/// provides an [onSend] callback that handles actual message submission. +class ComposeBar extends HookConsumerWidget { + final String channelId; + final String channelName; + final String? hintText; + final Future Function(String content, List mentionPubkeys) + onSend; + + /// Optional thread IDs for thread-scoped typing indicators. + final String? threadHeadId; + final String? rootId; + + const ComposeBar({ + super.key, + required this.channelId, + this.channelName = '', + this.hintText, + this.threadHeadId, + this.rootId, + required this.onSend, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final focusNode = useFocusNode(); + final isSending = useState(false); + final showFormatting = useState(false); + + final resolvedHint = + hintText ?? + (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + + // Mention state -------------------------------------------------------- + final mentionQuery = useState(null); + final mentionStartIdx = useState(-1); + // Map of displayName → pubkey built as the user selects mentions. + // Used to pass resolved pubkeys directly to onSend, avoiding regex. + final mentionMap = useRef({}); + + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final currentPubkey = ref.watch(currentPubkeyProvider); + final userCache = ref.watch(userCacheProvider); + + // Typing indicator broadcast — throttled to one event per 3 seconds. + final lastTypingSentMs = useRef(0); + + // Detect @mention query and broadcast typing on text / selection change. + useEffect(() { + void listener() { + final text = controller.text; + final sel = controller.selection; + + // Broadcast typing indicator (throttled). + if (text.isNotEmpty) { + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastTypingSentMs.value > _typingThrottleMs) { + lastTypingSentMs.value = now; + _sendTypingIndicator( + ref, + channelId: channelId, + threadHeadId: threadHeadId, + rootId: rootId, + ); + } + } + + if (!sel.isValid || !sel.isCollapsed) { + mentionQuery.value = null; + return; + } + final cursor = sel.baseOffset; + if (cursor < 1) { + mentionQuery.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; + } + } + + if (atPos != null) { + mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase(); + mentionStartIdx.value = atPos; + } else { + mentionQuery.value = null; + } + } + + controller.addListener(listener); + return () => controller.removeListener(listener); + }, [controller]); + + // Filter channel members against the query. + final members = membersAsync.asData?.value ?? []; + final suggestions = _filterMembers( + members, + mentionQuery.value, + currentPubkey, + ); + + // 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, + ); + mentionQuery.value = null; + focusNode.requestFocus(); + } + + // 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(); + } + + // Send the message. + Future send() async { + final text = controller.text.trim(); + if (text.isEmpty || isSending.value) return; + + // Extract pubkeys for mentions present in the final text. + final pubkeys = [ + for (final entry in mentionMap.value.entries) + if (text.contains('@${entry.key}')) entry.value, + ]; + + isSending.value = true; + try { + await onSend(text, pubkeys); + if (context.mounted) { + controller.clear(); + mentionMap.value.clear(); + mentionQuery.value = null; + showFormatting.value = false; + focusNode.requestFocus(); + } + } finally { + if (context.mounted) isSending.value = false; + } + } + + // Insert an emoji at the cursor. + void insertEmoji(String emoji) { + final text = controller.text; + final cursor = controller.selection.isValid + ? controller.selection.baseOffset + : text.length; + final before = text.substring(0, cursor); + final after = text.substring(cursor); + controller.text = '$before$emoji$after'; + controller.selection = TextSelection.collapsed( + offset: cursor + emoji.length, + ); + focusNode.requestFocus(); + } + + // Wrap (or insert) markdown formatting around the current selection. + void applyFormat(String prefix, [String? suffix]) { + suffix ??= prefix; + final text = controller.text; + final sel = controller.selection; + if (!sel.isValid) return; + + if (sel.isCollapsed) { + final offset = sel.baseOffset; + final updated = + '${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: offset + prefix.length, + ); + } else { + final selected = text.substring(sel.start, sel.end); + final updated = + '${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}'; + controller.text = updated; + controller.selection = TextSelection.collapsed( + offset: sel.start + prefix.length + selected.length + suffix.length, + ); + } + focusNode.requestFocus(); + } + + // ----- Widget tree ---------------------------------------------------- + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Mention suggestions (above the compose chrome). + if (suggestions.isNotEmpty) + _MentionSuggestions( + suggestions: suggestions, + userCache: userCache, + currentPubkey: currentPubkey, + onSelect: insertMention, + ), + + // Compose chrome — bottom-sheet style container. + Container( + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: suggestions.isEmpty + ? const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), + ) + : BorderRadius.zero, + boxShadow: suggestions.isEmpty + ? [ + BoxShadow( + color: context.colors.shadow.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ] + : null, + ), + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xs, + top: Grid.xs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.twelve, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Formatting toolbar (toggled via Aa button). + if (showFormatting.value) + _FormattingToolbar(onFormat: applyFormat), + + // Row 1 — text input (full width, grows). + TextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.send, + onSubmitted: (_) => send(), + minLines: 1, + maxLines: 5, + style: context.textTheme.bodyMedium, + decoration: InputDecoration( + hintText: resolvedHint, + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.half, + ), + isDense: true, + ), + ), + + const SizedBox(height: Grid.xxs), + + // Row 2 — action buttons [paperclip, emoji, @, Aa] ... [send]. + Row( + children: [ + _ComposeAction( + icon: LucideIcons.paperclip, + onTap: () {}, // attachment placeholder + ), + _ComposeAction( + icon: LucideIcons.smilePlus, + onTap: () => _showEmojiPicker( + context: context, + onSelect: insertEmoji, + ), + ), + _ComposeAction( + icon: LucideIcons.atSign, + onTap: triggerMention, + ), + _ComposeAction( + icon: LucideIcons.aLargeSmall, + active: showFormatting.value, + onTap: () => showFormatting.value = !showFormatting.value, + ), + const Spacer(), + _SendButton(isSending: isSending.value, onTap: send), + ], + ), + ], + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Typing indicator broadcast +// --------------------------------------------------------------------------- + +const _typingThrottleMs = 3000; + +/// Send a typing indicator over the WebSocket (fire-and-forget). +/// +/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not +/// via HTTP. Ephemeral events like typing indicators are broadcast-only and +/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may +/// silently discard them. +void _sendTypingIndicator( + WidgetRef ref, { + required String channelId, + String? threadHeadId, + String? rootId, +}) { + try { + final config = ref.read(relayConfigProvider); + final nsec = config.nsec; + if (nsec == null || nsec.isEmpty) return; + + final privkeyHex = nostr.Nip19.decodePrivkey(nsec); + if (privkeyHex.isEmpty) return; + + final tags = >[ + ['h', channelId], + if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[ + ['e', rootId, '', 'root'], + ['e', threadHeadId, '', 'reply'], + ] else if (threadHeadId != null) + ['e', threadHeadId, '', 'reply'], + ]; + + final event = nostr.Event.from( + kind: EventKind.typingIndicator, + content: '', + tags: tags, + privkey: privkeyHex, + verify: false, + ); + + // Send directly over WebSocket — fire-and-forget, matching desktop. + final session = ref.read(relaySessionProvider.notifier); + session.sendRaw(['EVENT', event.toJson()]); + } catch (_) { + // Fire-and-forget — typing indicator failure is non-fatal. + } +} + +// --------------------------------------------------------------------------- +// Emoji picker +// --------------------------------------------------------------------------- + +void _showEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + builder: (sheetContext) => _EmojiPickerSheet( + onSelect: (emoji) { + Navigator.of(sheetContext).pop(); + onSelect(emoji); + }, + ), + ); +} + +/// Emoji categories for the picker. System Unicode emoji — no packages needed. +const _emojiCategories = <({String label, IconData icon, List emoji})>[ + ( + label: 'Popular', + icon: LucideIcons.clock, + emoji: [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', + '\u{1F525}', + '\u{2705}', + ], + ), + ( + label: 'Smileys', + icon: LucideIcons.smile, + emoji: [ + '\u{1F600}', + '\u{1F603}', + '\u{1F604}', + '\u{1F601}', + '\u{1F605}', + '\u{1F602}', + '\u{1F923}', + '\u{1F607}', + '\u{1F60A}', + '\u{1F60D}', + '\u{1F618}', + '\u{1F617}', + '\u{1F61A}', + '\u{1F619}', + '\u{1F60B}', + '\u{1F61B}', + '\u{1F61D}', + '\u{1F61C}', + '\u{1F911}', + '\u{1F917}', + '\u{1F914}', + '\u{1F910}', + '\u{1F928}', + '\u{1F610}', + '\u{1F611}', + '\u{1F636}', + '\u{1F60F}', + '\u{1F612}', + '\u{1F644}', + '\u{1F62C}', + '\u{1F925}', + '\u{1F60C}', + '\u{1F614}', + '\u{1F62A}', + '\u{1F924}', + '\u{1F634}', + '\u{1F637}', + '\u{1F912}', + '\u{1F915}', + '\u{1F922}', + '\u{1F92E}', + '\u{1F927}', + '\u{1F975}', + '\u{1F976}', + '\u{1F974}', + '\u{1F635}', + '\u{1F92F}', + '\u{1F920}', + '\u{1F973}', + '\u{1F978}', + ], + ), + ( + label: 'Gestures', + icon: LucideIcons.hand, + emoji: [ + '\u{1F44D}', + '\u{1F44E}', + '\u{1F44A}', + '\u{270A}', + '\u{1F91B}', + '\u{1F91C}', + '\u{1F44F}', + '\u{1F64C}', + '\u{1F450}', + '\u{1F64F}', + '\u{1F91D}', + '\u{270C}\u{FE0F}', + '\u{1F91E}', + '\u{1F91F}', + '\u{1F918}', + '\u{1F448}', + '\u{1F449}', + '\u{1F446}', + '\u{1F447}', + '\u{261D}\u{FE0F}', + '\u{1F4AA}', + '\u{1F44B}', + '\u{1F590}\u{FE0F}', + ], + ), + ( + label: 'Objects', + icon: LucideIcons.lightbulb, + emoji: [ + '\u{2764}\u{FE0F}', + '\u{1F525}', + '\u{2B50}', + '\u{1F31F}', + '\u{1F4A5}', + '\u{1F389}', + '\u{1F38A}', + '\u{1F3C6}', + '\u{1F947}', + '\u{1F4A1}', + '\u{1F4AF}', + '\u{2705}', + '\u{274C}', + '\u{26A0}\u{FE0F}', + '\u{1F6A8}', + '\u{1F4DD}', + '\u{1F4CB}', + '\u{1F4CC}', + '\u{1F517}', + '\u{1F4E3}', + '\u{1F514}', + '\u{1F3B5}', + '\u{1F3B6}', + '\u{1F680}', + ], + ), + ( + label: 'Nature', + icon: LucideIcons.sprout, + emoji: [ + '\u{1F331}', + '\u{1F332}', + '\u{1F333}', + '\u{1F334}', + '\u{1F335}', + '\u{1F33B}', + '\u{1F33A}', + '\u{1F337}', + '\u{1F339}', + '\u{1F340}', + '\u{1F341}', + '\u{1F343}', + '\u{1F31E}', + '\u{1F308}', + '\u{2600}\u{FE0F}', + '\u{1F327}\u{FE0F}', + '\u{26A1}', + '\u{2744}\u{FE0F}', + '\u{1F30A}', + '\u{1F436}', + '\u{1F431}', + '\u{1F98A}', + '\u{1F42C}', + '\u{1F985}', + ], + ), +]; + +class _EmojiPickerSheet extends StatefulWidget { + final void Function(String emoji) onSelect; + + const _EmojiPickerSheet({required this.onSelect}); + + @override + State<_EmojiPickerSheet> createState() => _EmojiPickerSheetState(); +} + +class _EmojiPickerSheetState extends State<_EmojiPickerSheet> { + /// -1 = "All", 0..N = specific category. + int _selectedCategory = -1; + + static final _allEmoji = () { + final seen = {}; + return [ + for (final cat in _emojiCategories) + for (final e in cat.emoji) + if (seen.add(e)) e, + ]; + }(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final emoji = _selectedCategory < 0 + ? _allEmoji + : _emojiCategories[_selectedCategory].emoji; + + return SizedBox( + height: 340, + child: Column( + children: [ + // Category icon bar. + SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: Grid.twelve), + _CategoryIcon( + icon: LucideIcons.layoutGrid, + selected: _selectedCategory < 0, + onTap: () => setState(() => _selectedCategory = -1), + ), + for (var i = 0; i < _emojiCategories.length; i++) + _CategoryIcon( + icon: _emojiCategories[i].icon, + selected: _selectedCategory == i, + onTap: () => setState(() => _selectedCategory = i), + ), + ], + ), + ), + Divider(height: 1, color: colors.outlineVariant), + const SizedBox(height: Grid.xxs), + // Emoji grid. + Expanded( + child: GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + mainAxisSpacing: Grid.half, + crossAxisSpacing: Grid.half, + ), + itemCount: emoji.length, + itemBuilder: (context, index) { + final e = emoji[index]; + return GestureDetector( + onTap: () => widget.onSelect(e), + child: Center( + child: Text(e, style: const TextStyle(fontSize: 28)), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _CategoryIcon extends StatelessWidget { + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _CategoryIcon({ + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SizedBox( + width: 40, + height: 40, + child: IconButton( + onPressed: onTap, + icon: Icon( + icon, + size: 18, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Mention suggestions +// --------------------------------------------------------------------------- + +List _filterMembers( + List members, + String? query, + String? currentPubkey, +) { + if (query == null) return const []; + final q = query.toLowerCase(); + return members + .where( + (m) => + currentPubkey == null || + m.pubkey.toLowerCase() != currentPubkey.toLowerCase(), + ) + .where((m) { + if (q.isEmpty) return true; + final name = (m.displayName ?? '').toLowerCase(); + final firstName = name.split(RegExp(r'\s+')).first; + return name.startsWith(q) || + firstName.startsWith(q) || + name.contains(q); + }) + .take(6) + .toList(); +} + +class _MentionSuggestions extends StatelessWidget { + final List suggestions; + final Map userCache; + final String? currentPubkey; + final void Function(ChannelMember) onSelect; + + const _MentionSuggestions({ + required this.suggestions, + required this.userCache, + required this.currentPubkey, + 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 member = suggestions[index]; + final name = member.labelFor(currentPubkey); + final profile = userCache[member.pubkey.toLowerCase()]; + final avatarUrl = profile?.avatarUrl; + final initial = (member.displayName ?? member.pubkey)[0] + .toUpperCase(); + + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: CircleAvatar( + radius: 14, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null + ? NetworkImage(avatarUrl) + : null, + child: avatarUrl == null + ? Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ), + title: Text(name, style: context.textTheme.bodyMedium), + trailing: member.isBot + ? Icon(LucideIcons.bot, size: 14, color: context.colors.outline) + : null, + onTap: () => onSelect(member), + ); + }, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Formatting toolbar +// --------------------------------------------------------------------------- + +class _FormattingToolbar extends StatelessWidget { + final void Function(String prefix, [String? suffix]) onFormat; + + const _FormattingToolbar({required this.onFormat}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: Grid.half), + child: Row( + children: [ + _FormatButton( + icon: LucideIcons.bold, + tooltip: 'Bold', + onTap: () => onFormat('**'), + ), + _FormatButton( + icon: LucideIcons.italic, + tooltip: 'Italic', + onTap: () => onFormat('_'), + ), + _FormatButton( + icon: LucideIcons.strikethrough, + tooltip: 'Strikethrough', + onTap: () => onFormat('~~'), + ), + _FormatButton( + icon: LucideIcons.code, + tooltip: 'Code', + onTap: () => onFormat('`'), + ), + ], + ), + ); + } +} + +class _FormatButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const _FormatButton({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.sm), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: Icon(icon, size: 18, color: context.colors.onSurfaceVariant), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Compose action buttons +// --------------------------------------------------------------------------- + +class _ComposeAction extends StatelessWidget { + final IconData icon; + final bool active; + final VoidCallback onTap; + + const _ComposeAction({ + required this.icon, + this.active = false, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + onPressed: onTap, + icon: Icon( + icon, + size: 20, + color: active + ? context.colors.primary + : context.colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + } +} + +class _SendButton extends StatelessWidget { + final bool isSending; + final VoidCallback onTap; + + const _SendButton({required this.isSending, required this.onTap}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + onPressed: isSending ? null : onTap, + style: IconButton.styleFrom( + backgroundColor: context.colors.primary, + disabledBackgroundColor: context.colors.primary.withValues( + alpha: 0.5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + ), + ), + padding: EdgeInsets.zero, + icon: isSending + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.onPrimary, + ), + ) + : Icon( + LucideIcons.sendHorizontal, + size: 18, + color: context.colors.onPrimary, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 68ad963974..0d2c3b0d9f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -11,6 +10,7 @@ import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channels_provider.dart'; +import 'compose_bar.dart'; import 'message_content.dart'; import 'send_message_provider.dart'; import 'timeline_message.dart'; @@ -178,10 +178,20 @@ class ThreadDetailPage extends HookConsumerWidget { if (threadTyping.isNotEmpty) _ThreadTypingIndicator(entries: threadTyping), if (isMember && !isArchived) - _ThreadComposeBar( + ComposeBar( channelId: channelId, + hintText: 'Reply in thread\u2026', threadHeadId: threadHead.id, rootId: effectiveRootId, + onSend: (content, mentionPubkeys) => ref + .read(sendMessageProvider) + .call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + ), ), ], ), @@ -482,107 +492,6 @@ class _ThreadMessage extends ConsumerWidget { // Thread compose bar // --------------------------------------------------------------------------- -class _ThreadComposeBar extends HookConsumerWidget { - final String channelId; - final String threadHeadId; - final String? rootId; - - const _ThreadComposeBar({ - required this.channelId, - required this.threadHeadId, - required this.rootId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final isSending = useState(false); - - Future send() async { - final text = controller.text.trim(); - if (text.isEmpty || isSending.value) return; - - isSending.value = true; - try { - await ref - .read(sendMessageProvider) - .call( - channelId: channelId, - content: text, - parentEventId: threadHeadId, - rootEventId: rootId ?? threadHeadId, - ); - if (context.mounted) controller.clear(); - } finally { - if (context.mounted) isSending.value = false; - } - } - - return Container( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: context.colors.outlineVariant)), - color: context.colors.surface, - ), - padding: EdgeInsets.only( - left: Grid.xs, - right: Grid.xxs, - top: Grid.xxs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - textInputAction: TextInputAction.send, - onSubmitted: (_) => send(), - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: 'Reply...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: context.colors.primary), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, - ), - isDense: true, - ), - ), - ), - const SizedBox(width: Grid.half), - IconButton( - onPressed: isSending.value ? null : send, - icon: isSending.value - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: context.colors.primary, - ), - ) - : Icon( - LucideIcons.sendHorizontal, - color: context.colors.primary, - ), - ), - ], - ), - ); - } -} - // --------------------------------------------------------------------------- // Thread-scoped typing indicator // --------------------------------------------------------------------------- diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 654d5fc970..9dedb808e6 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -193,6 +193,12 @@ class RelaySessionNotifier extends Notifier { return completer.future; } + /// Send a raw message over the WebSocket without waiting for acknowledgement. + /// Used for ephemeral events like typing indicators. + void sendRaw(List payload) { + _socket?.send(payload); + } + /// Force a reconnect (e.g., returning from background). Future reconnect() async { await _socket?.disconnect(); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 62716b3909..02caac3ab3 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -289,7 +289,7 @@ void main() { find.text('Join this channel from Manage to participate.'), findsNothing, ); - expect(find.text('Message…'), findsOneWidget); + expect(find.text('Message #general'), findsOneWidget); }); testWidgets('shows empty state when no messages', (tester) async { @@ -848,7 +848,7 @@ void main() { await tester.pumpWidget(_buildTestable(messages: [])); await tester.pumpAndSettle(); - expect(find.text('Message…'), findsOneWidget); + expect(find.text('Message #general'), findsOneWidget); }); });