mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Improve thread branch display (#1166)
This commit is contained in:
@@ -831,6 +831,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadUnreadCount={threadUnreadCounts?.get(
|
||||
threadHeadMessage.id,
|
||||
)}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildThreadPanelData,
|
||||
buildThreadPanelDataFromIndex,
|
||||
buildThreadPanelIndex,
|
||||
buildThreadSummaryFromVisibleEntries,
|
||||
hasNestedThreadBranches,
|
||||
shouldRenderUnreadDivider,
|
||||
} from "./threadPanel.ts";
|
||||
|
||||
@@ -64,7 +66,7 @@ test("buildMainTimelineEntries includes broadcast replies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildThreadPanelData keeps direct comments unindented", () => {
|
||||
test("buildThreadPanelData connects direct comments to the thread head", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const directComment = message({
|
||||
id: "direct-comment",
|
||||
@@ -99,12 +101,198 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
|
||||
depth: entry.message.depth,
|
||||
})),
|
||||
[
|
||||
{ id: "direct-comment", depth: 0 },
|
||||
{ id: "nested-reply", depth: 1 },
|
||||
{ id: "direct-comment", depth: 1 },
|
||||
{ id: "nested-reply", depth: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildThreadPanelData hides collapsed summaries for expanded replies", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const branch = message({
|
||||
id: "branch",
|
||||
createdAt: 2,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
tags: [["e", "root", "", "reply"]],
|
||||
});
|
||||
const child = message({
|
||||
id: "child",
|
||||
createdAt: 3,
|
||||
parentId: "branch",
|
||||
rootId: "root",
|
||||
depth: 2,
|
||||
tags: [
|
||||
["e", "root", "", "root"],
|
||||
["e", "branch", "", "reply"],
|
||||
],
|
||||
});
|
||||
|
||||
const collapsed = buildThreadPanelData(
|
||||
[root, branch, child],
|
||||
"root",
|
||||
"root",
|
||||
new Set(),
|
||||
);
|
||||
const expanded = buildThreadPanelData(
|
||||
[root, branch, child],
|
||||
"root",
|
||||
"root",
|
||||
new Set(["branch"]),
|
||||
);
|
||||
|
||||
assert.equal(collapsed.visibleReplies[0].summary?.replyCount, 1);
|
||||
assert.equal(expanded.visibleReplies[0].summary, null);
|
||||
});
|
||||
|
||||
test("buildThreadSummaryFromVisibleEntries counts visible rows and hidden descendants", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const branch = message({
|
||||
id: "branch",
|
||||
createdAt: 2,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
pubkey: "branch-author",
|
||||
author: "Branch Author",
|
||||
});
|
||||
const child = message({
|
||||
id: "child",
|
||||
createdAt: 3,
|
||||
parentId: "branch",
|
||||
rootId: "root",
|
||||
depth: 2,
|
||||
pubkey: "child-author",
|
||||
author: "Child Author",
|
||||
});
|
||||
const grandchild = message({
|
||||
id: "grandchild",
|
||||
createdAt: 4,
|
||||
parentId: "child",
|
||||
rootId: "root",
|
||||
depth: 3,
|
||||
pubkey: "grandchild-author",
|
||||
author: "Grandchild Author",
|
||||
});
|
||||
const sibling = message({
|
||||
id: "sibling",
|
||||
createdAt: 5,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
pubkey: "sibling-author",
|
||||
author: "Sibling Author",
|
||||
});
|
||||
|
||||
const collapsed = buildThreadPanelData(
|
||||
[root, branch, child, grandchild, sibling],
|
||||
"root",
|
||||
"root",
|
||||
new Set(),
|
||||
);
|
||||
const expanded = buildThreadPanelData(
|
||||
[root, branch, child, grandchild, sibling],
|
||||
"root",
|
||||
"root",
|
||||
new Set(["branch"]),
|
||||
);
|
||||
|
||||
for (const entries of [collapsed.visibleReplies, expanded.visibleReplies]) {
|
||||
const summary = buildThreadSummaryFromVisibleEntries("root", entries);
|
||||
assert.equal(summary?.threadHeadId, "root");
|
||||
assert.equal(summary?.replyCount, 4);
|
||||
assert.equal(summary?.lastReplyAt, 5);
|
||||
assert.equal(summary?.participants.length, 3);
|
||||
assert.ok(
|
||||
summary?.participants.some(
|
||||
(participant) => participant.id === "sibling-author",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("hasNestedThreadBranches returns false for flat direct replies", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const first = message({
|
||||
id: "first",
|
||||
createdAt: 2,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
});
|
||||
const second = message({
|
||||
id: "second",
|
||||
createdAt: 3,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
const panelData = buildThreadPanelData(
|
||||
[root, first, second],
|
||||
"root",
|
||||
"root",
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false);
|
||||
});
|
||||
|
||||
test("hasNestedThreadBranches returns true for visible nested replies", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const branch = message({
|
||||
id: "branch",
|
||||
createdAt: 2,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
});
|
||||
const child = message({
|
||||
id: "child",
|
||||
createdAt: 3,
|
||||
parentId: "branch",
|
||||
rootId: "root",
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
const panelData = buildThreadPanelData(
|
||||
[root, branch, child],
|
||||
"root",
|
||||
"root",
|
||||
new Set(["branch"]),
|
||||
);
|
||||
|
||||
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true);
|
||||
});
|
||||
|
||||
test("hasNestedThreadBranches returns true for collapsed nested replies", () => {
|
||||
const root = message({ id: "root", createdAt: 1 });
|
||||
const branch = message({
|
||||
id: "branch",
|
||||
createdAt: 2,
|
||||
parentId: "root",
|
||||
rootId: "root",
|
||||
depth: 1,
|
||||
});
|
||||
const child = message({
|
||||
id: "child",
|
||||
createdAt: 3,
|
||||
parentId: "branch",
|
||||
rootId: "root",
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
const panelData = buildThreadPanelData(
|
||||
[root, branch, child],
|
||||
"root",
|
||||
"root",
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true);
|
||||
});
|
||||
|
||||
test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => {
|
||||
// Fresh/never-read channel: the first message IS the first unread, nothing
|
||||
// above it to separate from.
|
||||
|
||||
@@ -41,6 +41,12 @@ export type ThreadPanelIndex = {
|
||||
|
||||
const MAX_SUMMARY_PARTICIPANTS = 3;
|
||||
|
||||
type SummaryParticipantCandidate = {
|
||||
index: number;
|
||||
participant: TimelineThreadSummaryParticipant;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
|
||||
return {
|
||||
...message,
|
||||
@@ -217,6 +223,97 @@ function buildSummaryForDirectReplies(
|
||||
};
|
||||
}
|
||||
|
||||
function participantFromMessage(
|
||||
message: TimelineMessage,
|
||||
): TimelineThreadSummaryParticipant {
|
||||
return {
|
||||
id: message.pubkey ?? message.id,
|
||||
author: message.author,
|
||||
avatarUrl: message.avatarUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildThreadSummaryFromVisibleEntries(
|
||||
threadHeadId: string,
|
||||
entries: readonly MainTimelineEntry[],
|
||||
): TimelineThreadSummary | null {
|
||||
let replyCount = 0;
|
||||
let lastReplyAt: number | null = null;
|
||||
const participantCandidates: SummaryParticipantCandidate[] = [];
|
||||
|
||||
const addParticipantCandidate = (
|
||||
participant: TimelineThreadSummaryParticipant,
|
||||
timestamp: number,
|
||||
) => {
|
||||
participantCandidates.push({
|
||||
index: participantCandidates.length,
|
||||
participant,
|
||||
timestamp,
|
||||
});
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
replyCount += 1;
|
||||
lastReplyAt = Math.max(lastReplyAt ?? 0, entry.message.createdAt);
|
||||
addParticipantCandidate(
|
||||
participantFromMessage(entry.message),
|
||||
entry.message.createdAt,
|
||||
);
|
||||
|
||||
if (entry.summary) {
|
||||
replyCount += entry.summary.replyCount;
|
||||
if (entry.summary.lastReplyAt != null) {
|
||||
lastReplyAt = Math.max(lastReplyAt ?? 0, entry.summary.lastReplyAt);
|
||||
}
|
||||
|
||||
const summaryTimestamp =
|
||||
entry.summary.lastReplyAt ?? entry.message.createdAt;
|
||||
for (const participant of entry.summary.participants) {
|
||||
addParticipantCandidate(participant, summaryTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replyCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[] = [];
|
||||
for (const candidate of [...participantCandidates].sort((left, right) => {
|
||||
if (left.timestamp !== right.timestamp) {
|
||||
return right.timestamp - left.timestamp;
|
||||
}
|
||||
|
||||
return right.index - left.index;
|
||||
})) {
|
||||
if (
|
||||
recentParticipantsNewestFirst.some(
|
||||
(participant) => participant.id === candidate.participant.id,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
recentParticipantsNewestFirst.push(candidate.participant);
|
||||
if (recentParticipantsNewestFirst.length >= MAX_SUMMARY_PARTICIPANTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
threadHeadId,
|
||||
replyCount,
|
||||
lastReplyAt,
|
||||
participants: recentParticipantsNewestFirst.reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasNestedThreadBranches(entries: readonly MainTimelineEntry[]) {
|
||||
return entries.some(
|
||||
(entry) => entry.message.depth > 1 || entry.summary !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function appendExpandedReplies(params: {
|
||||
entries: MainTimelineEntry[];
|
||||
parentId: string;
|
||||
@@ -236,15 +333,15 @@ function appendExpandedReplies(params: {
|
||||
const directReplies = directChildrenByParentId.get(parentId) ?? [];
|
||||
|
||||
for (const reply of directReplies) {
|
||||
const isExpanded = expandedReplyIds.has(reply.id);
|
||||
entries.push({
|
||||
message: normalizeInlineReplyMessage(reply, depth),
|
||||
summary: buildSummaryForDirectReplies(
|
||||
reply.id,
|
||||
descendantStatsByMessageId,
|
||||
),
|
||||
summary: isExpanded
|
||||
? null
|
||||
: buildSummaryForDirectReplies(reply.id, descendantStatsByMessageId),
|
||||
});
|
||||
|
||||
if (expandedReplyIds.has(reply.id)) {
|
||||
if (isExpanded) {
|
||||
appendExpandedReplies({
|
||||
entries,
|
||||
parentId: reply.id,
|
||||
@@ -274,7 +371,7 @@ function buildVisibleThreadReplies(params: {
|
||||
appendExpandedReplies({
|
||||
entries,
|
||||
parentId: openThreadHeadId,
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
directChildrenByParentId,
|
||||
descendantStatsByMessageId,
|
||||
expandedReplyIds,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getThreadReplyAvatarCenterPx,
|
||||
getThreadReplyAvatarCenterYPx,
|
||||
getThreadReplyConnectorLayout,
|
||||
getThreadReplyDescendantRailStartYPx,
|
||||
getThreadReplyIndentPx,
|
||||
} from "./threadTreeLayout.ts";
|
||||
|
||||
test("getThreadReplyIndentPx aligns child avatars to parent text columns", () => {
|
||||
assert.equal(getThreadReplyIndentPx(0), 0);
|
||||
assert.equal(getThreadReplyIndentPx(1), 50);
|
||||
assert.equal(getThreadReplyIndentPx(2), 100);
|
||||
assert.equal(getThreadReplyIndentPx(3), 150);
|
||||
});
|
||||
|
||||
test("avatar center helpers expose the rail anchor points", () => {
|
||||
assert.equal(getThreadReplyAvatarCenterPx(0), 32);
|
||||
assert.equal(getThreadReplyAvatarCenterPx(1), 82);
|
||||
assert.equal(getThreadReplyAvatarCenterYPx(), 28);
|
||||
assert.equal(getThreadReplyDescendantRailStartYPx(), 52);
|
||||
});
|
||||
|
||||
test("getThreadReplyConnectorLayout stops before the child avatar edge", () => {
|
||||
assert.equal(getThreadReplyConnectorLayout(0), null);
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(1), {
|
||||
childOffsetPx: 82,
|
||||
heightPx: 28,
|
||||
parentOffsetPx: 32,
|
||||
widthPx: 26,
|
||||
});
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(2), {
|
||||
childOffsetPx: 132,
|
||||
heightPx: 28,
|
||||
parentOffsetPx: 82,
|
||||
widthPx: 26,
|
||||
});
|
||||
});
|
||||
|
||||
test("getThreadReplyConnectorLayout clamps very deep replies to the visible rail", () => {
|
||||
assert.deepEqual(getThreadReplyConnectorLayout(99), {
|
||||
childOffsetPx: 332,
|
||||
heightPx: 28,
|
||||
parentOffsetPx: 282,
|
||||
widthPx: 26,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const THREAD_REPLY_MAX_VISIBLE_DEPTH = 6;
|
||||
|
||||
const THREAD_REPLY_AVATAR_SIZE_PX = 40;
|
||||
const THREAD_REPLY_ROW_CONTENT_INSET_PX = 12;
|
||||
const THREAD_REPLY_ROW_CONTENT_GAP_PX = 10;
|
||||
const THREAD_REPLY_ROW_PADDING_TOP_PX = 8;
|
||||
const THREAD_REPLY_AVATAR_RADIUS_PX = THREAD_REPLY_AVATAR_SIZE_PX / 2;
|
||||
const THREAD_REPLY_AVATAR_LINE_GAP_PX = 4;
|
||||
|
||||
export const THREAD_REPLY_BODY_OFFSET_PX =
|
||||
THREAD_REPLY_ROW_CONTENT_INSET_PX +
|
||||
THREAD_REPLY_AVATAR_SIZE_PX +
|
||||
THREAD_REPLY_ROW_CONTENT_GAP_PX;
|
||||
export const THREAD_REPLY_ROOT_INDENT_PX =
|
||||
THREAD_REPLY_BODY_OFFSET_PX - THREAD_REPLY_ROW_CONTENT_INSET_PX;
|
||||
export const THREAD_REPLY_NESTED_INDENT_PX = THREAD_REPLY_ROOT_INDENT_PX;
|
||||
export const THREAD_REPLY_LINE_WIDTH_PX = 1.5;
|
||||
|
||||
const THREAD_REPLY_AVATAR_CENTER_OFFSET_PX =
|
||||
THREAD_REPLY_ROW_CONTENT_INSET_PX + THREAD_REPLY_AVATAR_SIZE_PX / 2;
|
||||
const THREAD_REPLY_AVATAR_CENTER_Y_PX =
|
||||
THREAD_REPLY_ROW_PADDING_TOP_PX + THREAD_REPLY_AVATAR_SIZE_PX / 2;
|
||||
|
||||
function clampVisibleDepth(depth: number) {
|
||||
return Math.min(Math.max(depth, 0), THREAD_REPLY_MAX_VISIBLE_DEPTH);
|
||||
}
|
||||
|
||||
export function getThreadReplyIndentPx(depth: number) {
|
||||
const visibleDepth = clampVisibleDepth(depth);
|
||||
return visibleDepth > 0
|
||||
? THREAD_REPLY_ROOT_INDENT_PX +
|
||||
(visibleDepth - 1) * THREAD_REPLY_NESTED_INDENT_PX
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function getThreadReplyAvatarCenterPx(depth: number) {
|
||||
return getThreadReplyIndentPx(depth) + THREAD_REPLY_AVATAR_CENTER_OFFSET_PX;
|
||||
}
|
||||
|
||||
export function getThreadReplyAvatarCenterYPx() {
|
||||
return THREAD_REPLY_AVATAR_CENTER_Y_PX;
|
||||
}
|
||||
|
||||
export function getThreadReplyDescendantRailStartYPx() {
|
||||
return (
|
||||
THREAD_REPLY_AVATAR_CENTER_Y_PX +
|
||||
THREAD_REPLY_AVATAR_RADIUS_PX +
|
||||
THREAD_REPLY_AVATAR_LINE_GAP_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function getThreadReplyConnectorLayout(depth: number) {
|
||||
const visibleDepth = clampVisibleDepth(depth);
|
||||
if (visibleDepth === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentOffsetPx = getThreadReplyAvatarCenterPx(visibleDepth - 1);
|
||||
const childOffsetPx = getThreadReplyAvatarCenterPx(visibleDepth);
|
||||
const childEdgeOffsetPx =
|
||||
childOffsetPx -
|
||||
THREAD_REPLY_AVATAR_RADIUS_PX -
|
||||
THREAD_REPLY_AVATAR_LINE_GAP_PX;
|
||||
|
||||
return {
|
||||
childOffsetPx,
|
||||
heightPx: THREAD_REPLY_AVATAR_CENTER_Y_PX,
|
||||
parentOffsetPx,
|
||||
widthPx: Math.max(0, childEdgeOffsetPx - parentOffsetPx),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import { X } from "lucide-react";
|
||||
import { CornerUpLeft, Pencil, X } from "lucide-react";
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
@@ -147,10 +147,7 @@ export function MessageComposer({
|
||||
const previousDraftKeyRef = React.useRef<string | null>(null);
|
||||
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
|
||||
effectiveDraftKeyRef.current = effectiveDraftKey;
|
||||
// Snapshot of composer state at the moment we enter edit mode (text body
|
||||
// + draft attachments) so the user's pre-edit work isn't lost when the
|
||||
// composer is hijacked for editing. Restored on edit-cancel/exit. `null`
|
||||
// while not in edit mode.
|
||||
// Snapshot composer state before edit mode so cancel can restore it.
|
||||
const preEditSnapshotRef = React.useRef<{
|
||||
content: string;
|
||||
pendingImeta: ImetaMedia[];
|
||||
@@ -828,9 +825,63 @@ export function MessageComposer({
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-5 bg-background"
|
||||
/>
|
||||
<div className="relative flex w-full flex-col gap-3">
|
||||
<div className="relative flex w-full flex-col gap-0">
|
||||
{editTarget ? (
|
||||
<div
|
||||
className="relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
</div>
|
||||
{onCancelEdit ? (
|
||||
<Button
|
||||
aria-label="Cancel edit"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelEdit}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : replyTarget ? (
|
||||
<div
|
||||
className="relative z-0 -mb-4 flex transform-gpu items-start gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
{replyTarget.body ? (
|
||||
<p className="truncate text-muted-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onCancelReply ? (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
|
||||
className="relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
|
||||
data-testid="message-composer"
|
||||
onDragEnter={ownsDropZone ? media.handleDragEnter : undefined}
|
||||
onDragLeave={ownsDropZone ? media.handleDragLeave : undefined}
|
||||
@@ -870,57 +921,6 @@ export function MessageComposer({
|
||||
selectedIndex={mentions.mentionSelectedIndex}
|
||||
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
|
||||
/>
|
||||
{editTarget ? (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2"
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">
|
||||
{editTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={onCancelEdit}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : replyTarget ? (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
{onCancelReply ? (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{media.uploadState.status === "error" ? (
|
||||
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Upload failed: {media.uploadState.message}
|
||||
|
||||
@@ -6,6 +6,14 @@ import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import {
|
||||
getThreadReplyAvatarCenterPx,
|
||||
getThreadReplyAvatarCenterYPx,
|
||||
getThreadReplyDescendantRailStartYPx,
|
||||
getThreadReplyConnectorLayout,
|
||||
getThreadReplyIndentPx,
|
||||
THREAD_REPLY_LINE_WIDTH_PX,
|
||||
} from "@/features/messages/lib/threadTreeLayout";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -27,18 +35,33 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
const MESSAGE_TEXT_OFFSET_PX = 54;
|
||||
const NESTED_REPLY_OFFSET_PX = 28;
|
||||
export type ThreadDepthGuideAction = {
|
||||
active?: boolean;
|
||||
depth: number;
|
||||
label: string;
|
||||
message: TimelineMessage;
|
||||
};
|
||||
|
||||
export const MessageRow = React.memo(
|
||||
function MessageRow({
|
||||
channelId = null,
|
||||
collapseDepthGuideActions,
|
||||
connectDescendants = false,
|
||||
depthGuideDepths,
|
||||
highlighted = false,
|
||||
highlightDescendantRail = false,
|
||||
highlightReplyConnector = false,
|
||||
highlightThreadLineDepths,
|
||||
hoverBackground = true,
|
||||
actionBarPlacement = "floating",
|
||||
collapseDescendantsLabel,
|
||||
isFollowingThread,
|
||||
layoutVariant = "default",
|
||||
message,
|
||||
onCollapseDepthGuide,
|
||||
onCollapseDepthGuideHoverChange,
|
||||
onCollapseDescendants,
|
||||
onCollapseDescendantsHoverChange,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
@@ -54,12 +77,29 @@ export const MessageRow = React.memo(
|
||||
}: {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
channelId?: string | null;
|
||||
collapseDepthGuideActions?: ReadonlyArray<ThreadDepthGuideAction>;
|
||||
connectDescendants?: boolean;
|
||||
depthGuideDepths?: ReadonlyArray<number>;
|
||||
highlighted?: boolean;
|
||||
highlightDescendantRail?: boolean;
|
||||
highlightReplyConnector?: boolean;
|
||||
highlightThreadLineDepths?: ReadonlyArray<number>;
|
||||
hoverBackground?: boolean;
|
||||
actionBarPlacement?: "floating" | "inside";
|
||||
collapseDescendantsLabel?: string;
|
||||
isFollowingThread?: boolean;
|
||||
layoutVariant?: "default" | "thread-reply";
|
||||
message: TimelineMessage;
|
||||
onCollapseDepthGuide?: (message: TimelineMessage) => void;
|
||||
onCollapseDepthGuideHoverChange?: (
|
||||
message: TimelineMessage,
|
||||
hovered: boolean,
|
||||
) => void;
|
||||
onCollapseDescendants?: (message: TimelineMessage) => void;
|
||||
onCollapseDescendantsHoverChange?: (
|
||||
message: TimelineMessage,
|
||||
hovered: boolean,
|
||||
) => void;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
@@ -142,24 +182,63 @@ export const MessageRow = React.memo(
|
||||
[channels],
|
||||
);
|
||||
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const indentPx =
|
||||
visibleDepth > 0
|
||||
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
|
||||
: 0;
|
||||
const depthGuideOffsets = React.useMemo(() => {
|
||||
if (visibleDepth === 0) {
|
||||
return [];
|
||||
const indentPx = getThreadReplyIndentPx(message.depth);
|
||||
const descendantGuideOffsetPx = connectDescendants
|
||||
? getThreadReplyAvatarCenterPx(message.depth)
|
||||
: null;
|
||||
const replyConnector = React.useMemo(() => {
|
||||
return getThreadReplyConnectorLayout(message.depth);
|
||||
}, [message.depth]);
|
||||
const depthGuideItems = React.useMemo(() => {
|
||||
const depths =
|
||||
depthGuideDepths ??
|
||||
Array.from({ length: message.depth }, (_, depth) => depth);
|
||||
|
||||
return depths.map((depth) => ({
|
||||
depth,
|
||||
offset: getThreadReplyAvatarCenterPx(depth),
|
||||
}));
|
||||
}, [depthGuideDepths, message.depth]);
|
||||
const handleCollapseDescendants = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCollapseDescendants?.(message);
|
||||
},
|
||||
[message, onCollapseDescendants],
|
||||
);
|
||||
const handleCollapseDescendantsHoverChange = React.useCallback(
|
||||
(hovered: boolean) => {
|
||||
onCollapseDescendantsHoverChange?.(message, hovered);
|
||||
},
|
||||
[message, onCollapseDescendantsHoverChange],
|
||||
);
|
||||
const handleCollapseDepthGuide = React.useCallback(
|
||||
(
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
targetMessage: TimelineMessage,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCollapseDepthGuide?.(targetMessage);
|
||||
},
|
||||
[onCollapseDepthGuide],
|
||||
);
|
||||
const handleCollapseDepthGuideHoverChange = React.useCallback(
|
||||
(targetMessage: TimelineMessage, hovered: boolean) => {
|
||||
onCollapseDepthGuideHoverChange?.(targetMessage, hovered);
|
||||
},
|
||||
[onCollapseDepthGuideHoverChange],
|
||||
);
|
||||
const collapseDepthGuideActionsByDepth = React.useMemo(() => {
|
||||
if (!collapseDepthGuideActions?.length) {
|
||||
return new Map<number, ThreadDepthGuideAction>();
|
||||
}
|
||||
|
||||
return Array.from({ length: visibleDepth }, (_, index) =>
|
||||
index === 0
|
||||
? MESSAGE_TEXT_OFFSET_PX / 2
|
||||
: MESSAGE_TEXT_OFFSET_PX +
|
||||
NESTED_REPLY_OFFSET_PX / 2 +
|
||||
(index - 1) * NESTED_REPLY_OFFSET_PX,
|
||||
return new Map(
|
||||
collapseDepthGuideActions.map((action) => [action.depth, action]),
|
||||
);
|
||||
}, [visibleDepth]);
|
||||
}, [collapseDepthGuideActions]);
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
|
||||
@@ -359,34 +438,163 @@ export const MessageRow = React.memo(
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{showDepthGuides && depthGuideOffsets.length > 0 ? (
|
||||
{showDepthGuides && depthGuideItems.length > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-0"
|
||||
aria-hidden={
|
||||
collapseDepthGuideActionsByDepth.size > 0 ? undefined : true
|
||||
}
|
||||
className={cn(
|
||||
"absolute left-0",
|
||||
collapseDepthGuideActionsByDepth.size === 0 &&
|
||||
"pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
bottom: `${-guideBleedPx}px`,
|
||||
top: `${-guideBleedPx}px`,
|
||||
}}
|
||||
>
|
||||
{depthGuideOffsets.map((offset, index) => (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 border-l border-border/70"
|
||||
key={`${message.id}-depth-guide-${offset}`}
|
||||
style={{
|
||||
left: `${offset}px`,
|
||||
opacity: index === depthGuideOffsets.length - 1 ? 0.9 : 0.55,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{depthGuideItems.map(({ depth, offset }) => {
|
||||
const collapseAction =
|
||||
collapseDepthGuideActionsByDepth.get(depth);
|
||||
const isHighlighted =
|
||||
Boolean(collapseAction?.active) ||
|
||||
Boolean(highlightThreadLineDepths?.includes(depth));
|
||||
const lineClassName = cn(
|
||||
"absolute bottom-0 left-1/2 top-0 border-l transition-[border-color]",
|
||||
isHighlighted
|
||||
? "border-primary"
|
||||
: "border-border group-hover/thread-guide:border-primary group-focus-visible/thread-guide:border-primary",
|
||||
);
|
||||
|
||||
if (collapseAction) {
|
||||
return (
|
||||
<button
|
||||
aria-label={collapseAction.label}
|
||||
className="group/thread-guide absolute bottom-0 top-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full focus-visible:outline-hidden"
|
||||
data-thread-head-id={collapseAction.message.id}
|
||||
data-testid="thread-collapse-guide"
|
||||
key={`${message.id}-depth-guide-${offset}`}
|
||||
onBlur={() =>
|
||||
handleCollapseDepthGuideHoverChange(
|
||||
collapseAction.message,
|
||||
false,
|
||||
)
|
||||
}
|
||||
onClick={(event) =>
|
||||
handleCollapseDepthGuide(event, collapseAction.message)
|
||||
}
|
||||
onFocus={() =>
|
||||
handleCollapseDepthGuideHoverChange(
|
||||
collapseAction.message,
|
||||
true,
|
||||
)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
handleCollapseDepthGuideHoverChange(
|
||||
collapseAction.message,
|
||||
true,
|
||||
)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
handleCollapseDepthGuideHoverChange(
|
||||
collapseAction.message,
|
||||
false,
|
||||
)
|
||||
}
|
||||
style={{ left: `${offset}px` }}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={lineClassName}
|
||||
style={{
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
|
||||
isHighlighted ? "border-primary" : "border-border",
|
||||
)}
|
||||
key={`${message.id}-depth-guide-${offset}`}
|
||||
style={{
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
left: `${offset}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{showDepthGuides && descendantGuideOffsetPx !== null ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 z-0 border-l transition-[border-color]",
|
||||
highlightDescendantRail ? "border-primary" : "border-border",
|
||||
)}
|
||||
style={{
|
||||
bottom: `${-guideBleedPx}px`,
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
left: `${descendantGuideOffsetPx}px`,
|
||||
top: `${getThreadReplyDescendantRailStartYPx()}px`,
|
||||
}}
|
||||
/>
|
||||
{onCollapseDescendants ? (
|
||||
<button
|
||||
aria-label={
|
||||
collapseDescendantsLabel ?? "Collapse replies to this message"
|
||||
}
|
||||
className="absolute bottom-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full p-0 focus-visible:outline-hidden"
|
||||
data-thread-head-id={message.id}
|
||||
data-testid="thread-collapse-rail"
|
||||
onBlur={() => handleCollapseDescendantsHoverChange(false)}
|
||||
onClick={handleCollapseDescendants}
|
||||
onFocus={() => handleCollapseDescendantsHoverChange(true)}
|
||||
onMouseEnter={() => handleCollapseDescendantsHoverChange(true)}
|
||||
onMouseLeave={() => handleCollapseDescendantsHoverChange(false)}
|
||||
style={{
|
||||
left: `${descendantGuideOffsetPx}px`,
|
||||
top: `${getThreadReplyAvatarCenterYPx()}px`,
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showDepthGuides && replyConnector ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 top-0 rounded-bl-2xl border-b border-l transition-[border-color]",
|
||||
highlightReplyConnector ? "border-primary" : "border-border",
|
||||
)}
|
||||
style={{
|
||||
borderBottomWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
height: `${replyConnector.heightPx + guideBleedPx}px`,
|
||||
left: `${replyConnector.parentOffsetPx}px`,
|
||||
top: `${-guideBleedPx}px`,
|
||||
width: `${replyConnector.widthPx}px`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative rounded-2xl py-2 transition-colors",
|
||||
"group/message relative z-10 rounded-2xl transition-colors",
|
||||
isThreadReplyLayout ? "py-1.5" : "py-2",
|
||||
hoverBackground
|
||||
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
|
||||
: "px-2",
|
||||
: isThreadReplyLayout
|
||||
? "mx-1 px-2"
|
||||
: "px-2",
|
||||
"flex items-start gap-2.5",
|
||||
hasActiveReminder ? "bg-blue-500/10" : "",
|
||||
highlighted
|
||||
@@ -520,10 +728,23 @@ export const MessageRow = React.memo(
|
||||
prev.message.tags === next.message.tags &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.message.personaDisplayName === next.message.personaDisplayName &&
|
||||
prev.collapseDepthGuideActions === next.collapseDepthGuideActions &&
|
||||
prev.collapseDescendantsLabel === next.collapseDescendantsLabel &&
|
||||
prev.connectDescendants === next.connectDescendants &&
|
||||
prev.depthGuideDepths === next.depthGuideDepths &&
|
||||
prev.highlightDescendantRail === next.highlightDescendantRail &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.highlightReplyConnector === next.highlightReplyConnector &&
|
||||
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
|
||||
prev.hoverBackground === next.hoverBackground &&
|
||||
prev.isFollowingThread === next.isFollowingThread &&
|
||||
prev.layoutVariant === next.layoutVariant &&
|
||||
prev.onCollapseDepthGuide === next.onCollapseDepthGuide &&
|
||||
prev.onCollapseDepthGuideHoverChange ===
|
||||
next.onCollapseDepthGuideHoverChange &&
|
||||
prev.onCollapseDescendants === next.onCollapseDescendants &&
|
||||
prev.onCollapseDescendantsHoverChange ===
|
||||
next.onCollapseDescendantsHoverChange &&
|
||||
prev.profiles === next.profiles &&
|
||||
prev.searchQuery === next.searchQuery &&
|
||||
prev.videoReviewContext === next.videoReviewContext,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown, ArrowLeft, X } from "lucide-react";
|
||||
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildThreadSummaryFromVisibleEntries,
|
||||
hasNestedThreadBranches,
|
||||
type MainTimelineEntry,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
@@ -26,7 +30,7 @@ import {
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
import { UnreadDivider } from "./UnreadDivider";
|
||||
@@ -41,7 +45,6 @@ type MessageThreadPanelProps = {
|
||||
channelName: string;
|
||||
currentPubkey?: string;
|
||||
disabled?: boolean;
|
||||
/** Event id of the first unread reply, or null/undefined if all read. */
|
||||
firstUnreadReplyId?: string | null;
|
||||
layout?: "standalone" | "split";
|
||||
editTarget?: {
|
||||
@@ -78,7 +81,7 @@ type MessageThreadPanelProps = {
|
||||
scrollTargetId: string | null;
|
||||
threadHead: TimelineMessage | null;
|
||||
threadReplies: MainTimelineEntry[];
|
||||
/** Subtree unread counts for collapsed summary rows, keyed by reply id. */
|
||||
threadUnreadCount?: number;
|
||||
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
|
||||
threadTypingPubkeys: string[];
|
||||
threadHeadVideoReviewContext?: VideoReviewContext;
|
||||
@@ -89,11 +92,11 @@ type MessageThreadPanelProps = {
|
||||
onUnfollowThread?: () => void;
|
||||
};
|
||||
|
||||
/** Stable empty reference used as the `useDeferredValue` initial value so the
|
||||
* first render when a thread opens stays light instead of blocking on the full
|
||||
* reply list. Must be module-level so its identity never changes. Mirrors
|
||||
* `EMPTY_MESSAGES` in MessageTimeline. */
|
||||
/** Stable `useDeferredValue` initial value; mirrors `EMPTY_MESSAGES`. */
|
||||
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
|
||||
const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
|
||||
const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
|
||||
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX = -2;
|
||||
|
||||
type MessageThreadPanelSkeletonProps = {
|
||||
isSinglePanelView?: boolean;
|
||||
@@ -113,6 +116,53 @@ function canManageMessage(
|
||||
);
|
||||
}
|
||||
|
||||
function hasLaterVisibleSibling(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
entryIndex: number,
|
||||
): boolean {
|
||||
const depth = entries[entryIndex]?.message.depth;
|
||||
if (depth == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = entryIndex + 1; index < entries.length; index += 1) {
|
||||
const nextDepth = entries[index].message.depth;
|
||||
if (nextDepth <= depth) {
|
||||
return nextDepth === depth;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getActiveContinuationDepths({
|
||||
ancestors,
|
||||
entries,
|
||||
index,
|
||||
message,
|
||||
}: {
|
||||
ancestors: readonly { index: number; message: TimelineMessage }[];
|
||||
entries: readonly MainTimelineEntry[];
|
||||
index: number;
|
||||
message: TimelineMessage;
|
||||
}): number[] {
|
||||
const depths: number[] = [];
|
||||
|
||||
for (const ancestor of ancestors) {
|
||||
const childDepth = ancestor.message.depth + 1;
|
||||
const pathChild =
|
||||
message.depth === childDepth
|
||||
? { index, message }
|
||||
: ancestors.find((candidate) => candidate.message.depth === childDepth);
|
||||
|
||||
if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) {
|
||||
depths.push(ancestor.message.depth);
|
||||
}
|
||||
}
|
||||
|
||||
return depths;
|
||||
}
|
||||
|
||||
function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) {
|
||||
return (
|
||||
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
|
||||
@@ -140,7 +190,12 @@ function ThreadComposerSkeleton() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="pointer-events-auto">
|
||||
<div className="relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 bg-transparent pb-2 pt-0",
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
)}
|
||||
>
|
||||
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
|
||||
<Skeleton className="h-5 w-48 max-w-full" />
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
@@ -150,7 +205,12 @@ function ThreadComposerSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-1 h-7 bg-background px-4 pb-1 pt-0 sm:px-6" />
|
||||
<div
|
||||
className={cn(
|
||||
"-mt-1 h-7 bg-background pb-1 pt-0",
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,10 +266,18 @@ export function MessageThreadPanelSkeleton({
|
||||
)}
|
||||
data-testid="message-thread-loading"
|
||||
>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head-loading">
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head-loading"
|
||||
>
|
||||
<ThreadMessageSkeleton isHead />
|
||||
</div>
|
||||
<div className="space-y-2.5 px-3 pb-3 pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2.5 pb-3 pt-1",
|
||||
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
|
||||
)}
|
||||
>
|
||||
<ThreadMessageSkeleton />
|
||||
<ThreadMessageSkeleton />
|
||||
<div className="ml-[58px] flex items-center gap-1.5 pt-0.5">
|
||||
@@ -300,6 +368,7 @@ export function MessageThreadPanel({
|
||||
threadHead,
|
||||
threadHeadVideoReviewContext,
|
||||
threadReplies,
|
||||
threadUnreadCount,
|
||||
threadReplyUnreadCounts,
|
||||
threadTypingPubkeys,
|
||||
toolbarExtraActions,
|
||||
@@ -312,9 +381,16 @@ export function MessageThreadPanel({
|
||||
// only to satisfy the hook's required ref contract.
|
||||
const threadTopSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [collapsedThreadHeadId, setCollapsedThreadHeadId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const isFloatingOverlay = isOverlay && !isSinglePanelView;
|
||||
const isSplitLayout = layout === "split";
|
||||
const threadHeadId = threadHead?.id ?? null;
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
useComposerHeightPadding(
|
||||
threadBodyRef,
|
||||
@@ -322,7 +398,41 @@ export function MessageThreadPanel({
|
||||
isSinglePanelView,
|
||||
);
|
||||
|
||||
const threadHeadId = threadHead?.id ?? null;
|
||||
const collapseThreadHeadReplies = React.useCallback(() => {
|
||||
if (!threadHeadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHoveredCollapseBranchId(null);
|
||||
setCollapsedThreadHeadId(threadHeadId);
|
||||
}, [threadHeadId]);
|
||||
const expandThreadHeadReplies = React.useCallback(() => {
|
||||
setHoveredCollapseBranchId(null);
|
||||
setCollapsedThreadHeadId(null);
|
||||
}, []);
|
||||
const handleCollapseBranchHoverChange = React.useCallback(
|
||||
(message: TimelineMessage, hovered: boolean) => {
|
||||
setHoveredCollapseBranchId((current) => {
|
||||
if (hovered) {
|
||||
return message.id;
|
||||
}
|
||||
|
||||
return current === message.id ? null : current;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleCollapseDepthGuide = React.useCallback(
|
||||
(message: TimelineMessage) => {
|
||||
if (message.id === threadHeadId) {
|
||||
collapseThreadHeadReplies();
|
||||
return;
|
||||
}
|
||||
|
||||
onExpandReplies(message);
|
||||
},
|
||||
[collapseThreadHeadReplies, onExpandReplies, threadHeadId],
|
||||
);
|
||||
|
||||
const composerReplyTarget =
|
||||
replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id
|
||||
@@ -333,22 +443,28 @@ export function MessageThreadPanel({
|
||||
}
|
||||
: null;
|
||||
|
||||
// The thread side pane renders its reply list straight into heavy
|
||||
// `react-markdown` rows (`MessageRow`), so opening a deep thread would block
|
||||
// the main thread and the OS would show the busy cursor. Gate the reply render
|
||||
// behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on
|
||||
// thread-open light; the heavy list streams in on a deferred, interruptible
|
||||
// commit. We deliberately drive BOTH the scroll manager and the rendered list
|
||||
// off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM
|
||||
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
|
||||
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
|
||||
// this no-tearing guarantee for free by routing through the same
|
||||
// `useAnchoredScroll` primitive as the main timeline.
|
||||
const deferredThreadReplies = React.useDeferredValue(
|
||||
threadReplies,
|
||||
EMPTY_THREAD_REPLIES,
|
||||
);
|
||||
const isRepliesPending = deferredThreadReplies !== threadReplies;
|
||||
const scrollTargetIsVisibleReply = React.useMemo(
|
||||
() =>
|
||||
scrollTargetId !== null &&
|
||||
scrollTargetId !== threadHeadId &&
|
||||
deferredThreadReplies.some(
|
||||
(entry) => entry.message.id === scrollTargetId,
|
||||
),
|
||||
[deferredThreadReplies, scrollTargetId, threadHeadId],
|
||||
);
|
||||
const isThreadHeadRepliesCollapsed =
|
||||
collapsedThreadHeadId === threadHeadId && !scrollTargetIsVisibleReply;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (scrollTargetIsVisibleReply && collapsedThreadHeadId === threadHeadId) {
|
||||
setCollapsedThreadHeadId(null);
|
||||
}
|
||||
}, [collapsedThreadHeadId, scrollTargetIsVisibleReply, threadHeadId]);
|
||||
|
||||
// Which of the three states the reply region paints this frame. Delegated to
|
||||
// a pure helper so the "don't flash empty over an incoming list" rule is
|
||||
@@ -357,17 +473,129 @@ export function MessageThreadPanel({
|
||||
deferredThreadReplies.length,
|
||||
threadReplies.length,
|
||||
);
|
||||
const threadHeadSummary = React.useMemo(() => {
|
||||
if (!threadHeadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildThreadSummaryFromVisibleEntries(
|
||||
threadHeadId,
|
||||
deferredThreadReplies,
|
||||
);
|
||||
}, [deferredThreadReplies, threadHeadId]);
|
||||
const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed
|
||||
? threadHeadSummary
|
||||
: null;
|
||||
|
||||
const threadMessages = React.useMemo(
|
||||
() => deferredThreadReplies.map((entry) => entry.message),
|
||||
[deferredThreadReplies],
|
||||
);
|
||||
const shouldShowThreadBranchGuides = React.useMemo(
|
||||
() => hasNestedThreadBranches(deferredThreadReplies),
|
||||
[deferredThreadReplies],
|
||||
);
|
||||
const highlightedBranch = React.useMemo(() => {
|
||||
if (!hoveredCollapseBranchId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hoveredCollapseBranchId === threadHeadId) {
|
||||
return {
|
||||
depth: 0,
|
||||
endIndex: deferredThreadReplies.length - 1,
|
||||
id: hoveredCollapseBranchId,
|
||||
startIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
const startIndex = deferredThreadReplies.findIndex(
|
||||
(entry) => entry.message.id === hoveredCollapseBranchId,
|
||||
);
|
||||
if (startIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const depth = deferredThreadReplies[startIndex].message.depth;
|
||||
let endIndex = startIndex;
|
||||
while (
|
||||
endIndex + 1 < deferredThreadReplies.length &&
|
||||
deferredThreadReplies[endIndex + 1].message.depth > depth
|
||||
) {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
depth,
|
||||
endIndex,
|
||||
id: hoveredCollapseBranchId,
|
||||
startIndex,
|
||||
};
|
||||
}, [deferredThreadReplies, hoveredCollapseBranchId, threadHeadId]);
|
||||
const threadReplyRenderItems = React.useMemo(() => {
|
||||
if (!threadHead) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ancestorStack: { index: number; message: TimelineMessage }[] = [
|
||||
{ index: -1, message: threadHead },
|
||||
];
|
||||
|
||||
return deferredThreadReplies.map((entry, index) => {
|
||||
while (
|
||||
ancestorStack.length > 0 &&
|
||||
ancestorStack[ancestorStack.length - 1].message.depth >=
|
||||
entry.message.depth
|
||||
) {
|
||||
ancestorStack.pop();
|
||||
}
|
||||
|
||||
const ancestors = [...ancestorStack];
|
||||
const continuationDepths = getActiveContinuationDepths({
|
||||
ancestors,
|
||||
entries: deferredThreadReplies,
|
||||
index,
|
||||
message: entry.message,
|
||||
});
|
||||
const collapseDepthGuideAncestors = ancestors.filter((ancestor) =>
|
||||
continuationDepths.includes(ancestor.message.depth),
|
||||
);
|
||||
const collapseDepthGuideActions: ThreadDepthGuideAction[] | undefined =
|
||||
collapseDepthGuideAncestors.length > 0
|
||||
? collapseDepthGuideAncestors.map((ancestor) => ({
|
||||
active:
|
||||
hoveredCollapseBranchId === ancestor.message.id &&
|
||||
entry.message.depth === ancestor.message.depth + 1,
|
||||
depth: ancestor.message.depth,
|
||||
label:
|
||||
ancestor.message.id === threadHead.id
|
||||
? "Collapse thread"
|
||||
: "Collapse replies",
|
||||
message: ancestor.message,
|
||||
}))
|
||||
: undefined;
|
||||
const nextEntry = deferredThreadReplies[index + 1];
|
||||
const connectsToVisibleChild =
|
||||
nextEntry != null && nextEntry.message.depth > entry.message.depth;
|
||||
|
||||
if (connectsToVisibleChild && !entry.summary) {
|
||||
ancestorStack.push({ index, message: entry.message });
|
||||
}
|
||||
|
||||
return {
|
||||
collapseDepthGuideActions,
|
||||
connectsToVisibleChild,
|
||||
continuationDepths,
|
||||
entry,
|
||||
index,
|
||||
};
|
||||
});
|
||||
}, [deferredThreadReplies, hoveredCollapseBranchId, threadHead]);
|
||||
|
||||
const { isAtBottom, newMessageCount, onScroll, scrollToBottom } =
|
||||
useAnchoredScroll({
|
||||
channelId: threadHeadId,
|
||||
contentRef: threadContentRef,
|
||||
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
|
||||
isLoading: repliesRenderState === "pending",
|
||||
messages: threadMessages,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
@@ -393,15 +621,35 @@ export function MessageThreadPanel({
|
||||
>
|
||||
<div ref={threadContentRef}>
|
||||
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head"
|
||||
>
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
actionBarPlacement="inside"
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
collapseDescendantsLabel="Collapse thread"
|
||||
connectDescendants={
|
||||
shouldShowThreadBranchGuides &&
|
||||
!isThreadHeadRepliesCollapsed &&
|
||||
deferredThreadReplies.length > 0
|
||||
}
|
||||
highlightDescendantRail={
|
||||
shouldShowThreadBranchGuides &&
|
||||
!isThreadHeadRepliesCollapsed &&
|
||||
highlightedBranch?.id === threadHead.id
|
||||
}
|
||||
isFollowingThread={isFollowingThread}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onCollapseDescendants={
|
||||
isThreadHeadRepliesCollapsed
|
||||
? undefined
|
||||
: collapseThreadHeadReplies
|
||||
}
|
||||
onCollapseDescendantsHoverChange={handleCollapseBranchHoverChange}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
@@ -421,67 +669,163 @@ export function MessageThreadPanel({
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
videoReviewContext={threadHeadVideoReviewContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
||||
data-testid="message-thread-replies"
|
||||
>
|
||||
{repliesRenderState === "list" ? (
|
||||
<div
|
||||
className="space-y-2.5"
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
>
|
||||
{deferredThreadReplies.map((entry, index) => {
|
||||
const showUnreadDivider =
|
||||
index > 0 && entry.message.id === firstUnreadReplyId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
entry.summary &&
|
||||
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
key={entry.message.renderKey ?? entry.message.id}
|
||||
>
|
||||
{showUnreadDivider ? <UnreadDivider /> : null}
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
hoverBackground={!entry.summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(entry.message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
visibleThreadHeadSummary ? (
|
||||
<div
|
||||
className="space-y-0"
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
>
|
||||
<MessageThreadSummaryRow
|
||||
depth={threadHead.depth}
|
||||
message={threadHead}
|
||||
onOpenThread={expandThreadHeadReplies}
|
||||
summary={visibleThreadHeadSummary}
|
||||
summaryIndentOffsetPx={THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX}
|
||||
unreadCount={threadUnreadCount}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-0"
|
||||
data-render-pending={isRepliesPending ? "true" : undefined}
|
||||
>
|
||||
{threadReplyRenderItems.map((item) => {
|
||||
const {
|
||||
collapseDepthGuideActions,
|
||||
connectsToVisibleChild,
|
||||
continuationDepths,
|
||||
entry,
|
||||
index,
|
||||
} = item;
|
||||
const showUnreadDivider =
|
||||
index > 0 && entry.message.id === firstUnreadReplyId;
|
||||
const isHighlightedBranchOwner =
|
||||
highlightedBranch?.id === entry.message.id;
|
||||
const isInsideHighlightedBranch =
|
||||
highlightedBranch != null &&
|
||||
index > highlightedBranch.startIndex &&
|
||||
index <= highlightedBranch.endIndex;
|
||||
const isDirectChildOfHighlightedBranch =
|
||||
isInsideHighlightedBranch &&
|
||||
highlightedBranch != null &&
|
||||
index > highlightedBranch.startIndex &&
|
||||
index <= highlightedBranch.endIndex &&
|
||||
entry.message.depth === highlightedBranch.depth + 1;
|
||||
const highlightedLineDepths =
|
||||
shouldShowThreadBranchGuides &&
|
||||
isInsideHighlightedBranch &&
|
||||
highlightedBranch
|
||||
? [highlightedBranch.depth]
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0",
|
||||
entry.summary &&
|
||||
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
key={entry.message.renderKey ?? entry.message.id}
|
||||
>
|
||||
{showUnreadDivider ? <UnreadDivider /> : null}
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
collapseDepthGuideActions={collapseDepthGuideActions}
|
||||
collapseDescendantsLabel="Collapse replies"
|
||||
connectDescendants={
|
||||
shouldShowThreadBranchGuides && connectsToVisibleChild
|
||||
}
|
||||
depthGuideDepths={
|
||||
shouldShowThreadBranchGuides
|
||||
? continuationDepths
|
||||
: undefined
|
||||
}
|
||||
highlightDescendantRail={
|
||||
shouldShowThreadBranchGuides &&
|
||||
isHighlightedBranchOwner &&
|
||||
connectsToVisibleChild
|
||||
}
|
||||
highlightReplyConnector={
|
||||
shouldShowThreadBranchGuides &&
|
||||
isDirectChildOfHighlightedBranch
|
||||
}
|
||||
highlightThreadLineDepths={highlightedLineDepths}
|
||||
hoverBackground={!entry.summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
unreadCount={threadReplyUnreadCounts?.get(
|
||||
entry.message.id,
|
||||
)}
|
||||
onCollapseDepthGuide={handleCollapseDepthGuide}
|
||||
onCollapseDepthGuideHoverChange={
|
||||
handleCollapseBranchHoverChange
|
||||
}
|
||||
onCollapseDescendants={
|
||||
shouldShowThreadBranchGuides &&
|
||||
connectsToVisibleChild &&
|
||||
!entry.summary
|
||||
? onExpandReplies
|
||||
: undefined
|
||||
}
|
||||
onCollapseDescendantsHoverChange={
|
||||
handleCollapseBranchHoverChange
|
||||
}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{entry.summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
collapseDepthGuideActions={collapseDepthGuideActions}
|
||||
depth={entry.message.depth}
|
||||
depthGuideDepths={
|
||||
shouldShowThreadBranchGuides
|
||||
? continuationDepths
|
||||
: undefined
|
||||
}
|
||||
highlightThreadLineDepths={highlightedLineDepths}
|
||||
message={entry.message}
|
||||
onCollapseDepthGuide={handleCollapseDepthGuide}
|
||||
onCollapseDepthGuideHoverChange={
|
||||
handleCollapseBranchHoverChange
|
||||
}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
summaryIndentOffsetPx={
|
||||
THREAD_PANEL_SUMMARY_INDENT_OFFSET_PX
|
||||
}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
unreadCount={threadReplyUnreadCounts?.get(
|
||||
entry.message.id,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : repliesRenderState === "empty" ? (
|
||||
// Only show the empty state when the thread is GENUINELY empty.
|
||||
// Keying off `deferredThreadReplies` would flash "No replies" for a
|
||||
@@ -532,6 +876,7 @@ export function MessageThreadPanel({
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channel?.channelType ?? null}
|
||||
containerClassName={THREAD_PANEL_COMPOSER_GUTTER_CLASS}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
editTarget={editTarget}
|
||||
@@ -547,7 +892,12 @@ export function MessageThreadPanel({
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
/>
|
||||
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
|
||||
<div
|
||||
className={cn(
|
||||
"-mt-1 h-7 bg-background pb-1 pt-0",
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
|
||||
{toolbarExtraActions ? (
|
||||
<div className="shrink-0">{toolbarExtraActions}</div>
|
||||
|
||||
@@ -3,13 +3,17 @@ import type {
|
||||
TimelineThreadSummaryParticipant,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { ThreadDepthGuideAction } from "@/features/messages/ui/MessageRow";
|
||||
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
getThreadReplyAvatarCenterPx,
|
||||
getThreadReplyIndentPx,
|
||||
THREAD_REPLY_BODY_OFFSET_PX,
|
||||
THREAD_REPLY_LINE_WIDTH_PX,
|
||||
} from "@/features/messages/lib/threadTreeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
const MESSAGE_TEXT_OFFSET_PX = 54;
|
||||
const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX;
|
||||
const NESTED_REPLY_OFFSET_PX = 28;
|
||||
|
||||
function ParticipantAvatar({
|
||||
participant,
|
||||
index,
|
||||
@@ -43,59 +47,145 @@ function ParticipantAvatar({
|
||||
}
|
||||
|
||||
export function MessageThreadSummaryRow({
|
||||
collapseDepthGuideActions,
|
||||
depth = 0,
|
||||
depthGuideDepths,
|
||||
highlightThreadLineDepths,
|
||||
message,
|
||||
onCollapseDepthGuide,
|
||||
onCollapseDepthGuideHoverChange,
|
||||
onOpenThread,
|
||||
showDepthGuides = true,
|
||||
summary,
|
||||
summaryIndentOffsetPx = 0,
|
||||
unreadCount,
|
||||
}: {
|
||||
collapseDepthGuideActions?: ReadonlyArray<ThreadDepthGuideAction>;
|
||||
depth?: number;
|
||||
depthGuideDepths?: ReadonlyArray<number>;
|
||||
highlightThreadLineDepths?: ReadonlyArray<number>;
|
||||
message: TimelineMessage;
|
||||
onCollapseDepthGuide?: (message: TimelineMessage) => void;
|
||||
onCollapseDepthGuideHoverChange?: (
|
||||
message: TimelineMessage,
|
||||
hovered: boolean,
|
||||
) => void;
|
||||
onOpenThread: (message: TimelineMessage) => void;
|
||||
showDepthGuides?: boolean;
|
||||
summary: TimelineThreadSummary;
|
||||
summaryIndentOffsetPx?: number;
|
||||
unreadCount?: number;
|
||||
}) {
|
||||
const visibleDepth = Math.min(Math.max(depth, 0), 6);
|
||||
const indentPx =
|
||||
visibleDepth > 0
|
||||
? MESSAGE_TEXT_OFFSET_PX + (visibleDepth - 1) * NESTED_REPLY_OFFSET_PX
|
||||
: 0;
|
||||
const marginLeftPx = indentPx + MESSAGE_BODY_OFFSET_PX;
|
||||
const indentPx = getThreadReplyIndentPx(depth);
|
||||
const marginLeftPx =
|
||||
indentPx + THREAD_REPLY_BODY_OFFSET_PX + summaryIndentOffsetPx;
|
||||
const replyLabel = summary.replyCount === 1 ? "reply" : "replies";
|
||||
const summaryAriaLabel = summary.lastReplyAt
|
||||
? `View thread with ${summary.replyCount} ${replyLabel}, last reply ${formatThreadSummaryLastReplyTime(summary.lastReplyAt)}`
|
||||
: `View thread with ${summary.replyCount} ${replyLabel}`;
|
||||
const depthGuideOffsets =
|
||||
visibleDepth === 0
|
||||
? []
|
||||
: Array.from({ length: visibleDepth }, (_, index) =>
|
||||
index === 0
|
||||
? MESSAGE_TEXT_OFFSET_PX / 2
|
||||
: MESSAGE_TEXT_OFFSET_PX +
|
||||
NESTED_REPLY_OFFSET_PX / 2 +
|
||||
(index - 1) * NESTED_REPLY_OFFSET_PX,
|
||||
);
|
||||
const guideDepths = depthGuideDepths
|
||||
? [...depthGuideDepths]
|
||||
: Array.from({ length: depth }, (_, index) => index);
|
||||
const depthGuideItems = guideDepths.map((guideDepth) => ({
|
||||
depth: guideDepth,
|
||||
offset: getThreadReplyAvatarCenterPx(guideDepth),
|
||||
}));
|
||||
const collapseDepthGuideActionsByDepth = new Map(
|
||||
collapseDepthGuideActions?.map((action) => [action.depth, action]) ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative pb-1 pt-0.5">
|
||||
{showDepthGuides && depthGuideOffsets.length > 0 ? (
|
||||
{showDepthGuides && depthGuideItems.length > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-0"
|
||||
aria-hidden={
|
||||
collapseDepthGuideActionsByDepth.size > 0 ? undefined : true
|
||||
}
|
||||
className={cn(
|
||||
"absolute left-0",
|
||||
collapseDepthGuideActionsByDepth.size === 0 &&
|
||||
"pointer-events-none",
|
||||
)}
|
||||
style={{ bottom: "-4px", top: "-4px" }}
|
||||
>
|
||||
{depthGuideOffsets.map((offset, index) => (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 border-l border-border/70"
|
||||
key={`${message.id}-summary-depth-guide-${offset}`}
|
||||
style={{
|
||||
left: `${offset}px`,
|
||||
opacity: index === depthGuideOffsets.length - 1 ? 0.9 : 0.55,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{depthGuideItems.map(({ depth: guideDepth, offset }) => {
|
||||
const collapseAction =
|
||||
collapseDepthGuideActionsByDepth.get(guideDepth);
|
||||
const isHighlighted =
|
||||
Boolean(collapseAction?.active) ||
|
||||
Boolean(highlightThreadLineDepths?.includes(guideDepth));
|
||||
const lineClassName = cn(
|
||||
"absolute bottom-0 left-1/2 top-0 border-l transition-[border-color]",
|
||||
isHighlighted
|
||||
? "border-primary"
|
||||
: "border-border group-hover/thread-guide:border-primary group-focus-visible/thread-guide:border-primary",
|
||||
);
|
||||
|
||||
if (collapseAction) {
|
||||
return (
|
||||
<button
|
||||
aria-label={collapseAction.label}
|
||||
className="group/thread-guide absolute bottom-0 top-0 z-20 w-5 -translate-x-1/2 cursor-pointer rounded-full focus-visible:outline-hidden"
|
||||
data-thread-head-id={collapseAction.message.id}
|
||||
data-testid="thread-collapse-guide"
|
||||
key={`${message.id}-summary-depth-guide-${offset}`}
|
||||
onBlur={() =>
|
||||
onCollapseDepthGuideHoverChange?.(
|
||||
collapseAction.message,
|
||||
false,
|
||||
)
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCollapseDepthGuide?.(collapseAction.message);
|
||||
}}
|
||||
onFocus={() =>
|
||||
onCollapseDepthGuideHoverChange?.(
|
||||
collapseAction.message,
|
||||
true,
|
||||
)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
onCollapseDepthGuideHoverChange?.(
|
||||
collapseAction.message,
|
||||
true,
|
||||
)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
onCollapseDepthGuideHoverChange?.(
|
||||
collapseAction.message,
|
||||
false,
|
||||
)
|
||||
}
|
||||
style={{ left: `${offset}px` }}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={lineClassName}
|
||||
style={{
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 top-0 border-l transition-[border-color]",
|
||||
isHighlighted ? "border-primary" : "border-border",
|
||||
)}
|
||||
key={`${message.id}-summary-depth-guide-${offset}`}
|
||||
style={{
|
||||
borderLeftWidth: `${THREAD_REPLY_LINE_WIDTH_PX}px`,
|
||||
left: `${offset}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -496,7 +496,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
throw new Error("Expected root message row to have a data-message-id.");
|
||||
}
|
||||
const rootSummaryRow = timeline.locator(
|
||||
`[data-thread-head-id="${rootMessageId}"]`,
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="${rootMessageId}"]`,
|
||||
);
|
||||
|
||||
await rootMessage.hover();
|
||||
@@ -668,10 +668,13 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
await expect(nestedReplyFromBobRow).toBeVisible();
|
||||
|
||||
const firstReplySummaryRow = threadReplies.locator(
|
||||
`[data-thread-head-id="${firstReplyId}"]`,
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`,
|
||||
);
|
||||
await expect(firstReplySummaryRow).toHaveCount(1);
|
||||
await expect(firstReplySummaryRow).toContainText("2 replies");
|
||||
await expect(firstReplySummaryRow).toHaveCount(0);
|
||||
const firstReplyBranchRail = threadReplies.locator(
|
||||
`[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`,
|
||||
);
|
||||
await expect(firstReplyBranchRail).toHaveCount(1);
|
||||
|
||||
await expect(rootSummaryRow).toContainText("18 replies");
|
||||
await expect(
|
||||
@@ -691,7 +694,9 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
|
||||
await expectThreadReplyUnobscured(nestedReplyRow);
|
||||
|
||||
await firstReplySummaryRow.click();
|
||||
await firstReplyBranchRail.click();
|
||||
await expect(firstReplySummaryRow).toHaveCount(1);
|
||||
await expect(firstReplySummaryRow).toContainText("2 replies");
|
||||
await expect(
|
||||
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
|
||||
).toHaveCount(0);
|
||||
@@ -968,7 +973,8 @@ test("ArrowUp in an empty composer edits your last message right after sending",
|
||||
// Edit mode is entered for the just-sent message.
|
||||
const editBanner = page.getByTestId("edit-target");
|
||||
await expect(editBanner).toBeVisible();
|
||||
await expect(editBanner).toContainText(message);
|
||||
await expect(editBanner).toContainText("Editing message");
|
||||
await expect(editBanner).not.toContainText(message);
|
||||
await expect(input).toHaveText(message);
|
||||
});
|
||||
|
||||
@@ -1034,7 +1040,8 @@ test("ArrowUp edits your last thread reply right after sending it", async ({
|
||||
|
||||
const editBanner = threadPanel.getByTestId("edit-target");
|
||||
await expect(editBanner).toBeVisible();
|
||||
await expect(editBanner).toContainText(reply);
|
||||
await expect(editBanner).toContainText("Editing message");
|
||||
await expect(editBanner).not.toContainText(reply);
|
||||
await expect(threadInput).toHaveText(reply);
|
||||
});
|
||||
|
||||
|
||||
@@ -133,6 +133,16 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
await expect(threadSummary).toBeVisible();
|
||||
await threadSummary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-thread-panel")
|
||||
.getByTestId("thread-collapse-rail"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-thread-panel")
|
||||
.getByTestId("thread-collapse-guide"),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("message-thread-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
|
||||
|
||||
@@ -360,6 +370,46 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/04-thread-deep-nested-unread.png`,
|
||||
});
|
||||
|
||||
await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded();
|
||||
await page
|
||||
.locator(
|
||||
`[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`,
|
||||
)
|
||||
.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(replies).toHaveCount(0);
|
||||
const rootStack = page
|
||||
.getByTestId("message-thread-replies")
|
||||
.locator(
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]`,
|
||||
);
|
||||
await expect(rootStack).toBeVisible();
|
||||
await expect(rootStack).toContainText("6 replies");
|
||||
await rootStack.click();
|
||||
await expect(replies).toHaveCount(6);
|
||||
|
||||
await page
|
||||
.locator(
|
||||
`[data-testid="thread-collapse-guide"][data-thread-head-id="${r1.id}"]`,
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
await expect(replies).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-thread-replies")
|
||||
.locator(
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="${r1.id}"]`,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-thread-replies")
|
||||
.locator(
|
||||
`[data-testid="thread-collapse-rail"][data-thread-head-id="${r1.id}"]`,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("05-thread-in-panel-subtree-badge", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user