feat(mobile): full-screen search with messages, channels, and people (#402)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-26 11:38:15 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 480d78cb91
commit 3c0295331d
8 changed files with 723 additions and 292 deletions
+13 -276
View File
@@ -49,23 +49,6 @@ class ChannelsPage extends HookConsumerWidget {
);
}
Future<void> browseChannels() async {
final channels = channelsAsync.asData?.value;
if (channels == null || channels.isEmpty) {
return;
}
final selected = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _BrowseChannelsSheet(channels: channels),
);
if (selected != null && context.mounted) {
await openChannel(selected);
}
}
Future<void> 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<void>(builder: (_) => const SettingsPage()),
),
),
],
title: Text(
'\u{1F331} Sprout',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
actions: [
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(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<Channel> channels;
const _BrowseChannelsSheet({required this.channels});
@override
Widget build(BuildContext context, WidgetRef ref) {
final query = useState('');
final busyChannelId = useState<String?>(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<void> 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;
@@ -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;
}
@@ -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: <https://...>
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'(?<![(\]=])https?://[^\s)>\]]+'),
(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();
+7 -1
View File
@@ -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',
),
],
),
);
+466
View File
@@ -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<Channel> 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<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
),
),
],
);
}
}
class _PeopleSection extends ConsumerWidget {
final List<DirectoryUser> 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<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
},
),
],
);
}
}
class _MessagesSection extends ConsumerWidget {
final List<SearchHit> 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<void>(
builder: (_) => ForumThreadPage(
channelId: channel.id,
postEventId: hit.eventId,
currentPubkey: currentPubkey,
isMember: channel.isMember,
isArchived: channel.isArchived,
),
),
);
} else {
Navigator.of(context).push(
MaterialPageRoute<void>(
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',
};
}
@@ -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<String, dynamic> 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<SearchHit> messageResults;
final List<DirectoryUser> userResults;
final List<Channel> 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<SearchHit>? messageResults,
List<DirectoryUser>? userResults,
List<Channel>? 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<SearchState> {
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<void> _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<void> _searchMessages(String query) async {
try {
final client = ref.read(relayClientProvider);
final json =
await client.get(
'/api/search',
queryParams: {'q': query, 'limit': '20'},
)
as Map<String, dynamic>;
final hits = (json['hits'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>()
.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<void> _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, SearchState>(
SearchNotifier.new,
);
@@ -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 {
@@ -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<Text>(find.text('https://example.com'));
expect(urlWidget.style?.decoration, TextDecoration.underline);
});
});