mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user