diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9525ab8ad7..94396fae7b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -49,23 +49,6 @@ class ChannelsPage extends HookConsumerWidget { ); } - Future browseChannels() async { - final channels = channelsAsync.asData?.value; - if (channels == null || channels.isEmpty) { - return; - } - - final selected = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => _BrowseChannelsSheet(channels: channels), - ); - if (selected != null && context.mounted) { - await openChannel(selected); - } - } - Future openQuickActions() async { final action = await showModalBottomSheet<_QuickAction>( context: context, @@ -110,46 +93,20 @@ class ChannelsPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( titleSpacing: Grid.xs, - title: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: channelsAsync.hasValue ? browseChannels : null, - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Text( - 'Search', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: Grid.twelve), - ProfileAvatar( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), - ), - ], + title: Text( + '\u{1F331} Sprout', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), + actions: [ + ProfileAvatar( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), + ), + const SizedBox(width: Grid.xs), + ], ), floatingActionButton: FloatingActionButton( onPressed: openQuickActions, @@ -730,203 +687,6 @@ class _CreateChannelSheet extends HookConsumerWidget { } } -class _BrowseChannelsSheet extends HookConsumerWidget { - final List channels; - - const _BrowseChannelsSheet({required this.channels}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final query = useState(''); - final busyChannelId = useState(null); - - final normalizedQuery = query.value.trim().toLowerCase(); - final browsableChannels = channels.where((channel) { - if (channel.isDm) return false; - final visible = channel.isArchived - ? channel.isMember - : channel.visibility == 'open' || channel.isMember; - if (!visible) return false; - if (normalizedQuery.isEmpty) return true; - return channel.name.toLowerCase().contains(normalizedQuery) || - channel.description.toLowerCase().contains(normalizedQuery); - }).toList(); - - final notJoined = browsableChannels - .where((channel) => !channel.isMember) - .toList(); - final joined = browsableChannels - .where((channel) => channel.isMember) - .toList(); - - Future openOrJoin(Channel channel) async { - if (busyChannelId.value != null) return; - - if (channel.isMember) { - Navigator.of(context).pop(channel); - return; - } - - busyChannelId.value = channel.id; - try { - await ref.read(channelActionsProvider).joinChannel(channel.id); - final refreshed = await ref.read(channelsProvider.future); - final joinedChannel = refreshed.firstWhere( - (candidate) => candidate.id == channel.id, - orElse: () => channel, - ); - if (context.mounted) { - Navigator.of(context).pop(joinedChannel); - } - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error.toString()))); - } finally { - busyChannelId.value = null; - } - } - - return DraggableScrollableSheet( - initialChildSize: 0.75, - minChildSize: 0.4, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.xs, - Grid.twelve, - Grid.xs, - Grid.xxs, - ), - child: GestureDetector( - onTap: () {}, - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - autofocus: true, - decoration: InputDecoration( - hintText: 'Search channels, forums…', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - style: context.textTheme.bodyMedium, - onChanged: (value) => query.value = value, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: browsableChannels.isEmpty - ? Center( - child: Text( - 'No matching results', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ) - : ListView( - controller: scrollController, - padding: EdgeInsets.only( - top: Grid.xxs, - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ - if (notJoined.isNotEmpty) ...[ - _MiniHeader( - label: '${notJoined.length} available to join', - ), - for (final channel in notJoined) - _BrowseTile( - channel: channel, - isBusy: busyChannelId.value == channel.id, - onTap: () => openOrJoin(channel), - ), - ], - if (joined.isNotEmpty) ...[ - _MiniHeader(label: '${joined.length} joined'), - for (final channel in joined) - _BrowseTile( - channel: channel, - isBusy: false, - onTap: () => openOrJoin(channel), - ), - ], - ], - ), - ), - ], - ), - ); - } -} - -class _BrowseTile extends StatelessWidget { - final Channel channel; - final bool isBusy; - final VoidCallback onTap; - - const _BrowseTile({ - required this.channel, - required this.isBusy, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - channel.isForum ? LucideIcons.messageSquareText : LucideIcons.hash, - ), - title: Text(channel.name), - subtitle: Text( - channel.description.isEmpty ? 'No description' : channel.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: FilledButton.tonal( - onPressed: isBusy ? null : onTap, - child: Text( - isBusy - ? 'Joining…' - : channel.isMember - ? 'Open' - : 'Join', - ), - ), - onTap: isBusy ? null : onTap, - ); - } -} - class _NewDirectMessageSheet extends HookConsumerWidget { final String? currentPubkey; @@ -1128,29 +888,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget { } } -class _MiniHeader extends StatelessWidget { - final String label; - - const _MiniHeader({required this.label}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.half, - ), - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - class _EphemeralBadge extends StatelessWidget { final Channel channel; diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index c268144854..ee7d2f69e0 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -41,3 +41,26 @@ bool isSameDay(int a, int b) { ).toLocal(); return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day; } + +/// Returns a compact relative time string like "just now", "5m ago", "3h ago", +/// "2d ago", or a short date for older timestamps. +String relativeTime(int unixSeconds) { + final now = DateTime.now(); + final time = DateTime.fromMillisecondsSinceEpoch( + unixSeconds * 1000, + isUtc: true, + ).toLocal(); + final diff = now.difference(time); + + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${time.month}/${time.day}/${time.year}'; +} + +/// Truncates a hex pubkey to the first 8 characters with an ellipsis. +String shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; + return pubkey; +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index d43efb78bd..9801dd114e 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -51,7 +51,7 @@ class MessageContent extends StatelessWidget { context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final imetaByUrl = parseImetaTags(tags); - // Convert angle-bracket autolinks to standard markdown links, + // Convert autolinks and bare URLs to standard markdown links, // but skip content inside backticks (inline code / fenced blocks). final buffer = StringBuffer(); final parts = content.split('`'); @@ -60,12 +60,26 @@ class MessageContent extends StatelessWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - buffer.write( - parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), - (m) => '[${m[1]}](${m[1]})', - ), + // 1. Angle-bracket autolinks: + var segment = parts[i].replaceAllMapped( + RegExp(r'<(https?://[^>]+)>'), + (m) => '[${m[1]}](${m[1]})', + ); + // 2. Bare URLs not already inside markdown link/image syntax. + // Negative lookbehind avoids matching URLs preceded by ]( or = + // which are already part of markdown links or imeta tags. + segment = segment.replaceAllMapped( + RegExp(r'(?\]]+'), + (m) { + final url = m[0]!; + // Skip if this URL is already a markdown link label that equals + // the URL (produced by step 1 or authored as [url](url)). + final start = m.start; + if (start >= 1 && segment[start - 1] == '[') return url; + return '[$url]($url)'; + }, ); + buffer.write(segment); } } final processed = buffer.toString(); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 987d090aae..c235f38c7c 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; +import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { const HomePage({super.key}); @@ -13,7 +14,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - const pages = [ChannelsPage(), ActivityPage()]; + const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; return Scaffold( body: IndexedStack(index: tabIndex.value, children: pages), @@ -31,6 +32,11 @@ class HomePage extends HookConsumerWidget { selectedIcon: Icon(LucideIcons.bell), label: 'Activity', ), + NavigationDestination( + icon: Icon(LucideIcons.search), + selectedIcon: Icon(LucideIcons.search), + label: 'Search', + ), ], ), ); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart new file mode 100644 index 0000000000..7fdfcdcc4b --- /dev/null +++ b/mobile/lib/features/search/search_page.dart @@ -0,0 +1,466 @@ +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 '../../shared/theme/theme.dart'; +import '../channels/channel.dart'; +import '../channels/channel_detail_page.dart'; +import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; +import '../channels/date_formatters.dart'; +import '../forum/forum_thread_page.dart'; +import '../profile/profile_provider.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'search_provider.dart'; + +enum _SearchFilter { all, messages, channels, people } + +class SearchPage extends HookConsumerWidget { + const SearchPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchState = ref.watch(searchProvider); + final currentPubkey = ref + .watch(profileProvider) + .whenData((value) => value?.pubkey) + .value; + final activeFilter = useState(_SearchFilter.all); + final textController = useTextEditingController(); + final hasText = useListenableSelector( + textController, + () => textController.text.isNotEmpty, + ); + + return Scaffold( + appBar: AppBar( + titleSpacing: Grid.xs, + title: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: Grid.half), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + ), + child: TextField( + controller: textController, + decoration: InputDecoration( + hintText: 'Search messages, channels, people\u2026', + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + prefixIcon: const Icon(LucideIcons.search, size: 16), + prefixIconConstraints: const BoxConstraints(minWidth: 32), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), + ), + style: context.textTheme.bodyMedium, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + ), + ), + actions: [ + if (hasText) + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () { + textController.clear(); + ref.read(searchProvider.notifier).clear(); + }, + ), + ], + ), + body: Column( + children: [ + _FilterChips( + active: activeFilter.value, + onChanged: (f) => activeFilter.value = f, + ), + Expanded( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + ), + ), + ], + ), + ); + } +} + +class _FilterChips extends StatelessWidget { + final _SearchFilter active; + final ValueChanged<_SearchFilter> onChanged; + + const _FilterChips({required this.active, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.half, Grid.xs, Grid.xxs), + child: Row( + children: [ + for (final filter in _SearchFilter.values) ...[ + if (filter != _SearchFilter.values.first) + const SizedBox(width: Grid.xxs), + GestureDetector( + onTap: () => onChanged(filter), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.half + 2, + ), + decoration: BoxDecoration( + color: active == filter + ? context.colors.primary + : context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: active == filter + ? null + : Border.all(color: context.colors.outlineVariant), + ), + child: Text( + filter.label, + style: context.textTheme.labelMedium?.copyWith( + color: active == filter + ? context.colors.onPrimary + : context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _SearchBody extends ConsumerWidget { + final SearchState state; + final _SearchFilter filter; + final String? currentPubkey; + + const _SearchBody({ + required this.state, + required this.filter, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (state.query.isEmpty) { + return Center( + child: Text( + 'Search messages, channels, and people', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + } + + final showChannels = + filter == _SearchFilter.all || filter == _SearchFilter.channels; + final showPeople = + filter == _SearchFilter.all || filter == _SearchFilter.people; + final showMessages = + filter == _SearchFilter.all || filter == _SearchFilter.messages; + + final hasAnyResults = + state.channelResults.isNotEmpty || + state.userResults.isNotEmpty || + state.messageResults.isNotEmpty; + + if (!state.isLoading && !hasAnyResults) { + return Center( + child: Text( + "No results for '${state.query}'", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.only(bottom: Grid.xl), + children: [ + if (showChannels && state.channelResults.isNotEmpty) + _ChannelsSection(channels: state.channelResults), + if (showPeople && state.userResults.isNotEmpty) + _PeopleSection(users: state.userResults), + if (showMessages && state.messageResults.isNotEmpty) + _MessagesSection( + hits: state.messageResults, + currentPubkey: currentPubkey, + ), + if (state.isLoading) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: CircularProgressIndicator()), + ), + ], + ); + } +} + +class _ChannelsSection extends StatelessWidget { + final List channels; + + const _ChannelsSection({required this.channels}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'Channels'), + for (final channel in channels) + ListTile( + leading: Icon(channelIcon(channel), size: 20), + title: Text(channel.name), + subtitle: Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + trailing: !channel.isMember && !channel.isDm + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + 2, + vertical: 3, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'Open', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ), + ), + ], + ); + } +} + +class _PeopleSection extends ConsumerWidget { + final List users; + + const _PeopleSection({required this.users}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'People'), + for (final user in users) + ListTile( + leading: CircleAvatar( + backgroundImage: user.avatarUrl != null + ? NetworkImage(user.avatarUrl!) + : null, + child: user.avatarUrl == null + ? Text(user.label.substring(0, 1).toUpperCase()) + : null, + ), + title: Text(user.label), + subtitle: Text( + user.secondaryLabel, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + onTap: () async { + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [user.pubkey]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ), + ], + ); + } +} + +class _MessagesSection extends ConsumerWidget { + final List hits; + final String? currentPubkey; + + const _MessagesSection({required this.hits, required this.currentPubkey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profiles = ref.watch(userCacheProvider); + final channels = ref.watch(channelsProvider).value ?? []; + + // Preload author profiles. + final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList(); + ref.read(userCacheProvider.notifier).preload(pubkeys); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'Messages'), + for (final hit in hits) + _MessageTile( + hit: hit, + authorProfile: profiles[hit.pubkey.toLowerCase()], + channel: channels.where((c) => c.id == hit.channelId).firstOrNull, + currentPubkey: currentPubkey, + ), + ], + ); + } +} + +class _MessageTile extends StatelessWidget { + final SearchHit hit; + final UserProfile? authorProfile; + final Channel? channel; + final String? currentPubkey; + + const _MessageTile({ + required this.hit, + required this.authorProfile, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context) { + final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); + final timeAgo = relativeTime(hit.createdAt); + + return ListTile( + title: Row( + children: [ + Expanded( + child: Text( + authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (hit.channelName != null) ...[ + const SizedBox(width: Grid.half), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + hit.channelName!, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2), + Text(hit.content, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text( + timeAgo, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + onTap: () => _navigateToHit(context, hit, channel), + ); + } + + void _navigateToHit(BuildContext context, SearchHit hit, Channel? channel) { + if (channel == null) return; + + if (hit.kind == 45001) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ForumThreadPage( + channelId: channel.id, + postEventId: hit.eventId, + currentPubkey: currentPubkey, + isMember: channel.isMember, + isArchived: channel.isArchived, + ), + ), + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + } + } +} + +class _SectionLabel extends StatelessWidget { + final String label; + + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xs, Grid.xs, Grid.half), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} + +extension on _SearchFilter { + String get label => switch (this) { + _SearchFilter.all => 'All', + _SearchFilter.messages => 'Messages', + _SearchFilter.channels => 'Channels', + _SearchFilter.people => 'People', + }; +} diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart new file mode 100644 index 0000000000..07a41cf487 --- /dev/null +++ b/mobile/lib/features/search/search_provider.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import '../channels/channel.dart'; +import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; + +@immutable +class SearchHit { + final String eventId; + final String content; + final int kind; + final String pubkey; + final String? channelId; + final String? channelName; + final int createdAt; + final double score; + + const SearchHit({ + required this.eventId, + required this.content, + required this.kind, + required this.pubkey, + this.channelId, + this.channelName, + required this.createdAt, + required this.score, + }); + + factory SearchHit.fromJson(Map json) => SearchHit( + eventId: json['event_id'] as String, + content: json['content'] as String? ?? '', + kind: json['kind'] as int? ?? 9, + pubkey: json['pubkey'] as String, + channelId: json['channel_id'] as String?, + channelName: json['channel_name'] as String?, + createdAt: json['created_at'] as int? ?? 0, + score: (json['score'] as num?)?.toDouble() ?? 0.0, + ); +} + +@immutable +class SearchState { + final String query; + final List messageResults; + final List userResults; + final List channelResults; + final bool isLoading; + final String? error; + + const SearchState({ + this.query = '', + this.messageResults = const [], + this.userResults = const [], + this.channelResults = const [], + this.isLoading = false, + this.error, + }); + + const SearchState.initial() + : query = '', + messageResults = const [], + userResults = const [], + channelResults = const [], + isLoading = false, + error = null; + + SearchState copyWith({ + String? query, + List? messageResults, + List? userResults, + List? channelResults, + bool? isLoading, + String? error, + }) => SearchState( + query: query ?? this.query, + messageResults: messageResults ?? this.messageResults, + userResults: userResults ?? this.userResults, + channelResults: channelResults ?? this.channelResults, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); +} + +class SearchNotifier extends Notifier { + Timer? _debounce; + + @override + SearchState build() { + ref.onDispose(() { + _debounce?.cancel(); + }); + return const SearchState.initial(); + } + + void search(String query) { + _debounce?.cancel(); + + final trimmed = query.trim(); + if (trimmed.isEmpty) { + state = const SearchState.initial(); + return; + } + + state = SearchState(query: trimmed, isLoading: true); + + _debounce = Timer(const Duration(milliseconds: 300), () { + _executeSearch(trimmed); + }); + } + + Future _executeSearch(String query) async { + // If the query changed while debouncing, bail out. + if (state.query != query) return; + + // Fire all three lookups in parallel. + _searchMessages(query); + _searchUsers(query); + _searchChannels(query); + } + + Future _searchMessages(String query) async { + try { + final client = ref.read(relayClientProvider); + final json = + await client.get( + '/api/search', + queryParams: {'q': query, 'limit': '20'}, + ) + as Map; + + final hits = (json['hits'] as List? ?? []) + .cast>() + .map(SearchHit.fromJson) + .toList(); + + if (state.query != query) return; + state = state.copyWith(messageResults: hits, isLoading: false); + } catch (e) { + if (state.query != query) return; + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future _searchUsers(String query) async { + try { + final users = await ref + .read(channelActionsProvider) + .searchUsers(query, limit: 8); + + if (state.query != query) return; + state = state.copyWith( + userResults: users, + isLoading: state.messageResults.isEmpty && state.channelResults.isEmpty, + ); + } catch (_) { + // User search failure is non-critical — keep existing results. + } + } + + void _searchChannels(String query) { + final channels = ref.read(channelsProvider).value ?? []; + final lowerQuery = query.toLowerCase(); + final matches = channels.where((c) { + if (c.isDm) return false; + return c.name.toLowerCase().contains(lowerQuery) || + c.description.toLowerCase().contains(lowerQuery); + }).toList(); + + if (state.query != query) return; + state = state.copyWith( + channelResults: matches, + isLoading: state.messageResults.isEmpty && state.userResults.isEmpty, + ); + } + + void clear() { + _debounce?.cancel(); + state = const SearchState.initial(); + } +} + +final searchProvider = NotifierProvider( + SearchNotifier.new, +); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 85cb67ffc1..5c1955b2c9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -79,7 +79,7 @@ void main() { expect(find.text('CHANNELS'), findsOneWidget); expect(find.text('FORUMS'), findsOneWidget); expect(find.text('DMS'), findsOneWidget); - expect(find.text('Search'), findsOneWidget); + expect(find.text('\u{1F331} Sprout'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); @@ -122,15 +122,10 @@ void main() { ); await tester.pumpAndSettle(); + // Unjoined and archived channels should not appear in the main list. expect(find.text('general'), findsOneWidget); expect(find.text('open-stream'), findsNothing); expect(find.text('archived-stream'), findsNothing); - - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - - expect(find.text('open-stream'), findsOneWidget); - expect(find.text('archived-stream'), findsOneWidget); }); testWidgets('shows empty state when no channels', (tester) async { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 16e4007c5c..50658d88d9 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -226,8 +226,10 @@ void main() { ), ); - final allText = _allRichText(tester); - expect(allText, contains('https://example.com')); + // The URL text should be rendered and tappable. + expect(find.text('https://example.com'), findsOneWidget); + final urlWidget = tester.widget(find.text('https://example.com')); + expect(urlWidget.style?.decoration, TextDecoration.underline); }); });