mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): autoscroll thread sidebar for new replies (#363)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,6 @@ type ChannelPaneProps = {
|
||||
threadHeadMessage: TimelineMessage | null;
|
||||
threadMessages: MainTimelineEntry[];
|
||||
threadTypingPubkeys: string[];
|
||||
threadTotalReplyCount: number;
|
||||
threadReplyTargetId: string | null;
|
||||
threadReplyTargetMessage: TimelineMessage | null;
|
||||
threadScrollTargetId: string | null;
|
||||
@@ -130,7 +129,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
threadMessages,
|
||||
threadScrollTargetId,
|
||||
threadTypingPubkeys,
|
||||
threadTotalReplyCount,
|
||||
threadReplyTargetId,
|
||||
threadReplyTargetMessage,
|
||||
typingPubkeys,
|
||||
@@ -286,7 +284,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
totalReplyCount={threadTotalReplyCount}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -274,7 +274,7 @@ export function ChannelScreen({
|
||||
const openThreadHeadMessage = threadPanelData.threadHead;
|
||||
const threadMessages = threadPanelData.visibleReplies;
|
||||
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
|
||||
const threadTotalReplyCount = threadPanelData.totalReplyCount;
|
||||
|
||||
const editTargetMessage = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.find((message) => message.id === editTargetId) ?? null,
|
||||
@@ -466,7 +466,6 @@ export function ChannelScreen({
|
||||
threadHeadMessage={openThreadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadTotalReplyCount={threadTotalReplyCount}
|
||||
threadReplyTargetId={threadReplyTargetId}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
threadScrollTargetId={threadScrollTargetId}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { ArrowDown, X } from "lucide-react";
|
||||
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
@@ -10,6 +10,7 @@ import { MessageComposer } from "./MessageComposer";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageThreadPanelProps = {
|
||||
canResetWidth: boolean;
|
||||
@@ -44,7 +45,6 @@ type MessageThreadPanelProps = {
|
||||
threadHead: TimelineMessage | null;
|
||||
threadReplies: MainTimelineEntry[];
|
||||
threadTypingPubkeys: string[];
|
||||
totalReplyCount: number;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
@@ -98,59 +98,26 @@ export function MessageThreadPanel({
|
||||
}
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!threadHeadId) {
|
||||
return;
|
||||
}
|
||||
const threadMessages = React.useMemo(
|
||||
() => threadReplies.map((entry) => entry.message),
|
||||
[threadReplies],
|
||||
);
|
||||
|
||||
const threadBody = threadBodyRef.current;
|
||||
if (!threadBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
threadBody.scrollTop = threadBody.scrollHeight;
|
||||
};
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
const timeoutId = window.setTimeout(scrollToBottom, 300);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [threadHeadId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scrollTargetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadBody = threadBodyRef.current;
|
||||
if (!threadBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = threadBody.querySelector<HTMLElement>(
|
||||
`[data-message-id="${scrollTargetId}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
target.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
onScrollTargetResolved();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [onScrollTargetResolved, scrollTargetId]);
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
channelId: threadHeadId,
|
||||
isLoading: false,
|
||||
messages: threadMessages,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
});
|
||||
|
||||
if (!threadHead) {
|
||||
return null;
|
||||
@@ -197,73 +164,95 @@ export function MessageThreadPanel({
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<div ref={contentRef}>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
{threadReplies.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{threadReplies.map((entry, index) => {
|
||||
const nextDepth =
|
||||
threadReplies[index + 1]?.message.depth ?? -1;
|
||||
const isExpanded = nextDepth > entry.message.depth;
|
||||
|
||||
return (
|
||||
<div key={entry.message.id}>
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary && !isExpanded ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
{threadReplies.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{threadReplies.map((entry, index) => {
|
||||
const nextDepth = threadReplies[index + 1]?.message.depth ?? -1;
|
||||
const isExpanded = nextDepth > entry.message.depth;
|
||||
|
||||
return (
|
||||
<div key={entry.message.id}>
|
||||
<MessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary && !isExpanded ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto rounded-full shadow-lg"
|
||||
data-testid="thread-scroll-to-latest"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-4">
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
|
||||
@@ -151,6 +151,11 @@ dev *ARGS:
|
||||
set -euo pipefail
|
||||
cd {{desktop_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
# Ensure sidecar placeholder binaries exist (Tauri validates externalBin at compile time)
|
||||
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||
mkdir -p src-tauri/binaries
|
||||
touch "src-tauri/binaries/sprout-acp-$TARGET"
|
||||
touch "src-tauri/binaries/sprout-mcp-server-$TARGET"
|
||||
source ../scripts/instance-env.sh
|
||||
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
|
||||
pnpm exec tauri dev --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
Reference in New Issue
Block a user