Files
buzz/mobile/lib/features/channels/channel_detail_page.dart
2026-04-15 08:55:32 -07:00

551 lines
18 KiB
Dart

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/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channels_provider.dart';
import 'message_content.dart';
import 'send_message_provider.dart';
import 'timeline_message.dart';
/// Fetch channel members and preload their profiles into the user cache.
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
// Capture references before async gap to avoid using disposed ref.
final client = ref.read(relayClientProvider);
final notifier = ref.read(userCacheProvider.notifier);
try {
final json =
await client.get('/api/channels/$channelId/members')
as Map<String, dynamic>;
final members = json['members'] as List<dynamic>? ?? [];
final pubkeys = members
.map((m) => (m as Map<String, dynamic>)['pubkey'] as String)
.toList();
if (pubkeys.isNotEmpty) {
notifier.preload(pubkeys);
}
} catch (_) {
// Non-fatal — mentions will just fall back to cache from messages.
}
}
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
const ChannelDetailPage({super.key, required this.channel});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesState = ref.watch(channelMessagesProvider(channel.id));
final typingEntries = ref.watch(channelTypingProvider(channel.id));
// Preload channel member profiles so @mentions resolve correctly.
useEffect(() {
_preloadMembers(ref, channel.id);
return null;
}, [channel.id]);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
channel.isPrivate ? LucideIcons.lock : LucideIcons.hash,
size: 18,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(channel.name, overflow: TextOverflow.ellipsis),
),
],
),
),
body: Column(
children: [
Expanded(
child: messagesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text(
'Failed to load messages',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
),
),
data: (events) {
final messages = formatTimeline(events);
return _MessageList(messages: messages, channelId: channel.id);
},
),
),
if (typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
_ComposeBar(channelId: channel.id),
],
),
);
}
}
class _MessageList extends ConsumerWidget {
final List<TimelineMessage> messages;
final String channelId;
const _MessageList({required this.messages, required this.channelId});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messageSquare,
size: Grid.xl,
color: context.colors.outline,
),
const SizedBox(height: Grid.xxs),
Text(
'No messages yet',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
'Be the first to say something!',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
],
),
);
}
// Build channel names map once for all message bubbles.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
itemCount: messages.length,
itemBuilder: (context, index) {
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = messages.length - 1 - index;
final message = messages[chronIdx];
if (message.isSystem) {
return _SystemMessageRow(message: message);
}
// The message visually above is the one earlier in time.
final prevMessage = chronIdx > 0 ? messages[chronIdx - 1] : null;
final showAuthor =
prevMessage == null ||
prevMessage.isSystem ||
prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300;
return _MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
);
},
);
}
}
// ---------------------------------------------------------------------------
// System message row
// ---------------------------------------------------------------------------
class _SystemMessageRow extends ConsumerWidget {
final TimelineMessage message;
const _SystemMessageRow({required this.message});
@override
Widget build(BuildContext context, WidgetRef ref) {
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();
final userCache = ref.watch(userCacheProvider);
String resolveLabel(String? pubkey) {
if (pubkey == null) return 'Someone';
final profile =
userCache[pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
return profile?.label ?? _shortPubkey(pubkey);
}
final description = systemEvent.describe(resolveLabel);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.outline,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
description,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.outline,
),
),
),
Text(
_formatTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline.withValues(alpha: 0.6),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// User message bubble
// ---------------------------------------------------------------------------
class _MessageBubble extends ConsumerWidget {
final TimelineMessage message;
final bool showAuthor;
final Map<String, String> channelNames;
final String currentChannelId;
const _MessageBubble({
required this.message,
required this.showAuthor,
required this.channelNames,
required this.currentChannelId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? _shortPubkey(message.pubkey);
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final mentionNames = <String, String>{};
for (final mpk in message.mentionPubkeys) {
final p = userCache[mpk.toLowerCase()];
if (p?.displayName != null) {
mentionNames[mpk.toLowerCase()] = p!.displayName!;
}
}
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
_UserAvatar(profile: profile, pubkey: message.pubkey)
else
const SizedBox(width: 28),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: Row(
children: [
Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.colors.onSurface,
),
),
const SizedBox(width: Grid.xxs),
Text(
_formatTime(message.createdAt),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: mentionNames,
channelNames: channelNames,
onChannelTap: (channelId) {
if (channelId == currentChannelId) {
return;
}
final channelsAsync = ref.read(channelsProvider);
final channels = channelsAsync.hasValue
? channelsAsync.value
: null;
Channel? targetChannel;
for (final channel in channels ?? const <Channel>[]) {
if (channel.id == channelId) {
targetChannel = channel;
break;
}
}
if (targetChannel == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelDetailPage(channel: targetChannel!),
),
);
},
),
],
),
),
],
),
);
}
}
class _UserAvatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
const _UserAvatar({required this.profile, required this.pubkey});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return CircleAvatar(
radius: 14,
backgroundColor: context.colors.primaryContainer,
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
child: avatarUrl == null
? Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
)
: null,
);
}
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
class _TypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;
const _TypingIndicator({required this.entries});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final names = entries.map((e) {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? _shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => '${names[0]} and ${names.length - 1} others are typing…',
};
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.quarter + 2,
),
child: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Compose bar
// ---------------------------------------------------------------------------
class _ComposeBar extends HookConsumerWidget {
final String channelId;
const _ComposeBar({required this.channelId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final isSending = useState(false);
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || isSending.value) return;
isSending.value = true;
try {
await ref
.read(sendMessageProvider)
.call(channelId: channelId, content: text);
if (context.mounted) controller.clear();
} finally {
if (context.mounted) isSending.value = false;
}
}
return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: context.colors.outlineVariant)),
color: context.colors.surface,
),
padding: EdgeInsets.only(
left: Grid.xs,
right: Grid.xxs,
top: Grid.xxs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
textInputAction: TextInputAction.send,
onSubmitted: (_) => send(),
minLines: 1,
maxLines: 5,
decoration: InputDecoration(
hintText: 'Message…',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: context.colors.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: context.colors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: context.colors.primary),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xxs,
),
isDense: true,
),
),
),
const SizedBox(width: Grid.half),
IconButton(
onPressed: isSending.value ? null : send,
icon: isSending.value
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.primary,
),
)
: Icon(
LucideIcons.sendHorizontal,
color: context.colors.primary,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
String _shortPubkey(String pubkey) {
if (pubkey.length > 12) return '${pubkey.substring(0, 8)}…';
return pubkey;
}
String _formatTime(int createdAt) {
final dt = DateTime.fromMillisecondsSinceEpoch(
createdAt * 1000,
isUtc: true,
).toLocal();
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inDays > 0) {
return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}';
}
return '${_pad(dt.hour)}:${_pad(dt.minute)}';
}
String _pad(int n) => n.toString().padLeft(2, '0');