mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
1317 lines
41 KiB
Dart
1317 lines
41 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
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/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../profile/profile_avatar.dart';
|
|
import '../profile/profile_provider.dart';
|
|
import '../settings/settings_page.dart';
|
|
import '../profile/presence_cache_provider.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import 'channel.dart';
|
|
import 'channel_detail_page.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'channels_provider.dart';
|
|
|
|
enum _QuickAction { createChannel, createForum, newDm }
|
|
|
|
class ChannelsPage extends HookConsumerWidget {
|
|
const ChannelsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final channelsAsync = ref.watch(channelsProvider);
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
final currentPubkey = ref
|
|
.watch(profileProvider)
|
|
.whenData((value) => value?.pubkey)
|
|
.value;
|
|
|
|
// Cache the last successfully loaded channels so the UI never flashes
|
|
// back to a loading state when the provider rebuilds.
|
|
final cachedChannels = useRef<List<Channel>?>(null);
|
|
if (channelsAsync.asData?.value case final data?) {
|
|
cachedChannels.value = data;
|
|
}
|
|
final channels = cachedChannels.value;
|
|
|
|
Future<void> openChannel(Channel channel) async {
|
|
if (!context.mounted) return;
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ChannelDetailPage(channel: channel),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
showDragHandle: true,
|
|
builder: (_) => const _QuickActionsSheet(),
|
|
);
|
|
|
|
if (!context.mounted || action == null) {
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case _QuickAction.createChannel:
|
|
case _QuickAction.createForum:
|
|
final created = await showModalBottomSheet<Channel>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (_) => _CreateChannelSheet(
|
|
channelType: action == _QuickAction.createForum
|
|
? 'forum'
|
|
: 'stream',
|
|
),
|
|
);
|
|
if (created != null && context.mounted) {
|
|
await openChannel(created);
|
|
}
|
|
case _QuickAction.newDm:
|
|
final opened = await showModalBottomSheet<Channel>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (_) =>
|
|
_NewDirectMessageSheet(currentPubkey: currentPubkey),
|
|
);
|
|
if (opened != null && context.mounted) {
|
|
await openChannel(opened);
|
|
}
|
|
}
|
|
}
|
|
|
|
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()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: openQuickActions,
|
|
tooltip: 'Create or start conversation',
|
|
shape: const CircleBorder(),
|
|
child: const Icon(LucideIcons.plus),
|
|
),
|
|
body: channels != null
|
|
? Column(
|
|
children: [
|
|
_ConnectionBanner(status: sessionState.status),
|
|
Expanded(
|
|
child: _ChannelsList(
|
|
channels: channels,
|
|
currentPubkey: currentPubkey,
|
|
onSelectChannel: openChannel,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: channelsAsync.hasError
|
|
? _ErrorView(
|
|
error: channelsAsync.error!,
|
|
onRetry: () => ref.read(channelsProvider.notifier).refresh(),
|
|
)
|
|
: const _ConnectionBanner(status: SessionStatus.connecting),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChannelsList extends HookConsumerWidget {
|
|
final List<Channel> channels;
|
|
final String? currentPubkey;
|
|
final Future<void> Function(Channel channel) onSelectChannel;
|
|
|
|
const _ChannelsList({
|
|
required this.channels,
|
|
required this.currentPubkey,
|
|
required this.onSelectChannel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final visibleChannels = channels
|
|
.where((channel) => channel.isMember && !channel.isArchived)
|
|
.toList();
|
|
final streamChannels = visibleChannels
|
|
.where((channel) => channel.isStream)
|
|
.toList();
|
|
final forumChannels = visibleChannels
|
|
.where((channel) => channel.isForum)
|
|
.toList();
|
|
final dmChannels = visibleChannels
|
|
.where((channel) => channel.isDm)
|
|
.toList();
|
|
|
|
final channelsExpanded = useState(true);
|
|
final forumsExpanded = useState(true);
|
|
final dmsExpanded = useState(true);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
|
|
child: ListView(
|
|
padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80),
|
|
children: [
|
|
if (visibleChannels.isEmpty)
|
|
const _EmptyState()
|
|
else ...[
|
|
_ChannelSection(
|
|
title: 'Channels',
|
|
icon: LucideIcons.hash,
|
|
expanded: channelsExpanded.value,
|
|
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
|
|
channels: streamChannels,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: 'No stream channels yet',
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
_ChannelSection(
|
|
title: 'Forums',
|
|
icon: LucideIcons.messageSquareText,
|
|
expanded: forumsExpanded.value,
|
|
onToggle: () => forumsExpanded.value = !forumsExpanded.value,
|
|
channels: forumChannels,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: 'No forums yet',
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
_ChannelSection(
|
|
title: 'DMs',
|
|
icon: LucideIcons.messagesSquare,
|
|
expanded: dmsExpanded.value,
|
|
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
|
|
channels: dmChannels,
|
|
currentPubkey: currentPubkey,
|
|
emptyLabel: 'No direct messages yet',
|
|
onSelectChannel: onSelectChannel,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChannelSection extends StatelessWidget {
|
|
final String title;
|
|
final IconData icon;
|
|
final bool expanded;
|
|
final VoidCallback onToggle;
|
|
final List<Channel> channels;
|
|
final String? currentPubkey;
|
|
final String emptyLabel;
|
|
final Future<void> Function(Channel channel) onSelectChannel;
|
|
|
|
const _ChannelSection({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.expanded,
|
|
required this.onToggle,
|
|
required this.channels,
|
|
required this.currentPubkey,
|
|
required this.emptyLabel,
|
|
required this.onSelectChannel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SectionHeader(
|
|
label: title,
|
|
icon: icon,
|
|
expanded: expanded,
|
|
onToggle: onToggle,
|
|
),
|
|
if (expanded) ...[
|
|
if (channels.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: Grid.xs + Grid.xxs,
|
|
right: Grid.xs,
|
|
top: Grid.half,
|
|
bottom: Grid.half,
|
|
),
|
|
child: Text(
|
|
emptyLabel,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.outline,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
for (final channel in channels)
|
|
_ChannelTile(
|
|
channel: channel,
|
|
currentPubkey: currentPubkey,
|
|
onTap: () => onSelectChannel(channel),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: MediaQuery.sizeOf(context).height * 0.55,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.messagesSquare,
|
|
size: Grid.xl,
|
|
color: context.colors.outline,
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
'No conversations yet',
|
|
style: context.textTheme.bodyLarge?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
final String label;
|
|
final IconData icon;
|
|
final bool expanded;
|
|
final VoidCallback onToggle;
|
|
|
|
const _SectionHeader({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.expanded,
|
|
required this.onToggle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onToggle,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
Grid.xs,
|
|
Grid.twelve,
|
|
Grid.xs,
|
|
Grid.half,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 14, color: context.colors.outline),
|
|
const SizedBox(width: Grid.half),
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.outline,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.8,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Icon(
|
|
expanded ? LucideIcons.chevronDown : LucideIcons.chevronRight,
|
|
size: 14,
|
|
color: context.colors.outline,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChannelTile extends ConsumerWidget {
|
|
final Channel channel;
|
|
final String? currentPubkey;
|
|
final VoidCallback onTap;
|
|
|
|
const _ChannelTile({
|
|
required this.channel,
|
|
required this.currentPubkey,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final hasActivity = channel.lastMessageAt != null;
|
|
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: Grid.xs + Grid.xxs,
|
|
right: Grid.xs,
|
|
top: Grid.xxs + Grid.quarter,
|
|
bottom: Grid.xxs + Grid.quarter,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (channel.isDm)
|
|
_DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
|
else
|
|
Icon(
|
|
channelIcon(channel),
|
|
size: 18,
|
|
color: hasActivity
|
|
? context.colors.onSurface
|
|
: context.colors.outline,
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
channel.displayLabel(currentPubkey: currentPubkey),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: hasActivity
|
|
? context.colors.onSurface
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!channel.isMember && !channel.isDm)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: Grid.xxs),
|
|
child: 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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (channel.isEphemeral) ...[
|
|
const SizedBox(width: Grid.xxs),
|
|
_EphemeralBadge(channel: channel),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DmAvatar extends ConsumerWidget {
|
|
final Channel channel;
|
|
final String? currentPubkey;
|
|
|
|
const _DmAvatar({required this.channel, required this.currentPubkey});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final profiles = ref.watch(userCacheProvider);
|
|
final presenceMap = ref.watch(presenceCacheProvider);
|
|
final normalizedCurrent = currentPubkey?.toLowerCase();
|
|
|
|
// Find the other participant's pubkey.
|
|
String? otherPubkey;
|
|
for (final pk in channel.participantPubkeys) {
|
|
if (pk.toLowerCase() != normalizedCurrent) {
|
|
otherPubkey = pk.toLowerCase();
|
|
break;
|
|
}
|
|
}
|
|
|
|
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
|
|
|
|
// Trigger fetches if not cached yet.
|
|
if (otherPubkey != null) {
|
|
if (profile == null) {
|
|
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
|
|
}
|
|
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
|
|
}
|
|
|
|
final avatarUrl = profile?.avatarUrl;
|
|
final initial =
|
|
profile?.initial ??
|
|
(channel.participants.isNotEmpty
|
|
? channel.participants.first[0].toUpperCase()
|
|
: '?');
|
|
final presence = otherPubkey != null
|
|
? (presenceMap[otherPubkey] ?? 'offline')
|
|
: 'offline';
|
|
|
|
return SizedBox(
|
|
width: 22,
|
|
height: 22,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 10,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
|
child: avatarUrl == null
|
|
? Text(
|
|
initial,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
fontSize: 9,
|
|
color: context.colors.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
Positioned(
|
|
right: -1,
|
|
bottom: -1,
|
|
child: Container(
|
|
width: 9,
|
|
height: 9,
|
|
decoration: BoxDecoration(
|
|
color: _presenceColor(context, presence),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: context.theme.scaffoldBackgroundColor,
|
|
width: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _presenceColor(BuildContext context, String presence) {
|
|
return switch (presence) {
|
|
'online' => context.appColors.success,
|
|
'away' => context.appColors.warning,
|
|
_ => context.colors.outline,
|
|
};
|
|
}
|
|
}
|
|
|
|
class _QuickActionsSheet extends StatelessWidget {
|
|
const _QuickActionsSheet();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(LucideIcons.hash),
|
|
title: const Text('Create channel'),
|
|
subtitle: const Text('Start a new stream channel'),
|
|
onTap: () =>
|
|
Navigator.of(context).pop(_QuickAction.createChannel),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(LucideIcons.messageSquareText),
|
|
title: const Text('Create forum'),
|
|
subtitle: const Text('Set up a threaded discussion space'),
|
|
onTap: () => Navigator.of(context).pop(_QuickAction.createForum),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(LucideIcons.messagesSquare),
|
|
title: const Text('New direct message'),
|
|
subtitle: const Text('Open a DM with one or more people'),
|
|
onTap: () => Navigator.of(context).pop(_QuickAction.newDm),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateChannelSheet extends HookConsumerWidget {
|
|
final String channelType;
|
|
|
|
const _CreateChannelSheet({required this.channelType});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final nameController = useTextEditingController();
|
|
final descriptionController = useTextEditingController();
|
|
final visibility = useState('open');
|
|
final isSubmitting = useState(false);
|
|
final errorMessage = useState<String?>(null);
|
|
|
|
Future<void> submit() async {
|
|
final name = nameController.text.trim();
|
|
if (name.isEmpty || isSubmitting.value) {
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
errorMessage.value = null;
|
|
try {
|
|
final created = await ref
|
|
.read(channelActionsProvider)
|
|
.createChannel(
|
|
name: name,
|
|
channelType: channelType,
|
|
visibility: visibility.value,
|
|
description: descriptionController.text.trim(),
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(created);
|
|
}
|
|
} catch (error) {
|
|
errorMessage.value = error.toString();
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
Grid.xs,
|
|
0,
|
|
Grid.xs,
|
|
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: nameController,
|
|
enabled: !isSubmitting.value,
|
|
decoration: InputDecoration(
|
|
labelText: 'Name',
|
|
hintText: channelType == 'forum'
|
|
? 'design-discussions'
|
|
: 'release-notes',
|
|
),
|
|
textInputAction: TextInputAction.next,
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
TextField(
|
|
controller: descriptionController,
|
|
enabled: !isSubmitting.value,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
hintText: 'What this space is for',
|
|
),
|
|
minLines: 2,
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
SwitchListTile(
|
|
title: const Text('Private'),
|
|
contentPadding: EdgeInsets.zero,
|
|
value: visibility.value == 'private',
|
|
onChanged: isSubmitting.value
|
|
? null
|
|
: (on) => visibility.value = on ? 'private' : 'open',
|
|
),
|
|
if (errorMessage.value case final error?) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
error,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: Grid.xs),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: isSubmitting.value
|
|
? null
|
|
: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
FilledButton(
|
|
onPressed: isSubmitting.value ? null : submit,
|
|
child: Text(isSubmitting.value ? 'Creating…' : 'Create'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: const EdgeInsets.only(top: Grid.xxs),
|
|
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;
|
|
|
|
const _NewDirectMessageSheet({required this.currentPubkey});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final queryController = useTextEditingController();
|
|
final query = useState('');
|
|
final debouncedQuery = useState('');
|
|
final selectedUsers = useState<List<DirectoryUser>>([]);
|
|
final isSubmitting = useState(false);
|
|
final submitError = useState<String?>(null);
|
|
|
|
useEffect(() {
|
|
final timer = Timer(const Duration(milliseconds: 250), () {
|
|
debouncedQuery.value = query.value.trim();
|
|
});
|
|
return timer.cancel;
|
|
}, [query.value]);
|
|
|
|
final searchFuture = useMemoized(() {
|
|
if (debouncedQuery.value.isEmpty || selectedUsers.value.length >= 8) {
|
|
return Future.value(const <DirectoryUser>[]);
|
|
}
|
|
return ref
|
|
.read(channelActionsProvider)
|
|
.searchUsers(debouncedQuery.value, limit: 8);
|
|
}, [debouncedQuery.value, selectedUsers.value.length]);
|
|
final searchResults = useFuture(searchFuture);
|
|
|
|
final selectedPubkeys = selectedUsers.value
|
|
.map((user) => user.pubkey.toLowerCase())
|
|
.toSet();
|
|
final availableResults =
|
|
searchResults.data
|
|
?.where(
|
|
(user) =>
|
|
!selectedPubkeys.contains(user.pubkey.toLowerCase()) &&
|
|
user.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
|
|
)
|
|
.toList() ??
|
|
const <DirectoryUser>[];
|
|
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
|
|
|
|
Future<void> submit() async {
|
|
if (selectedUsers.value.isEmpty || isSubmitting.value) {
|
|
return;
|
|
}
|
|
|
|
isSubmitting.value = true;
|
|
submitError.value = null;
|
|
try {
|
|
final channel = await ref
|
|
.read(channelActionsProvider)
|
|
.openDm(
|
|
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(channel);
|
|
}
|
|
} catch (error) {
|
|
submitError.value = error.toString();
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
Grid.xs,
|
|
0,
|
|
Grid.xs,
|
|
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: queryController,
|
|
decoration: const InputDecoration(
|
|
prefixIcon: Icon(LucideIcons.search),
|
|
hintText: 'Search by name, NIP-05, or pubkey',
|
|
),
|
|
enabled: !isSubmitting.value,
|
|
onChanged: (value) => query.value = value,
|
|
),
|
|
if (selectedUsers.value.isNotEmpty) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
Wrap(
|
|
spacing: Grid.half,
|
|
runSpacing: Grid.half,
|
|
children: [
|
|
for (final user in selectedUsers.value)
|
|
InputChip(
|
|
label: Text(user.label),
|
|
onDeleted: isSubmitting.value
|
|
? null
|
|
: () {
|
|
selectedUsers.value = [
|
|
for (final candidate in selectedUsers.value)
|
|
if (candidate.pubkey != user.pubkey)
|
|
candidate,
|
|
];
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: Grid.xs),
|
|
SizedBox(
|
|
height: 280,
|
|
child: Builder(
|
|
builder: (context) {
|
|
if (selectedUsers.value.length >= 8) {
|
|
return const Center(
|
|
child: Text(
|
|
'Direct messages support up to 9 people including you.',
|
|
),
|
|
);
|
|
}
|
|
if (debouncedQuery.value.isEmpty) {
|
|
return const Center(
|
|
child: Text(
|
|
'Search for someone to start a conversation.',
|
|
),
|
|
);
|
|
}
|
|
if (searchResults.connectionState ==
|
|
ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (availableResults.isEmpty) {
|
|
return const Center(child: Text('No matching users.'));
|
|
}
|
|
return ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final user in availableResults)
|
|
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),
|
|
onTap: () {
|
|
selectedUsers.value = [
|
|
...selectedUsers.value,
|
|
user,
|
|
];
|
|
queryController.clear();
|
|
query.value = '';
|
|
debouncedQuery.value = '';
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (submitError.value case final error?) ...[
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
error,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: Grid.xs),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: isSubmitting.value
|
|
? null
|
|
: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
FilledButton(
|
|
onPressed: canSubmit ? submit : null,
|
|
child: Text(isSubmitting.value ? 'Opening…' : 'Open DM'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
const _EphemeralBadge({required this.channel});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final label = _label();
|
|
|
|
return Tooltip(
|
|
message: 'Ephemeral channel — cleans up after inactivity',
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(Radii.sm),
|
|
border: Border.all(
|
|
color: const Color(0xFFF59E0B).withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(LucideIcons.clock, size: 10, color: _amberColor(context)),
|
|
if (label != null) ...[
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
label,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
fontSize: 10,
|
|
color: _amberColor(context),
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _amberColor(BuildContext context) {
|
|
return context.colors.brightness == Brightness.light
|
|
? const Color(0xFFB45309)
|
|
: const Color(0xFFFCD34D);
|
|
}
|
|
|
|
String? _label() {
|
|
final deadline = channel.ttlDeadline;
|
|
if (deadline == null) return null;
|
|
final diff = deadline.difference(DateTime.now());
|
|
if (diff.isNegative) return 'due';
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m';
|
|
if (diff.inHours < 24) return '${diff.inHours}h';
|
|
return '${diff.inDays}d';
|
|
}
|
|
}
|
|
|
|
class _ConnectionBanner extends StatelessWidget {
|
|
final SessionStatus status;
|
|
|
|
const _ConnectionBanner({required this.status});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (status == SessionStatus.connected ||
|
|
status == SessionStatus.disconnected) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final isConnecting = status == SessionStatus.connecting;
|
|
final message = isConnecting ? 'Connecting…' : 'Reconnecting…';
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xs,
|
|
vertical: Grid.quarter + 2,
|
|
),
|
|
color: context.colors.surfaceContainerHighest,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 12,
|
|
height: 12,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Text(
|
|
message,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorView extends StatelessWidget {
|
|
final Object error;
|
|
final VoidCallback onRetry;
|
|
|
|
const _ErrorView({required this.error, required this.onRetry});
|
|
|
|
static String _userMessage(Object error) {
|
|
if (error is RelayException) {
|
|
if (error.statusCode == 401) {
|
|
return 'Not authorized. Check your API token.';
|
|
}
|
|
if (error.statusCode == 403) {
|
|
return 'Access denied.';
|
|
}
|
|
return 'Server error (${error.statusCode}). Try again later.';
|
|
}
|
|
if (error is SocketException) {
|
|
return 'Could not reach the relay server.';
|
|
}
|
|
return 'Something went wrong. Check your connection.';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(Grid.sm),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.wifiOff,
|
|
size: Grid.xl,
|
|
color: context.colors.error,
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
'Could not load channels',
|
|
style: context.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
_userMessage(error),
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
FilledButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(LucideIcons.refreshCw),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|