9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1000 lines
40 KiB
Dart
1000 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
|
|
import '../../shared/mentions/agent_identity_provider.dart';
|
|
import '../../shared/l10n/l10n.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/avatar_image.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
|
|
import '../../shared/widgets/message_author_meta.dart';
|
|
import '../profile/user_cache_provider.dart';
|
|
import '../profile/user_profile.dart';
|
|
import 'channel_link_navigation.dart';
|
|
import 'channel_messages_provider.dart';
|
|
import 'channel_typing_provider.dart';
|
|
import 'channel_typing_indicator.dart';
|
|
import 'thread_replies_provider.dart';
|
|
import 'channels_provider.dart';
|
|
import 'compose_bar.dart';
|
|
import 'composer_dock_size_reporter.dart';
|
|
import 'date_formatters.dart';
|
|
import 'day_divider.dart';
|
|
import '../profile/user_profile_sheet.dart';
|
|
import 'message_actions.dart';
|
|
import 'message_long_press_region.dart';
|
|
import 'message_content.dart';
|
|
import 'reaction_row.dart';
|
|
import '../../shared/read_state/read_state_format.dart';
|
|
import '../../shared/read_state/read_state_provider.dart';
|
|
import 'send_message_provider.dart';
|
|
import 'small_avatar.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;
|
|
final String? initialMessageId;
|
|
|
|
const ThreadDetailPage({
|
|
super.key,
|
|
required this.threadHead,
|
|
required this.allMessages,
|
|
required this.channelId,
|
|
required this.currentPubkey,
|
|
required this.isMember,
|
|
required this.isArchived,
|
|
this.initialMessageId,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final composerDockHeight = useState(0.0);
|
|
final sendMessage = ref.read(sendMessageProvider);
|
|
// Relay thread queries are keyed by the outermost root, even when this
|
|
// page displays a nested branch. Query that root, then select this head's
|
|
// direct children from the returned subtree below.
|
|
final queryRootId = threadHead.rootId ?? threadHead.id;
|
|
final repliesState = ref.watch(
|
|
threadRepliesWithLocalProvider(
|
|
ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
|
|
),
|
|
);
|
|
// The thread query is one-shot and asks only for content kinds, so a
|
|
// reaction, edit, or deletion that lands while the thread is open never
|
|
// reaches it — a new pill (and its burst) only showed up after leaving and
|
|
// re-entering, which refetched. The channel socket already receives those
|
|
// events, so union the two sources and format once.
|
|
final liveChannelEvents =
|
|
ref.watch(channelMessagesProvider(channelId)).value ??
|
|
const <NostrEvent>[];
|
|
final replyMessages = repliesState.whenData((events) {
|
|
return formatTimeline(
|
|
mergeThreadEvents(events, liveChannelEvents),
|
|
currentPubkey: currentPubkey,
|
|
);
|
|
});
|
|
|
|
final fetchedReplies = replyMessages.value;
|
|
final liveDeletionHidesHead = _isDeletedBy(
|
|
liveChannelEvents,
|
|
threadHead.id,
|
|
);
|
|
final allMsgs = fetchedReplies == null
|
|
? allMessages
|
|
: [
|
|
// Only fall back to the pushed-route snapshot when neither source
|
|
// carries the head, and no live deletion has suppressed it. That
|
|
// keeps a temporarily unavailable head visible without restoring
|
|
// a head that was deleted while this page was open.
|
|
if (!liveDeletionHidesHead &&
|
|
!fetchedReplies.any((message) => message.id == threadHead.id))
|
|
threadHead,
|
|
...fetchedReplies,
|
|
];
|
|
|
|
// 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 [];
|
|
final itemScrollController = useMemoized(ItemScrollController.new);
|
|
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
|
final didJumpToInitialMessage = useRef(false);
|
|
final followsThreadTail = useRef(false);
|
|
final pendingTailAlignment = useRef<double?>(null);
|
|
final tailRealignmentQueued = useRef(false);
|
|
|
|
// Item 0 is the thread head; reply `i` lives at `i + 1`.
|
|
const headIndex = 0;
|
|
int indexForReply(int chronologicalIndex) => chronologicalIndex + 1;
|
|
|
|
bool threadTailIsVisible() {
|
|
final lastIndex = replies.isEmpty
|
|
? headIndex
|
|
: indexForReply(replies.length - 1);
|
|
return itemPositionsListener.itemPositions.value.any(
|
|
(position) =>
|
|
position.index == lastIndex && position.itemTrailingEdge <= 1.001,
|
|
);
|
|
}
|
|
|
|
useEffect(() {
|
|
void onPositionsChanged() {
|
|
if (threadTailIsVisible()) followsThreadTail.value = true;
|
|
}
|
|
|
|
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
|
|
return () => itemPositionsListener.itemPositions.removeListener(
|
|
onPositionsChanged,
|
|
);
|
|
}, [itemPositionsListener, replies.length]);
|
|
|
|
useEffect(() {
|
|
final messageId = initialMessageId;
|
|
// Wait for the authoritative thread query before consuming the one-shot
|
|
// jump; the fallback main-timeline list can contain only the linked reply.
|
|
if (messageId == null || fetchedReplies == null) return null;
|
|
final chronologicalIndex = replies.indexWhere(
|
|
(reply) => reply.id == messageId,
|
|
);
|
|
final targetIndex = messageId == threadHead.id
|
|
? headIndex
|
|
: chronologicalIndex < 0
|
|
? null
|
|
: indexForReply(chronologicalIndex);
|
|
if (targetIndex == null || didJumpToInitialMessage.value) return null;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!context.mounted || !itemScrollController.isAttached) return;
|
|
// The provisional route snapshot can make the linked reply look like
|
|
// the tail. This authoritative deep-link jump intentionally leaves
|
|
// the user at an older item, so it must opt out of follow-tail first.
|
|
followsThreadTail.value = false;
|
|
pendingTailAlignment.value = null;
|
|
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
|
|
didJumpToInitialMessage.value = true;
|
|
});
|
|
return null;
|
|
}, [initialMessageId, fetchedReplies, replies.length]);
|
|
|
|
// A top-anchored list doesn't stick to the newest item the way the old
|
|
// reversed one did, so follow the tail explicitly: when a reply arrives
|
|
// while the last item is on screen, scroll it into view. If the user has
|
|
// scrolled up to read, leave them where they are.
|
|
final hasFetchedReplies = fetchedReplies != null;
|
|
final didEstablishInitialReplies = useRef(hasFetchedReplies);
|
|
final previousReplyCount = useRef(replies.length);
|
|
useEffect(() {
|
|
// The first authoritative query result is hydration, not a live arrival.
|
|
// Establish the baseline without moving the user away from the head.
|
|
if (!hasFetchedReplies) return null;
|
|
if (!didEstablishInitialReplies.value) {
|
|
didEstablishInitialReplies.value = true;
|
|
previousReplyCount.value = replies.length;
|
|
return null;
|
|
}
|
|
|
|
final previous = previousReplyCount.value;
|
|
previousReplyCount.value = replies.length;
|
|
if (replies.length <= previous) return null;
|
|
final positions = itemPositionsListener.itemPositions.value;
|
|
final lastIndex = indexForReply(replies.length - 1);
|
|
// Positions still describe the list as it was *before* these replies, so
|
|
// compare against the old tail. Measuring against the new one only reads
|
|
// as "at the tail" when exactly one reply arrived.
|
|
final previousLastIndex = previous == 0
|
|
? headIndex
|
|
: indexForReply(previous - 1);
|
|
final wasAtTail =
|
|
positions.isEmpty ||
|
|
positions.any((position) => position.index >= previousLastIndex);
|
|
final localPubkey = currentPubkey?.toLowerCase();
|
|
final hasNewLocalReply =
|
|
localPubkey != null &&
|
|
replies
|
|
.skip(previous)
|
|
.any((reply) => reply.pubkey.toLowerCase() == localPubkey);
|
|
// A reply the current user just sent must be visible even if they were
|
|
// reading at the head of a long thread. Remote arrivals still respect
|
|
// the user's scroll position.
|
|
if (!wasAtTail && !hasNewLocalReply) return null;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!context.mounted || !itemScrollController.isAttached) return;
|
|
itemScrollController.scrollTo(
|
|
index: lastIndex,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
});
|
|
return null;
|
|
}, [hasFetchedReplies, replies.length]);
|
|
final readState = ref.watch(readStateProvider);
|
|
final visibleReplyReadKey = replies
|
|
.map((reply) => '${reply.id}:${reply.createdAt}')
|
|
.join(',');
|
|
|
|
useEffect(() {
|
|
if (!readState.isReady || replies.isEmpty) return null;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
for (final reply in replies) {
|
|
ref
|
|
.read(readStateProvider.notifier)
|
|
.markContextRead(msgContextKey(reply.id), reply.createdAt);
|
|
}
|
|
});
|
|
return null;
|
|
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
|
|
|
|
// Thread-scoped typing indicators (exclude self).
|
|
final allTyping = ref.watch(channelTypingProvider(channelId));
|
|
final threadTyping = allTyping
|
|
.where((e) => e.threadHeadId == threadHead.id)
|
|
.where(
|
|
(e) =>
|
|
currentPubkey == null ||
|
|
e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
|
|
)
|
|
.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;
|
|
|
|
void updateComposerDockHeight(double height) {
|
|
final previousHeight = composerDockHeight.value;
|
|
final heightDelta = height - previousHeight;
|
|
if (heightDelta.abs() < 0.5) return;
|
|
|
|
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
|
|
if (shouldFollowTail) followsThreadTail.value = true;
|
|
composerDockHeight.value = height;
|
|
if (heightDelta <= 0 || !shouldFollowTail) {
|
|
pendingTailAlignment.value = null;
|
|
return;
|
|
}
|
|
final lastIndex = replies.isEmpty
|
|
? headIndex
|
|
: indexForReply(replies.length - 1);
|
|
final lastPosition = itemPositionsListener.itemPositions.value
|
|
.where((position) => position.index == lastIndex)
|
|
.firstOrNull;
|
|
if (lastPosition == null) return;
|
|
final targetAlignment =
|
|
(pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) -
|
|
(heightDelta / MediaQuery.sizeOf(context).height);
|
|
pendingTailAlignment.value = targetAlignment;
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!context.mounted || !itemScrollController.isAttached) return;
|
|
itemScrollController.jumpTo(
|
|
index: lastIndex,
|
|
alignment: targetAlignment,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Composer size changes and keyboard metrics changes are independent:
|
|
// the dock grows first, then the Scaffold's viewport shrinks once the
|
|
// keyboard appears. Re-align after that latter layout pass too, but only
|
|
// while the user was already following the thread tail.
|
|
void realignThreadTailAfterMetricsChange() {
|
|
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
|
|
if (!shouldFollowTail || tailRealignmentQueued.value) return;
|
|
followsThreadTail.value = true;
|
|
tailRealignmentQueued.value = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
tailRealignmentQueued.value = false;
|
|
if (!context.mounted ||
|
|
!itemScrollController.isAttached ||
|
|
!followsThreadTail.value) {
|
|
return;
|
|
}
|
|
final lastIndex = replies.isEmpty
|
|
? headIndex
|
|
: indexForReply(replies.length - 1);
|
|
itemScrollController.scrollTo(
|
|
index: lastIndex,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
});
|
|
}
|
|
|
|
useEffect(() {
|
|
final observer = _ThreadTailMetricsObserver(
|
|
onMetricsChanged: realignThreadTailAfterMetricsChange,
|
|
);
|
|
WidgetsBinding.instance.addObserver(observer);
|
|
return () => WidgetsBinding.instance.removeObserver(observer);
|
|
}, [itemScrollController, replies.length]);
|
|
|
|
// 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 FrostedScaffold(
|
|
appBar: FrostedAppBar(
|
|
title: Text(context.l10n.threadTitle),
|
|
titleStyle: channelTitleTextStyle,
|
|
),
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: KeyboardDismissOnDrag(
|
|
onUserScrollStart: () {
|
|
followsThreadTail.value = false;
|
|
pendingTailAlignment.value = null;
|
|
},
|
|
child: ScrollablePositionedList.builder(
|
|
key: const ValueKey('thread-message-list'),
|
|
itemScrollController: itemScrollController,
|
|
itemPositionsListener: itemPositionsListener,
|
|
// Top-anchored, head first, replies flowing down — matching
|
|
// desktop's thread panel. The old reversed list bottom-anchored
|
|
// the content, which jammed the head against the composer
|
|
// whenever a thread had only a handful of replies.
|
|
padding: EdgeInsets.only(
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
top: frostedAppBarHeight(context),
|
|
bottom: Grid.xs + composerDockHeight.value,
|
|
),
|
|
itemCount: replies.length + 1, // +1 for thread head
|
|
itemBuilder: (context, index) {
|
|
if (index == headIndex) {
|
|
if (liveDeletionHidesHead) {
|
|
return Padding(
|
|
key: const ValueKey('thread-message-deleted'),
|
|
padding: const EdgeInsets.only(bottom: Grid.xs),
|
|
child: Text(context.l10n.messageDeleted),
|
|
);
|
|
}
|
|
return Padding(
|
|
key: ValueKey('thread-message-group-${liveHead.id}'),
|
|
padding: const EdgeInsets.only(bottom: Grid.xs),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DayDivider(
|
|
label: formatDayHeading(
|
|
liveHead.createdAt,
|
|
l10n: context.l10n,
|
|
),
|
|
),
|
|
_ThreadMessage(
|
|
message: liveHead,
|
|
channelNames: channelNamesMap,
|
|
channelId: channelId,
|
|
currentPubkey: currentPubkey,
|
|
showAuthor: true,
|
|
isHighlighted: liveHead.id == initialMessageId,
|
|
allMessages: allMsgs,
|
|
isMember: isMember,
|
|
isArchived: isArchived,
|
|
isThreadHead: true,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: Grid.xxs,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
context.l10n.replyCount(replies.length),
|
|
style: context.textTheme.labelMedium
|
|
?.copyWith(
|
|
color:
|
|
context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Expanded(
|
|
child: Divider(
|
|
color: context.colors.outlineVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Chronological list: index 1 = oldest reply.
|
|
final chronIdx = index - 1;
|
|
final reply = replies[chronIdx];
|
|
final prevReply = chronIdx > 0
|
|
? replies[chronIdx - 1]
|
|
: null;
|
|
final previousMessage = prevReply ?? liveHead;
|
|
final showDayDivider = !isSameDay(
|
|
previousMessage.createdAt,
|
|
reply.createdAt,
|
|
);
|
|
final showAuthor =
|
|
prevReply == null ||
|
|
showDayDivider ||
|
|
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 Padding(
|
|
key: ValueKey('thread-message-group-${reply.id}'),
|
|
// Tail spacing comes from the list's own bottom padding now
|
|
// that the list runs top-down; the reversed list used to
|
|
// need it here because item 0 sat against the composer.
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (showDayDivider)
|
|
DayDivider(
|
|
label: formatDayHeading(
|
|
reply.createdAt,
|
|
l10n: context.l10n,
|
|
),
|
|
),
|
|
_ThreadMessage(
|
|
message: reply,
|
|
channelNames: channelNamesMap,
|
|
channelId: channelId,
|
|
currentPubkey: currentPubkey,
|
|
showAuthor: showAuthor,
|
|
isHighlighted: reply.id == initialMessageId,
|
|
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 (!isMember || isArchived)
|
|
AnimatedSize(
|
|
duration: MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.bottomCenter,
|
|
child: threadTyping.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ChannelTypingIndicator(entries: threadTyping),
|
|
),
|
|
],
|
|
),
|
|
if (isMember && !isArchived)
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: ComposerDockSizeReporter(
|
|
key: const ValueKey('thread-composer-dock'),
|
|
onHeightChanged: updateComposerDockHeight,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedSize(
|
|
duration: MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.bottomCenter,
|
|
child: threadTyping.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ChannelTypingIndicator(entries: threadTyping),
|
|
),
|
|
ComposeBar(
|
|
channelId: channelId,
|
|
hintText: context.l10n.replyInThreadHint,
|
|
threadHeadId: threadHead.id,
|
|
rootId: effectiveRootId,
|
|
onSend:
|
|
(
|
|
content,
|
|
mentionPubkeys, {
|
|
mediaTags = const <List<String>>[],
|
|
}) => sendMessage.call(
|
|
channelId: channelId,
|
|
content: content,
|
|
mentionPubkeys: mentionPubkeys,
|
|
parentEventId: threadHead.id,
|
|
rootEventId: effectiveRootId,
|
|
mediaTags: mediaTags,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
bool _isDeletedBy(Iterable<NostrEvent> events, String messageId) {
|
|
for (final event in events) {
|
|
if (event.kind != EventKind.deletion &&
|
|
event.kind != EventKind.nip29DeleteEvent) {
|
|
continue;
|
|
}
|
|
if (event.tags.any(
|
|
(tag) => tag.length >= 2 && tag[0] == 'e' && tag[1] == messageId,
|
|
)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/// 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(),
|
|
lastReplyAt: children.last.createdAt,
|
|
);
|
|
}
|
|
|
|
/// 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(
|
|
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
|
|
padding: const EdgeInsets.only(
|
|
left: messageAvatarSize + messageAvatarContentGap,
|
|
top: Grid.half,
|
|
bottom: Grid.xs,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Stacked participant avatars.
|
|
SizedBox(
|
|
width:
|
|
32.0 +
|
|
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
|
|
height: 32,
|
|
child: Stack(
|
|
children: [
|
|
for (var i = 0; i < summary.participantPubkeys.length; i++)
|
|
Positioned(
|
|
left: i * 20.0,
|
|
child: SmallAvatar(
|
|
pubkey: summary.participantPubkeys[i],
|
|
userCache: userCache,
|
|
size: 32,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Flexible(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text:
|
|
context.l10n.replyCount(summary.replyCount),
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
if (summary.lastReplyAt case final lastReplyAt?) ...[
|
|
TextSpan(
|
|
text: ' · ',
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: context.l10n.lastReply(
|
|
formatThreadSummaryLastReplyTime(
|
|
lastReplyAt,
|
|
l10n: context.l10n,
|
|
),
|
|
),
|
|
style: replyPreviewTextStyle.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
|
|
final VoidCallback onMetricsChanged;
|
|
_ThreadTailMetricsObserver({required this.onMetricsChanged});
|
|
|
|
@override
|
|
void didChangeMetrics() => onMetricsChanged();
|
|
}
|
|
|
|
class _ThreadMessage extends ConsumerWidget {
|
|
final TimelineMessage message;
|
|
final Map<String, String> channelNames;
|
|
final String channelId;
|
|
final String? currentPubkey;
|
|
final bool showAuthor;
|
|
final bool isHighlighted;
|
|
final List<TimelineMessage>? allMessages;
|
|
final bool isMember;
|
|
final bool isArchived;
|
|
|
|
/// Whether this is the message the thread hangs off, which keeps a standing
|
|
/// "+" where replies only get one once they carry a reaction.
|
|
final bool isThreadHead;
|
|
|
|
const _ThreadMessage({
|
|
required this.message,
|
|
required this.channelNames,
|
|
required this.channelId,
|
|
required this.currentPubkey,
|
|
required this.showAuthor,
|
|
this.isHighlighted = false,
|
|
this.allMessages,
|
|
this.isMember = false,
|
|
this.isArchived = false,
|
|
this.isThreadHead = 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 canManageMessage =
|
|
currentPubkey?.toLowerCase() == pk ||
|
|
(profile?.ownerPubkey != null &&
|
|
profile?.ownerPubkey == currentPubkey?.toLowerCase());
|
|
|
|
final userCache = ref.watch(userCacheProvider);
|
|
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
|
|
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
|
|
profileOwnedAgentPubkeys: [
|
|
for (final profile in userCache.values)
|
|
if (profile.ownerPubkey != null) profile.pubkey,
|
|
],
|
|
);
|
|
final mentionNames = <String, String>{};
|
|
final agentMentionPubkeys = <String>{};
|
|
for (final mpk in message.mentionPubkeys) {
|
|
final normalizedPubkey = mpk.toLowerCase();
|
|
final p = userCache[normalizedPubkey];
|
|
if (p?.displayName != null) {
|
|
mentionNames[normalizedPubkey] = p!.displayName!;
|
|
}
|
|
if (knownAgentPubkeys.contains(normalizedPubkey)) {
|
|
agentMentionPubkeys.add(normalizedPubkey);
|
|
}
|
|
}
|
|
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
|
|
mentionPubkeys: message.mentionPubkeys,
|
|
profileMentionNames: mentionNames,
|
|
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
|
|
agentMentionPubkeys: agentMentionPubkeys,
|
|
);
|
|
|
|
void openMessageActions(Rect anchorRect) {
|
|
showMessageActions(
|
|
context: context,
|
|
ref: ref,
|
|
message: message,
|
|
channelId: channelId,
|
|
canManageMessage: canManageMessage,
|
|
allMessages: allMessages,
|
|
currentPubkey: currentPubkey,
|
|
isMember: isMember,
|
|
isArchived: isArchived,
|
|
anchorRect: anchorRect,
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
|
child: DecoratedBox(
|
|
key: ValueKey('thread-message-${message.id}'),
|
|
decoration: BoxDecoration(
|
|
color: isHighlighted
|
|
? context.colors.primary.withValues(alpha: 0.12)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
// The media carousel intentionally continues through the list's
|
|
// trailing gutter. InkWell still clips its ink to [borderRadius],
|
|
// while leaving overflowing message content visible.
|
|
clipBehavior: Clip.none,
|
|
child: MessageLongPressInkWell(
|
|
key: ValueKey('thread-message-row-${message.id}'),
|
|
onLongPress: openMessageActions,
|
|
borderRadius: BorderRadius.circular(Radii.md),
|
|
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
top: showAuthor ? 0 : Grid.xxs,
|
|
bottom: showAuthor ? 0 : Grid.xxs,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (showAuthor)
|
|
GestureDetector(
|
|
onTap: () =>
|
|
showUserProfileSheet(context, message.pubkey),
|
|
child: _Avatar(profile: profile, pubkey: message.pubkey),
|
|
)
|
|
else
|
|
const SizedBox(width: messageAvatarSize),
|
|
const SizedBox(width: messageAvatarContentGap),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (showAuthor)
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
bottom: Grid.quarter,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: MessageAuthorMeta(
|
|
displayName: displayName,
|
|
username: messageUsernameLabel(profile),
|
|
timestamp: formatMessageTime(
|
|
message.createdAt,
|
|
l10n: context.l10n,
|
|
),
|
|
nameColor: context.colors.onSurface,
|
|
metadataColor:
|
|
context.colors.onSurfaceVariant,
|
|
onAuthorTap: () => showUserProfileSheet(
|
|
context,
|
|
message.pubkey,
|
|
),
|
|
displayNameKey: ValueKey(
|
|
'thread-message-author-${message.id}',
|
|
),
|
|
usernameKey: ValueKey(
|
|
'thread-message-username-${message.id}',
|
|
),
|
|
timestampKey: ValueKey(
|
|
'thread-message-timestamp-${message.id}',
|
|
),
|
|
),
|
|
),
|
|
if (message.edited) ...[
|
|
const SizedBox(width: Grid.half),
|
|
Text(
|
|
context.l10n.edited,
|
|
style: context.textTheme.labelSmall
|
|
?.copyWith(
|
|
color:
|
|
context.colors.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
MessageContent(
|
|
content: message.content,
|
|
mentionNames: resolvedMentionNames,
|
|
agentMentionPubkeys: agentMentionPubkeys,
|
|
channelNames: channelNames,
|
|
tags: message.tags,
|
|
baseStyle: messageBodyTextStyle.copyWith(
|
|
color: context.colors.onSurface,
|
|
),
|
|
scaleEmojiOnly: true,
|
|
mediaCarouselTrailingOverflow: Grid.gutter,
|
|
onMediaReply: allMessages == null
|
|
? null
|
|
: () {
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ThreadDetailPage(
|
|
threadHead: message,
|
|
allMessages: allMessages!,
|
|
channelId: channelId,
|
|
currentPubkey: currentPubkey,
|
|
isMember: isMember,
|
|
isArchived: isArchived,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
onMediaMore: (viewerContext, imageUrl) =>
|
|
showImageActions(
|
|
context: viewerContext,
|
|
ref: ref,
|
|
message: message,
|
|
channelId: channelId,
|
|
imageUrl: imageUrl,
|
|
canManageMessage: canManageMessage,
|
|
onDeleted: () {
|
|
if (viewerContext.mounted) {
|
|
Navigator.of(viewerContext).maybePop();
|
|
}
|
|
},
|
|
),
|
|
onChannelTap: (targetChannelId) {
|
|
openChannelLink(
|
|
context: context,
|
|
ref: ref,
|
|
channelId: targetChannelId,
|
|
currentChannelId: channelId,
|
|
);
|
|
},
|
|
onMentionTap: (pubkey) =>
|
|
showUserProfileSheet(context, pubkey),
|
|
),
|
|
ReactionRow(
|
|
messageId: message.id,
|
|
reactions: message.reactions,
|
|
onToggle: (emoji) =>
|
|
toggleReaction(ref, message, emoji),
|
|
showAddButton:
|
|
isMember &&
|
|
!isArchived &&
|
|
(isThreadHead || message.reactions.isNotEmpty),
|
|
onAddReaction: () => showAddReactionPicker(
|
|
context: context,
|
|
ref: ref,
|
|
message: message,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
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 AvatarImage(
|
|
imageUrl: avatarUrl,
|
|
radius: messageAvatarSize / 2,
|
|
backgroundColor: context.colors.primaryContainer,
|
|
fallback: Text(
|
|
initial,
|
|
style: context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|