Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ type ChannelPaneProps = {
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadTypingPubkeys: string[];
threadTotalReplyCount: number;
threadReplyTargetId: string | null;
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
Expand Down Expand Up @@ -130,7 +129,6 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessages,
threadScrollTargetId,
threadTypingPubkeys,
threadTotalReplyCount,
threadReplyTargetId,
threadReplyTargetMessage,
typingPubkeys,
Expand Down Expand Up @@ -286,7 +284,6 @@ export const ChannelPane = React.memo(function ChannelPane({
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
totalReplyCount={threadTotalReplyCount}
/>
) : null}
</div>
Expand Down
3 changes: 1 addition & 2 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -466,7 +466,6 @@ export function ChannelScreen({
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
threadTotalReplyCount={threadTotalReplyCount}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
Expand Down
211 changes: 100 additions & 111 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -44,7 +45,6 @@ type MessageThreadPanelProps = {
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadTypingPubkeys: string[];
totalReplyCount: number;
widthPx: number;
};

Expand Down Expand Up @@ -98,59 +98,26 @@ export function MessageThreadPanel({
}
: null;

React.useEffect(() => {
if (!threadHeadId) {
return;
}

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();
const threadMessages = React.useMemo(
() => threadReplies.map((entry) => entry.message),
[threadReplies],
);

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;
Expand Down Expand Up @@ -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>

<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;
<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}
return (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
) : 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>
)}
{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>

{!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}
Expand Down
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down