feat(mobile): implement message thread support (#338)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-16 10:00:02 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6c0e363bdd
commit cd6fc96fc5
8 changed files with 1539 additions and 24 deletions
@@ -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
// ---------------------------------------------------------------------------
+32 -1
View File
@@ -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);
});
});
}