feat: import Chinese-localized Buzz source snapshot
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
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>
This commit is contained in:
@@ -0,0 +1,999 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user