From c9bb453577b10c899748f1e169caee10257fb41e Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 10:43:04 -0700 Subject: [PATCH 1/6] feat(mobile): full-screen search route with messages, channels, people MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old _BrowseChannelsSheet bottom sheet with a unified full-screen search experience. Search is accessible from both the bottom nav (new Search tab) and the channels page search bar. - Messages: global full-text search via /api/search with debounce - Channels: client-side filtering of joined + discoverable channels - People: user search via /api/users/search, tap to open DM - Filter chips: All / Messages / Channels / People - Deep-linking: forum posts (kind 45001) → ForumThreadPage, stream messages → ChannelDetailPage - Progressive rendering: 3 parallel lookups update state independently - Removed dead code: browseChannels(), _BrowseChannelsSheet, _BrowseTile, _MiniHeader (~220 lines) Co-Authored-By: Claude Opus 4.6 --- .../lib/features/channels/channels_page.dart | 243 +--------- .../features/channels/date_formatters.dart | 23 + mobile/lib/features/home/home_page.dart | 12 +- mobile/lib/features/search/search_page.dart | 435 ++++++++++++++++++ .../lib/features/search/search_provider.dart | 188 ++++++++ .../features/channels/channels_page_test.dart | 7 +- 6 files changed, 662 insertions(+), 246 deletions(-) create mode 100644 mobile/lib/features/search/search_page.dart create mode 100644 mobile/lib/features/search/search_provider.dart diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9525ab8ad7..f715c24e73 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -21,7 +21,9 @@ import 'channels_provider.dart'; enum _QuickAction { createChannel, createForum, newDm } class ChannelsPage extends HookConsumerWidget { - const ChannelsPage({super.key}); + final VoidCallback? onSearchTap; + + const ChannelsPage({super.key, this.onSearchTap}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -49,23 +51,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, @@ -114,7 +99,7 @@ class ChannelsPage extends HookConsumerWidget { children: [ Expanded( child: GestureDetector( - onTap: channelsAsync.hasValue ? browseChannels : null, + onTap: onSearchTap, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), @@ -730,203 +715,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 +916,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/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 987d090aae..372c6ff365 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,11 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - const pages = [ChannelsPage(), ActivityPage()]; + final pages = [ + ChannelsPage(onSearchTap: () => tabIndex.value = 1), + const SearchPage(), + const ActivityPage(), + ]; return Scaffold( body: IndexedStack(index: tabIndex.value, children: pages), @@ -26,6 +31,11 @@ class HomePage extends HookConsumerWidget { selectedIcon: Icon(LucideIcons.house), label: 'Home', ), + NavigationDestination( + icon: Icon(LucideIcons.search), + selectedIcon: Icon(LucideIcons.search), + label: 'Search', + ), NavigationDestination( icon: Icon(LucideIcons.bell), selectedIcon: Icon(LucideIcons.bell), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart new file mode 100644 index 0000000000..75da9ca7ce --- /dev/null +++ b/mobile/lib/features/search/search_page.dart @@ -0,0 +1,435 @@ +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: 0, + title: 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: 20), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + 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.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Row( + children: [ + for (final filter in _SearchFilter.values) ...[ + if (filter != _SearchFilter.values.first) + const SizedBox(width: Grid.xxs), + FilterChip( + label: Text(filter.label), + selected: active == filter, + onSelected: (_) => onChanged(filter), + ), + ], + ], + ), + ); + } +} + +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..f938c0f0a9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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 { From 411319c4e64fb943f5e76aa65be5f668d2d65a50 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 10:52:33 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(mobile):=20search=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20remove=20redundant=20pill,=20compact=20filter=20chi?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove search pill from channels_page app bar (dedicated Search tab handles this now), replace with "Sprout" title + profile avatar - Remove onSearchTap callback from ChannelsPage (no longer needed) - Restyle filter chips: compact custom containers instead of bulky Material FilterChip with oversized checkmarks - Style search TextField with pill container matching the app's visual language (rounded, surfaceContainerHighest background) Co-Authored-By: Claude Opus 4.6 --- .../lib/features/channels/channels_page.dart | 56 ++++------------ mobile/lib/features/home/home_page.dart | 6 +- mobile/lib/features/search/search_page.dart | 66 ++++++++++++++----- 3 files changed, 65 insertions(+), 63 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index f715c24e73..30b2cb6023 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -21,9 +21,7 @@ import 'channels_provider.dart'; enum _QuickAction { createChannel, createForum, newDm } class ChannelsPage extends HookConsumerWidget { - final VoidCallback? onSearchTap; - - const ChannelsPage({super.key, this.onSearchTap}); + const ChannelsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -95,46 +93,20 @@ class ChannelsPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( titleSpacing: Grid.xs, - title: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: onSearchTap, - 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( + '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, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 372c6ff365..822a7aeba9 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -14,11 +14,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - final pages = [ - ChannelsPage(onSearchTap: () => tabIndex.value = 1), - const SearchPage(), - const ActivityPage(), - ]; + const pages = [ChannelsPage(), SearchPage(), ActivityPage()]; return Scaffold( body: IndexedStack(index: tabIndex.value, children: pages), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 75da9ca7ce..3bd34ebd3f 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -37,20 +37,32 @@ class SearchPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( titleSpacing: 0, - title: TextField( - controller: textController, - decoration: InputDecoration( - hintText: 'Search messages, channels, people\u2026', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + 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), ), - prefixIcon: const Icon(LucideIcons.search, size: 20), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, + style: context.textTheme.bodyMedium, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), ), - style: context.textTheme.bodyMedium, - onChanged: (value) => ref.read(searchProvider.notifier).search(value), ), actions: [ if (hasText) @@ -101,10 +113,32 @@ class _FilterChips extends StatelessWidget { for (final filter in _SearchFilter.values) ...[ if (filter != _SearchFilter.values.first) const SizedBox(width: Grid.xxs), - FilterChip( - label: Text(filter.label), - selected: active == filter, - onSelected: (_) => onChanged(filter), + 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, + ), + ), + ), ), ], ], From 1cf5e1a8c9e9a6bbafb3c4db79ba01c91e51fcd1 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 10:54:41 -0700 Subject: [PATCH 3/6] fix(mobile): search bar padding, filter chip spacing, sprout emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add horizontal padding (Grid.xs) to search bar in AppBar - Even out filter chip row padding (symmetric xxs top/bottom) - Add 🌱 emoji to "Sprout" title on Home tab Co-Authored-By: Claude Opus 4.6 --- mobile/lib/features/channels/channels_page.dart | 2 +- mobile/lib/features/search/search_page.dart | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 30b2cb6023..94396fae7b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -94,7 +94,7 @@ class ChannelsPage extends HookConsumerWidget { appBar: AppBar( titleSpacing: Grid.xs, title: Text( - 'Sprout', + '\u{1F331} Sprout', style: context.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 3bd34ebd3f..856bfbd6a8 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -36,7 +36,7 @@ class SearchPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - titleSpacing: 0, + titleSpacing: Grid.xs, title: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: Grid.half), @@ -104,10 +104,7 @@ class _FilterChips extends StatelessWidget { Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, - ), + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xxs, Grid.xs, Grid.xxs), child: Row( children: [ for (final filter in _SearchFilter.values) ...[ From c0df81d733c149b07470660b40a8047be9bf06b2 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 11:01:09 -0700 Subject: [PATCH 4/6] fix(mobile): reorder tabs to Home/Activity/Search, fix chip padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab order: Home → Activity → Search (was Home → Search → Activity) - Reduce top padding on filter chip row to visually balance the gap between search bar and chip row vs chip row and results Co-Authored-By: Claude Opus 4.6 --- mobile/lib/features/home/home_page.dart | 12 ++++++------ mobile/lib/features/search/search_page.dart | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 822a7aeba9..c235f38c7c 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -14,7 +14,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - const pages = [ChannelsPage(), SearchPage(), ActivityPage()]; + const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; return Scaffold( body: IndexedStack(index: tabIndex.value, children: pages), @@ -27,16 +27,16 @@ class HomePage extends HookConsumerWidget { selectedIcon: Icon(LucideIcons.house), label: 'Home', ), - NavigationDestination( - icon: Icon(LucideIcons.search), - selectedIcon: Icon(LucideIcons.search), - label: 'Search', - ), NavigationDestination( icon: Icon(LucideIcons.bell), 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 index 856bfbd6a8..7fdfcdcc4b 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -104,7 +104,7 @@ class _FilterChips extends StatelessWidget { Widget build(BuildContext context) { return SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xxs, Grid.xs, Grid.xxs), + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.half, Grid.xs, Grid.xxs), child: Row( children: [ for (final filter in _SearchFilter.values) ...[ From 929424319100f7b3a4b7c4e7410ba68600dafced Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 11:11:24 -0700 Subject: [PATCH 5/6] fix(mobile): update channels_page test for renamed title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test was looking for old "Search" pill text, now expects "🌱 Sprout". Co-Authored-By: Claude Opus 4.6 --- mobile/test/features/channels/channels_page_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index f938c0f0a9..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); }); From a0b84a059a27768b3b0e12a4206814a5c07f88a0 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 11:20:39 -0700 Subject: [PATCH 6/6] feat(mobile): auto-linkify bare URLs in chat messages Bare https:// URLs are now converted to tappable markdown links, matching the desktop behavior (remark-gfm autolinks). Existing markdown links, image syntax, and backtick-quoted code are preserved. Co-Authored-By: Claude Opus 4.6 --- .../features/channels/message_content.dart | 26 ++++++++++++++----- .../channels/message_content_test.dart | 6 +++-- 2 files changed, 24 insertions(+), 8 deletions(-) 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/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); }); });