mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): implement message thread support (#338)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import 'channel_typing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'message_content.dart';
|
||||
import 'send_message_provider.dart';
|
||||
import 'thread_detail_page.dart';
|
||||
import 'timeline_message.dart';
|
||||
|
||||
/// Fetch channel members and preload their profiles into the user cache.
|
||||
@@ -51,7 +52,11 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final messagesState = ref.watch(channelMessagesProvider(channel.id));
|
||||
final typingEntries = ref.watch(channelTypingProvider(channel.id));
|
||||
// Only show channel-level typing (exclude thread-scoped entries).
|
||||
final typingEntries = ref
|
||||
.watch(channelTypingProvider(channel.id))
|
||||
.where((e) => e.threadHeadId == null)
|
||||
.toList();
|
||||
final currentPubkey = ref
|
||||
.watch(profileProvider)
|
||||
.whenData((value) => value?.pubkey)
|
||||
@@ -148,10 +153,14 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
events,
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
return _MessageList(
|
||||
messages: messages,
|
||||
entries: entries,
|
||||
allMessages: messages,
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
isArchived: resolvedChannel.isArchived,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -172,20 +181,49 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageList extends ConsumerWidget {
|
||||
final List<TimelineMessage> messages;
|
||||
class _MessageList extends HookConsumerWidget {
|
||||
final List<MainTimelineEntry> entries;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _MessageList({
|
||||
required this.messages,
|
||||
required this.entries,
|
||||
required this.allMessages,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (messages.isEmpty) {
|
||||
// Pagination: fetch older messages when scrolling near the top.
|
||||
final scrollController = useScrollController();
|
||||
final isLoadingOlder = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (isLoadingOlder.value) return;
|
||||
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
|
||||
if (notifier.reachedOldest) return;
|
||||
// In a reversed ListView, maxScrollExtent is the oldest messages.
|
||||
final pos = scrollController.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 200) {
|
||||
isLoadingOlder.value = true;
|
||||
notifier.fetchOlder().whenComplete(
|
||||
() => isLoadingOlder.value = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController]);
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -224,35 +262,70 @@ class _MessageList extends ConsumerWidget {
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
itemCount: entries.length + (isLoadingOlder.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
// Loading indicator at the top (last index in reversed list).
|
||||
if (index >= entries.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: Grid.xs),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Reversed list: index 0 = newest (bottom of screen).
|
||||
final chronIdx = messages.length - 1 - index;
|
||||
final message = messages[chronIdx];
|
||||
final chronIdx = entries.length - 1 - index;
|
||||
final entry = entries[chronIdx];
|
||||
final message = entry.message;
|
||||
|
||||
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 prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null;
|
||||
final prevMessage = prevEntry?.message;
|
||||
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,
|
||||
currentPubkey: currentPubkey,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_MessageBubble(
|
||||
message: message,
|
||||
showAuthor: showAuthor,
|
||||
channelNames: channelNamesMap,
|
||||
currentChannelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
allMessages: allMessages,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
if (entry.summary != null)
|
||||
_ThreadSummaryRow(
|
||||
summary: entry.summary!,
|
||||
message: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -323,6 +396,134 @@ class _SystemMessageRow extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread summary row (shown below messages that have replies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ThreadSummaryRow extends ConsumerWidget {
|
||||
final ThreadSummary summary;
|
||||
final TimelineMessage message;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _ThreadSummaryRow({
|
||||
required this.summary,
|
||||
required this.message,
|
||||
required this.allMessages,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 36,
|
||||
top: Grid.half,
|
||||
bottom: Grid.half,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Stacked participant avatars.
|
||||
SizedBox(
|
||||
width: 20.0 + (summary.participantPubkeys.length - 1) * 12.0,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < summary.participantPubkeys.length; i++)
|
||||
Positioned(
|
||||
left: i * 12.0,
|
||||
child: _SmallAvatar(
|
||||
pubkey: summary.participantPubkeys[i],
|
||||
userCache: userCache,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallAvatar extends StatelessWidget {
|
||||
final String pubkey;
|
||||
final Map<String, UserProfile> userCache;
|
||||
|
||||
const _SmallAvatar({required this.pubkey, required this.userCache});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = userCache[pubkey.toLowerCase()];
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: context.colors.surface, width: 1.5),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 9,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl == null
|
||||
? Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,6 +534,9 @@ class _MessageBubble extends ConsumerWidget {
|
||||
final Map<String, String> channelNames;
|
||||
final String currentChannelId;
|
||||
final String? currentPubkey;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
@@ -340,6 +544,9 @@ class _MessageBubble extends ConsumerWidget {
|
||||
required this.channelNames,
|
||||
required this.currentChannelId,
|
||||
required this.currentPubkey,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -370,6 +577,10 @@ class _MessageBubble extends ConsumerWidget {
|
||||
channelId: currentChannelId,
|
||||
isOwnMessage:
|
||||
currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter),
|
||||
@@ -576,6 +787,10 @@ void _showMessageActions({
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool isOwnMessage,
|
||||
List<TimelineMessage>? allMessages,
|
||||
String? currentPubkey,
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -614,6 +829,26 @@ void _showMessageActions({
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
if (allMessages != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.messageSquareReply),
|
||||
title: const Text('Reply in thread'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../shared/relay/relay.dart';
|
||||
class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
final String channelId;
|
||||
void Function()? _unsubscribe;
|
||||
bool _reachedOldest = false;
|
||||
|
||||
ChannelMessagesNotifier(this.channelId);
|
||||
|
||||
@@ -22,6 +23,8 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
return const AsyncData([]);
|
||||
}
|
||||
|
||||
// Reset pagination state on rebuild (e.g. after reconnect).
|
||||
_reachedOldest = false;
|
||||
_init();
|
||||
return const AsyncLoading();
|
||||
}
|
||||
@@ -75,10 +78,16 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Whether all history has been loaded (no more older messages).
|
||||
bool get reachedOldest => _reachedOldest;
|
||||
|
||||
/// Fetch older messages (pagination). Call this when the user scrolls up.
|
||||
Future<void> fetchOlder() async {
|
||||
/// Returns `true` if new messages were loaded.
|
||||
Future<bool> fetchOlder() async {
|
||||
if (_reachedOldest) return false;
|
||||
|
||||
final currentEvents = state.value;
|
||||
if (currentEvents == null || currentEvents.isEmpty) return;
|
||||
if (currentEvents == null || currentEvents.isEmpty) return false;
|
||||
|
||||
final oldest = currentEvents.first.createdAt;
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
@@ -94,15 +103,28 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
),
|
||||
);
|
||||
|
||||
if (older.isEmpty) return;
|
||||
if (older.isEmpty) {
|
||||
_reachedOldest = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dedup against existing events. If nothing new remains after dedup
|
||||
// (e.g. all returned events share the boundary timestamp), mark as
|
||||
// exhausted to avoid an infinite fetch loop.
|
||||
final currentIds = state.value?.map((e) => e.id).toSet() ?? {};
|
||||
final deduped = older.where((e) => !currentIds.contains(e.id)).toList();
|
||||
|
||||
if (deduped.isEmpty) {
|
||||
_reachedOldest = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
state = state.whenData((events) {
|
||||
final ids = events.map((e) => e.id).toSet();
|
||||
final deduped = older.where((e) => !ids.contains(e.id)).toList();
|
||||
final merged = [...deduped, ...events];
|
||||
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
return merged;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,19 +18,36 @@ class SendMessage {
|
||||
_readUserCache = readUserCache;
|
||||
|
||||
/// Send a text message to a channel.
|
||||
///
|
||||
/// For thread replies, pass [parentEventId] and optionally [rootEventId].
|
||||
/// If [rootEventId] is null it defaults to [parentEventId] (direct reply to
|
||||
/// thread head). Tags are built to match the desktop's `buildReplyTags`
|
||||
/// convention with `root` / `reply` markers.
|
||||
Future<void> call({
|
||||
required String channelId,
|
||||
required String content,
|
||||
String? parentEventId,
|
||||
String? rootEventId,
|
||||
List<String>? mentionPubkeys,
|
||||
}) async {
|
||||
// Resolve @mentions in the message content to pubkeys.
|
||||
final resolvedMentions = mentionPubkeys ?? _resolveMentions(content);
|
||||
final authorPubkey = _signedEventRelay.pubkey;
|
||||
|
||||
// Normalize mentions: lowercase, deduplicate, exclude self (matching
|
||||
// the desktop's normalizeMentionPubkeys).
|
||||
final selfLower = authorPubkey?.toLowerCase();
|
||||
final seenMentions = <String>{?selfLower};
|
||||
final normalizedMentions = <String>[
|
||||
for (final pk in resolvedMentions)
|
||||
if (seenMentions.add(pk.toLowerCase())) pk,
|
||||
];
|
||||
|
||||
final tags = <List<String>>[
|
||||
if (authorPubkey != null) ['p', authorPubkey],
|
||||
['h', channelId],
|
||||
if (parentEventId != null) ['e', parentEventId],
|
||||
for (final pk in resolvedMentions) ['p', pk],
|
||||
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
|
||||
for (final pk in normalizedMentions) ['p', pk],
|
||||
];
|
||||
|
||||
await _signedEventRelay.submit(
|
||||
@@ -70,6 +87,25 @@ class SendMessage {
|
||||
|
||||
return pubkeys.toList();
|
||||
}
|
||||
|
||||
/// Build `e`-tags for a thread reply, matching the desktop convention:
|
||||
/// - Direct reply to thread head: `["e", id, "", "reply"]`
|
||||
/// - Nested reply: `["e", rootId, "", "root"]` + `["e", parentId, "", "reply"]`
|
||||
static List<List<String>> _buildReplyTags(
|
||||
String parentEventId,
|
||||
String? rootEventId,
|
||||
) {
|
||||
final root = rootEventId ?? parentEventId;
|
||||
if (parentEventId == root) {
|
||||
return [
|
||||
['e', root, '', 'reply'],
|
||||
];
|
||||
}
|
||||
return [
|
||||
['e', root, '', 'root'],
|
||||
['e', parentEventId, '', 'reply'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
final sendMessageProvider = Provider<SendMessage>((ref) {
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
import 'channel_management_provider.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';
|
||||
|
||||
/// Full-screen thread detail page.
|
||||
///
|
||||
/// Shows the thread head message, direct replies, typing indicators scoped to
|
||||
/// the thread, and a compose bar for replying.
|
||||
class ThreadDetailPage extends HookConsumerWidget {
|
||||
final TimelineMessage threadHead;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const ThreadDetailPage({
|
||||
super.key,
|
||||
required this.threadHead,
|
||||
required this.allMessages,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Re-derive replies from live message state so new replies appear.
|
||||
final messagesState = ref.watch(channelMessagesProvider(channelId));
|
||||
final liveMessages = messagesState.whenData((events) {
|
||||
return formatTimeline(events, currentPubkey: currentPubkey);
|
||||
});
|
||||
|
||||
final allMsgs = liveMessages.value ?? allMessages;
|
||||
|
||||
// Index all messages by parentId so we can find direct children of any
|
||||
// message and compute thread summaries for nested threads.
|
||||
final childrenByParent = <String, List<TimelineMessage>>{};
|
||||
for (final msg in allMsgs) {
|
||||
final pid = msg.parentId;
|
||||
if (pid == null) continue;
|
||||
childrenByParent.putIfAbsent(pid, () => []).add(msg);
|
||||
}
|
||||
|
||||
final replies = childrenByParent[threadHead.id] ?? const [];
|
||||
|
||||
// Thread-scoped typing indicators.
|
||||
final allTyping = ref.watch(channelTypingProvider(channelId));
|
||||
final threadTyping = allTyping
|
||||
.where((e) => e.threadHeadId == threadHead.id)
|
||||
.toList();
|
||||
|
||||
// Resolve thread head from live data (reactions/edits may have changed).
|
||||
final liveHead =
|
||||
allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead;
|
||||
|
||||
// The root of the entire thread chain. If the current thread head is
|
||||
// itself a root message its rootId is null, so fall back to its own id.
|
||||
final effectiveRootId = threadHead.rootId ?? threadHead.id;
|
||||
|
||||
// Channel names for message content rendering.
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final channelNamesMap = <String, String>{};
|
||||
channelsAsync.whenData((channels) {
|
||||
for (final ch in channels) {
|
||||
channelNamesMap[ch.name.toLowerCase()] = ch.id;
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Thread')),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
itemCount: replies.length + 1, // +1 for thread head
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
// Thread head.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ThreadMessage(
|
||||
message: liveHead,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: true,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final reply = replies[index - 1];
|
||||
final prevReply = index > 1 ? replies[index - 2] : null;
|
||||
final showAuthor =
|
||||
prevReply == null ||
|
||||
prevReply.pubkey.toLowerCase() !=
|
||||
reply.pubkey.toLowerCase() ||
|
||||
(reply.createdAt - prevReply.createdAt) > 300;
|
||||
|
||||
// Check if this reply itself has children (nested thread).
|
||||
final nestedChildren = childrenByParent[reply.id];
|
||||
final nestedSummary =
|
||||
nestedChildren != null && nestedChildren.isNotEmpty
|
||||
? _buildNestedSummary(reply.id, nestedChildren)
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ThreadMessage(
|
||||
message: reply,
|
||||
channelNames: channelNamesMap,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
showAuthor: showAuthor,
|
||||
allMessages: allMsgs,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
if (nestedSummary != null)
|
||||
_NestedThreadSummaryRow(
|
||||
summary: nestedSummary,
|
||||
replyMessage: reply,
|
||||
allMessages: allMsgs,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (threadTyping.isNotEmpty)
|
||||
_ThreadTypingIndicator(entries: threadTyping),
|
||||
if (isMember && !isArchived)
|
||||
_ThreadComposeBar(
|
||||
channelId: channelId,
|
||||
threadHeadId: threadHead.id,
|
||||
rootId: effectiveRootId,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nested thread summary helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a lightweight summary for a nested thread (reply that has its own
|
||||
/// replies). Same logic as the top-level [ThreadSummary] but kept local to
|
||||
/// avoid coupling.
|
||||
ThreadSummary _buildNestedSummary(
|
||||
String messageId,
|
||||
List<TimelineMessage> children,
|
||||
) {
|
||||
final seen = <String>{};
|
||||
final participants = <String>[];
|
||||
for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) {
|
||||
final pk = children[i].pubkey.toLowerCase();
|
||||
if (seen.add(pk)) participants.add(pk);
|
||||
}
|
||||
return ThreadSummary(
|
||||
threadHeadId: messageId,
|
||||
replyCount: children.length,
|
||||
participantPubkeys: participants.reversed.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Tappable summary row shown below a reply that itself has replies.
|
||||
/// Pushes a new [ThreadDetailPage] for the nested thread.
|
||||
class _NestedThreadSummaryRow extends ConsumerWidget {
|
||||
final ThreadSummary summary;
|
||||
final TimelineMessage replyMessage;
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _NestedThreadSummaryRow({
|
||||
required this.summary,
|
||||
required this.replyMessage,
|
||||
required this.allMessages,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
required this.isArchived,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: replyMessage,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 36,
|
||||
top: Grid.half,
|
||||
bottom: Grid.half,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Stacked participant avatars.
|
||||
SizedBox(
|
||||
width:
|
||||
20.0 +
|
||||
(summary.participantPubkeys.length - 1).clamp(0, 2) * 12.0,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < summary.participantPubkeys.length; i++)
|
||||
Positioned(
|
||||
left: i * 12.0,
|
||||
child: _SmallAvatar(
|
||||
pubkey: summary.participantPubkeys[i],
|
||||
userCache: userCache,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Text(
|
||||
'${summary.replyCount} ${summary.replyCount == 1 ? 'reply' : 'replies'}',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallAvatar extends StatelessWidget {
|
||||
final String pubkey;
|
||||
final Map<String, UserProfile> userCache;
|
||||
|
||||
const _SmallAvatar({required this.pubkey, required this.userCache});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = userCache[pubkey.toLowerCase()];
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
||||
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: context.colors.surface, width: 1.5),
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 9,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl == null
|
||||
? Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread message row (reusable for head and replies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ThreadMessage extends ConsumerWidget {
|
||||
final TimelineMessage message;
|
||||
final Map<String, String> channelNames;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool showAuthor;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
|
||||
const _ThreadMessage({
|
||||
required this.message,
|
||||
required this.channelNames,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.showAuthor,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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);
|
||||
|
||||
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 GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: () => _showThreadMessageActions(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
isOwnMessage:
|
||||
currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
|
||||
allMessages: allMessages,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
_Avatar(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: (_) {},
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
_ReactionRow(
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) {
|
||||
final actions = ref.read(channelActionsProvider);
|
||||
final reaction = message.reactions.firstWhere(
|
||||
(r) => r.emoji == emoji,
|
||||
);
|
||||
if (reaction.reactedByCurrentUser &&
|
||||
reaction.currentUserReactionId != null) {
|
||||
actions.removeReaction(
|
||||
reaction.currentUserReactionId!,
|
||||
emoji,
|
||||
);
|
||||
} else {
|
||||
actions.addReaction(message.id, emoji);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread compose bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ThreadComposeBar extends HookConsumerWidget {
|
||||
final String channelId;
|
||||
final String threadHeadId;
|
||||
final String? rootId;
|
||||
|
||||
const _ThreadComposeBar({
|
||||
required this.channelId,
|
||||
required this.threadHeadId,
|
||||
required this.rootId,
|
||||
});
|
||||
|
||||
@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,
|
||||
parentEventId: threadHeadId,
|
||||
rootEventId: rootId ?? threadHeadId,
|
||||
);
|
||||
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: 'Reply...',
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-scoped typing indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ThreadTypingIndicator extends ConsumerWidget {
|
||||
final List<TypingEntry> entries;
|
||||
|
||||
const _ThreadTypingIndicator({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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread message actions (long-press sheet — reactions, copy, edit, delete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _quickEmojis = [
|
||||
'\u{1F44D}',
|
||||
'\u{2764}\u{FE0F}',
|
||||
'\u{1F602}',
|
||||
'\u{1F389}',
|
||||
'\u{1F440}',
|
||||
'\u{1F64F}',
|
||||
];
|
||||
|
||||
void _showThreadMessageActions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required TimelineMessage message,
|
||||
required String channelId,
|
||||
required bool isOwnMessage,
|
||||
List<TimelineMessage>? allMessages,
|
||||
String? currentPubkey,
|
||||
bool isMember = false,
|
||||
bool isArchived = false,
|
||||
}) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (final emoji in _quickEmojis)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelActionsProvider)
|
||||
.addReaction(message.id, emoji);
|
||||
},
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
sheetContext,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
if (allMessages != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.messageSquareReply),
|
||||
title: const Text('Reply in thread'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.copy),
|
||||
title: const Text('Copy text'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
Clipboard.setData(ClipboardData(text: message.content));
|
||||
},
|
||||
),
|
||||
if (isOwnMessage) ...[
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(sheetContext).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
'Delete message',
|
||||
style: TextStyle(
|
||||
color: Theme.of(sheetContext).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete message'),
|
||||
content: const Text('This cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
ref
|
||||
.read(channelActionsProvider)
|
||||
.deleteMessage(message.id);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
dialogContext,
|
||||
).colorScheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (duplicated here to keep the file self-contained)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final UserProfile? profile;
|
||||
final String pubkey;
|
||||
|
||||
const _Avatar({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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionRow extends StatelessWidget {
|
||||
final List<TimelineReaction> reactions;
|
||||
final void Function(String emoji) onToggle;
|
||||
|
||||
const _ReactionRow({required this.reactions, required this.onToggle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: Wrap(
|
||||
spacing: Grid.half,
|
||||
runSpacing: Grid.half,
|
||||
children: [
|
||||
for (final reaction in reactions)
|
||||
GestureDetector(
|
||||
onTap: () => onToggle(reaction.emoji),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xxs,
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: reaction.reactedByCurrentUser
|
||||
? context.colors.primary.withValues(alpha: 0.12)
|
||||
: context.colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
border: Border.all(
|
||||
color: reaction.reactedByCurrentUser
|
||||
? context.colors.primary.withValues(alpha: 0.4)
|
||||
: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(reaction.emoji, style: const TextStyle(fontSize: 14)),
|
||||
if (reaction.count > 1) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Text(
|
||||
'${reaction.count}',
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: reaction.reactedByCurrentUser
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -143,6 +143,12 @@ class TimelineMessage {
|
||||
/// Aggregated reactions on this message.
|
||||
final List<TimelineReaction> reactions;
|
||||
|
||||
/// Direct parent event ID (null for top-level messages).
|
||||
final String? parentId;
|
||||
|
||||
/// Root event ID of the thread (null for top-level messages).
|
||||
final String? rootId;
|
||||
|
||||
const TimelineMessage({
|
||||
required this.id,
|
||||
required this.pubkey,
|
||||
@@ -153,9 +159,39 @@ class TimelineMessage {
|
||||
this.systemEvent,
|
||||
this.mentionPubkeys = const [],
|
||||
this.reactions = const [],
|
||||
this.parentId,
|
||||
this.rootId,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ThreadSummary — inline thread indicator on the main timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@immutable
|
||||
class ThreadSummary {
|
||||
final String threadHeadId;
|
||||
final int replyCount;
|
||||
|
||||
/// Up to 3 most recent unique participant pubkeys.
|
||||
final List<String> participantPubkeys;
|
||||
|
||||
const ThreadSummary({
|
||||
required this.threadHeadId,
|
||||
required this.replyCount,
|
||||
required this.participantPubkeys,
|
||||
});
|
||||
}
|
||||
|
||||
/// A main-timeline entry: a root message with an optional thread summary.
|
||||
@immutable
|
||||
class MainTimelineEntry {
|
||||
final TimelineMessage message;
|
||||
final ThreadSummary? summary;
|
||||
|
||||
const MainTimelineEntry({required this.message, this.summary});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTimeline — converts raw NostrEvents into display-ready messages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,6 +303,8 @@ List<TimelineMessage> formatTimeline(
|
||||
),
|
||||
];
|
||||
|
||||
final threadRef = event.threadReference;
|
||||
|
||||
result.add(
|
||||
TimelineMessage(
|
||||
id: event.id,
|
||||
@@ -276,6 +314,8 @@ List<TimelineMessage> formatTimeline(
|
||||
edited: edit != null,
|
||||
mentionPubkeys: mentions,
|
||||
reactions: reactions,
|
||||
parentId: threadRef.parentId,
|
||||
rootId: threadRef.rootId,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -284,6 +324,53 @@ List<TimelineMessage> formatTimeline(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Build main-timeline entries: only root messages (parentId == null),
|
||||
/// each with an optional [ThreadSummary] when replies exist.
|
||||
///
|
||||
/// Mirrors the desktop's `buildMainTimelineEntries`.
|
||||
List<MainTimelineEntry> buildMainTimelineEntries(
|
||||
List<TimelineMessage> messages,
|
||||
) {
|
||||
// Index direct children by parentId.
|
||||
final childrenByParent = <String, List<TimelineMessage>>{};
|
||||
for (final msg in messages) {
|
||||
final pid = msg.parentId;
|
||||
if (pid == null) continue;
|
||||
childrenByParent.putIfAbsent(pid, () => []).add(msg);
|
||||
}
|
||||
|
||||
return [
|
||||
for (final msg in messages)
|
||||
if (msg.parentId == null)
|
||||
MainTimelineEntry(
|
||||
message: msg,
|
||||
summary: _buildSummary(msg.id, childrenByParent),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
ThreadSummary? _buildSummary(
|
||||
String messageId,
|
||||
Map<String, List<TimelineMessage>> childrenByParent,
|
||||
) {
|
||||
final replies = childrenByParent[messageId];
|
||||
if (replies == null || replies.isEmpty) return null;
|
||||
|
||||
// Up to 3 most recent unique participants (walk backwards).
|
||||
final seen = <String>{};
|
||||
final participants = <String>[];
|
||||
for (var i = replies.length - 1; i >= 0 && participants.length < 3; i--) {
|
||||
final pk = replies[i].pubkey.toLowerCase();
|
||||
if (seen.add(pk)) participants.add(pk);
|
||||
}
|
||||
|
||||
return ThreadSummary(
|
||||
threadHeadId: messageId,
|
||||
replyCount: replies.length,
|
||||
participantPubkeys: participants.reversed.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -87,8 +87,39 @@ class NostrEvent {
|
||||
/// The channel/group ID from the `h` tag (NIP-29).
|
||||
String? get channelId => getTagValue('h');
|
||||
|
||||
/// Extract thread parent and root IDs from `e` tags.
|
||||
///
|
||||
/// Matches the desktop's `getThreadReference` logic:
|
||||
/// - Tags with marker `"reply"` identify the direct parent.
|
||||
/// - Tags with marker `"root"` identify the thread root.
|
||||
/// - If no markers are present, falls back to null (top-level message).
|
||||
({String? parentId, String? rootId}) get threadReference {
|
||||
final eTags = [
|
||||
for (final tag in tags)
|
||||
if (tag.length >= 2 && tag[0] == 'e') tag,
|
||||
];
|
||||
|
||||
if (eTags.isEmpty) return (parentId: null, rootId: null);
|
||||
|
||||
// Find tagged root and reply markers (desktop convention).
|
||||
List<String>? rootTag;
|
||||
List<String>? replyTag;
|
||||
for (final tag in eTags) {
|
||||
if (tag.length >= 4) {
|
||||
if (tag[3] == 'root') rootTag = tag;
|
||||
if (tag[3] == 'reply') replyTag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
if (replyTag == null) return (parentId: null, rootId: null);
|
||||
|
||||
final parentId = replyTag[1];
|
||||
final rootId = rootTag?[1] ?? parentId;
|
||||
return (parentId: parentId, rootId: rootId);
|
||||
}
|
||||
|
||||
/// The parent event ID from the `e` tag.
|
||||
String? get parentEventId => getTagValue('e');
|
||||
String? get parentEventId => threadReference.parentId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
|
||||
@@ -13,6 +13,15 @@ class SignedEventRelay {
|
||||
: _client = client,
|
||||
_nsec = nsec;
|
||||
|
||||
/// The hex pubkey derived from the signing key, or null if no key.
|
||||
String? get pubkey {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return null;
|
||||
final privkeyHex = nostr.Nip19.decodePrivkey(nsec);
|
||||
if (privkeyHex.isEmpty) return null;
|
||||
return nostr.Keychain(privkeyHex).public;
|
||||
}
|
||||
|
||||
Future<void> submit({
|
||||
required int kind,
|
||||
required String content,
|
||||
|
||||
@@ -13,6 +13,7 @@ NostrEvent _textMsg({
|
||||
String pubkey = 'alice',
|
||||
String content = 'hello',
|
||||
int createdAt = 1000,
|
||||
List<List<String>>? extraTags,
|
||||
}) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
@@ -20,11 +21,39 @@ NostrEvent _textMsg({
|
||||
kind: EventKind.streamMessage,
|
||||
tags: [
|
||||
['h', 'ch1'],
|
||||
...?extraTags,
|
||||
],
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
/// A reply message with proper root/reply e-tag markers.
|
||||
NostrEvent _replyMsg({
|
||||
required String id,
|
||||
required String parentId,
|
||||
String? rootId,
|
||||
String pubkey = 'alice',
|
||||
String content = 'reply',
|
||||
int createdAt = 2000,
|
||||
}) {
|
||||
final root = rootId ?? parentId;
|
||||
final eTags = parentId == root
|
||||
? [
|
||||
['e', root, '', 'reply'],
|
||||
]
|
||||
: [
|
||||
['e', root, '', 'root'],
|
||||
['e', parentId, '', 'reply'],
|
||||
];
|
||||
return _textMsg(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
content: content,
|
||||
createdAt: createdAt,
|
||||
extraTags: eTags,
|
||||
);
|
||||
}
|
||||
|
||||
NostrEvent _systemMsg({
|
||||
required String id,
|
||||
required Map<String, dynamic> payload,
|
||||
@@ -443,4 +472,193 @@ void main() {
|
||||
expect(result[0].content, contains('diff'));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// NostrEvent.threadReference
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('NostrEvent.threadReference', () {
|
||||
test('returns nulls for top-level message (no e-tags)', () {
|
||||
final event = _textMsg(id: 'a');
|
||||
final ref = event.threadReference;
|
||||
expect(ref.parentId, isNull);
|
||||
expect(ref.rootId, isNull);
|
||||
});
|
||||
|
||||
test('returns nulls for e-tags without root/reply markers', () {
|
||||
final event = _textMsg(
|
||||
id: 'a',
|
||||
extraTags: [
|
||||
['e', 'target'],
|
||||
],
|
||||
);
|
||||
final ref = event.threadReference;
|
||||
expect(ref.parentId, isNull);
|
||||
expect(ref.rootId, isNull);
|
||||
});
|
||||
|
||||
test('parses direct reply to root (single reply marker)', () {
|
||||
final event = _textMsg(
|
||||
id: 'reply1',
|
||||
extraTags: [
|
||||
['e', 'root1', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final ref = event.threadReference;
|
||||
expect(ref.parentId, 'root1');
|
||||
expect(ref.rootId, 'root1');
|
||||
});
|
||||
|
||||
test('parses nested reply (root + reply markers)', () {
|
||||
final event = _textMsg(
|
||||
id: 'reply2',
|
||||
extraTags: [
|
||||
['e', 'root1', '', 'root'],
|
||||
['e', 'parent1', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final ref = event.threadReference;
|
||||
expect(ref.parentId, 'parent1');
|
||||
expect(ref.rootId, 'root1');
|
||||
});
|
||||
|
||||
test('last reply tag wins when multiple exist', () {
|
||||
final event = _textMsg(
|
||||
id: 'reply3',
|
||||
extraTags: [
|
||||
['e', 'root1', '', 'root'],
|
||||
['e', 'old_parent', '', 'reply'],
|
||||
['e', 'new_parent', '', 'reply'],
|
||||
],
|
||||
);
|
||||
final ref = event.threadReference;
|
||||
expect(ref.parentId, 'new_parent');
|
||||
expect(ref.rootId, 'root1');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// formatTimeline — threading fields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('formatTimeline threading', () {
|
||||
test('root messages have null parentId and rootId', () {
|
||||
final events = [_textMsg(id: 'a')];
|
||||
final result = formatTimeline(events);
|
||||
expect(result[0].parentId, isNull);
|
||||
expect(result[0].rootId, isNull);
|
||||
});
|
||||
|
||||
test('reply messages carry parentId and rootId', () {
|
||||
final events = [
|
||||
_textMsg(id: 'root1'),
|
||||
_replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000),
|
||||
];
|
||||
final result = formatTimeline(events);
|
||||
final reply = result.firstWhere((m) => m.id == 'r1');
|
||||
expect(reply.parentId, 'root1');
|
||||
expect(reply.rootId, 'root1');
|
||||
});
|
||||
|
||||
test('nested reply has distinct parentId and rootId', () {
|
||||
final events = [
|
||||
_textMsg(id: 'root1'),
|
||||
_replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000),
|
||||
_replyMsg(id: 'r2', parentId: 'r1', rootId: 'root1', createdAt: 3000),
|
||||
];
|
||||
final result = formatTimeline(events);
|
||||
final nested = result.firstWhere((m) => m.id == 'r2');
|
||||
expect(nested.parentId, 'r1');
|
||||
expect(nested.rootId, 'root1');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// buildMainTimelineEntries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('buildMainTimelineEntries', () {
|
||||
test('returns only root messages', () {
|
||||
final messages = formatTimeline([
|
||||
_textMsg(id: 'a', createdAt: 1000),
|
||||
_replyMsg(id: 'r1', parentId: 'a', createdAt: 2000),
|
||||
_textMsg(id: 'b', createdAt: 3000),
|
||||
]);
|
||||
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
expect(entries, hasLength(2));
|
||||
expect(entries[0].message.id, 'a');
|
||||
expect(entries[1].message.id, 'b');
|
||||
});
|
||||
|
||||
test('root without replies has null summary', () {
|
||||
final messages = formatTimeline([_textMsg(id: 'a')]);
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
expect(entries[0].summary, isNull);
|
||||
});
|
||||
|
||||
test('root with replies has summary with correct count', () {
|
||||
final messages = formatTimeline([
|
||||
_textMsg(id: 'a', createdAt: 1000),
|
||||
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
||||
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
||||
]);
|
||||
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].summary, isNotNull);
|
||||
expect(entries[0].summary!.replyCount, 2);
|
||||
expect(entries[0].summary!.threadHeadId, 'a');
|
||||
});
|
||||
|
||||
test('summary counts only direct children, not nested replies', () {
|
||||
final messages = formatTimeline([
|
||||
_textMsg(id: 'a', createdAt: 1000),
|
||||
_replyMsg(id: 'r1', parentId: 'a', createdAt: 2000),
|
||||
_replyMsg(id: 'r2', parentId: 'r1', rootId: 'a', createdAt: 3000),
|
||||
]);
|
||||
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
// Only r1 is a direct child of a; r2 is a child of r1.
|
||||
expect(entries[0].summary!.replyCount, 1);
|
||||
});
|
||||
|
||||
test('summary has up to 3 unique participant pubkeys', () {
|
||||
final messages = formatTimeline([
|
||||
_textMsg(id: 'a', createdAt: 1000),
|
||||
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
||||
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
||||
_replyMsg(id: 'r3', parentId: 'a', pubkey: 'bob', createdAt: 4000),
|
||||
_replyMsg(id: 'r4', parentId: 'a', pubkey: 'dave', createdAt: 5000),
|
||||
_replyMsg(id: 'r5', parentId: 'a', pubkey: 'eve', createdAt: 6000),
|
||||
]);
|
||||
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
final participants = entries[0].summary!.participantPubkeys;
|
||||
expect(participants, hasLength(3));
|
||||
// Most recent unique: eve, dave, bob (r5, r4, r3 — carol skipped
|
||||
// because bob at r3 comes before carol at r2 when walking backwards).
|
||||
// Reversed to chronological order.
|
||||
expect(participants, ['bob', 'dave', 'eve']);
|
||||
});
|
||||
|
||||
test('system messages remain in entries (parentId is null)', () {
|
||||
final events = [
|
||||
_systemMsg(
|
||||
id: 's1',
|
||||
payload: {'type': 'channel_created', 'actor': 'pk1'},
|
||||
createdAt: 500,
|
||||
),
|
||||
_textMsg(id: 'a', createdAt: 1000),
|
||||
];
|
||||
final messages = formatTimeline(events);
|
||||
final entries = buildMainTimelineEntries(messages);
|
||||
expect(entries, hasLength(2));
|
||||
expect(entries[0].message.isSystem, true);
|
||||
});
|
||||
|
||||
test('empty input returns empty', () {
|
||||
expect(buildMainTimelineEntries([]), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user