mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add focused thread mode (#2108)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
"**/boot-splash.spec.ts",
|
||||
"**/thread-reply-anchor-roleplay.spec.ts",
|
||||
"**/threadpane-ultrawide.spec.ts",
|
||||
"**/thread-focus-mode.spec.ts",
|
||||
"**/animated-avatar.spec.ts",
|
||||
"**/reminders.spec.ts",
|
||||
"**/reminder-click-repro.spec.ts",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { hasActiveEscapeSurface } from "@/shared/hooks/escapeSurfaces";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
|
||||
export function useMarkAsReadShortcuts({
|
||||
@@ -23,6 +24,13 @@ export function useMarkAsReadShortcuts({
|
||||
if (event.key !== "Escape") return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (hasPrimaryShortcutModifier(event) || event.altKey) return;
|
||||
// A closable foreground surface (focus drawer, overlay panel,
|
||||
// single-panel thread — anything holding `useEscapeKey`) owns Escape
|
||||
// while open: Escape means "close that", never "mark the channel I
|
||||
// can't currently see as read". Window listeners fire in registration
|
||||
// order, so without this yield the app-mount listener would win the
|
||||
// key over the surface that opened later.
|
||||
if (hasActiveEscapeSurface()) return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Layout constants for the focus-mode thread drawer.
|
||||
*
|
||||
* Focus mode overlays the channel content area with a right-anchored drawer
|
||||
* rather than splitting the row into two resizable panes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Width of the channel sliver left visible to the left of the focus drawer.
|
||||
*
|
||||
* Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
|
||||
* full-height click target back to the channel, but narrow enough that the
|
||||
* drawer still reads as the primary surface. The sliver keeps showing the real,
|
||||
* still-mounted channel timeline (dimmed by the scrim) so the user never loses
|
||||
* their place.
|
||||
*/
|
||||
export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
|
||||
|
||||
/**
|
||||
* Max width of the centered message column inside the focus drawer.
|
||||
*
|
||||
* The drawer itself spans nearly the whole channel content area, but message
|
||||
* text set that wide is unreadable. The list and composer share this max width
|
||||
* with auto horizontal margins so the reading measure stays comfortable no
|
||||
* matter how wide the window gets.
|
||||
*/
|
||||
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
|
||||
|
||||
/**
|
||||
* Horizontal distance the focus drawer travels on enter/exit.
|
||||
*
|
||||
* Deliberately a fraction of the drawer's own width rather than a true slide
|
||||
* from off-screen: opening a thread is a high-frequency act — threads are chat
|
||||
* sessions and get flipped between constantly — and full-width travel turns a
|
||||
* routine move into ceremony. Short travel keeps it light and repeatable.
|
||||
*
|
||||
* The floor matters as much as the ceiling: the shared 24px side-panel nudge is
|
||||
* only ~3% of this drawer's width, which reads as no movement at all, leaving
|
||||
* the opacity fade as the only perceptible change. This is large enough for the
|
||||
* eye to track a direction and for the ease to have somewhere to decelerate.
|
||||
*/
|
||||
export const THREAD_FOCUS_DRAWER_TRAVEL_PX = 120;
|
||||
|
||||
/**
|
||||
* `AnimatePresence` key shared by both thread layouts.
|
||||
*
|
||||
* The split pane and the focus drawer are two containers for one thread, so
|
||||
* presence is a property of the thread, not of either container. Keying them
|
||||
* apart would make every view-mode switch read as a close followed by an open.
|
||||
*/
|
||||
export const THREAD_SURFACE_KEY = "message-thread-surface";
|
||||
@@ -0,0 +1,45 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { THREAD_FOCUS_COLUMN_MAX_WIDTH_PX } from "@/features/channels/lib/threadFocusLayout";
|
||||
|
||||
export type ThreadPanelLayoutProps = {
|
||||
columnMaxWidthPx?: number;
|
||||
headerLeading?: React.ReactNode;
|
||||
isFocusMode: boolean;
|
||||
isSinglePanelView?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
transparentChrome?: boolean;
|
||||
};
|
||||
|
||||
type ThreadPanelLayoutOptions = {
|
||||
headerLeading?: React.ReactNode;
|
||||
isFocusDrawer: boolean;
|
||||
isSinglePanelView: boolean;
|
||||
useSplitAuxiliaryPane: boolean;
|
||||
};
|
||||
|
||||
/** Maps channel presentation into the shared thread-panel layout contract. */
|
||||
export function getThreadPanelLayout({
|
||||
headerLeading,
|
||||
isFocusDrawer,
|
||||
isSinglePanelView,
|
||||
useSplitAuxiliaryPane,
|
||||
}: ThreadPanelLayoutOptions): ThreadPanelLayoutProps {
|
||||
return isFocusDrawer
|
||||
? {
|
||||
columnMaxWidthPx: THREAD_FOCUS_COLUMN_MAX_WIDTH_PX,
|
||||
headerLeading,
|
||||
isFocusMode: true,
|
||||
isSinglePanelView: true,
|
||||
layout: "standalone",
|
||||
transparentChrome: false,
|
||||
}
|
||||
: {
|
||||
columnMaxWidthPx: undefined,
|
||||
headerLeading,
|
||||
isFocusMode: false,
|
||||
isSinglePanelView: useSplitAuxiliaryPane ? false : isSinglePanelView,
|
||||
layout: useSplitAuxiliaryPane ? "split" : "standalone",
|
||||
transparentChrome: useSplitAuxiliaryPane,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const KEY = "buzz.channels.threadViewMode";
|
||||
let importSequence = 0;
|
||||
|
||||
async function withStorage(storage, run) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
);
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
try {
|
||||
const module = await import(
|
||||
`./threadViewModePreference.ts?test=${importSequence++}`
|
||||
);
|
||||
await run(module);
|
||||
} finally {
|
||||
if (descriptor)
|
||||
Object.defineProperty(globalThis, "localStorage", descriptor);
|
||||
else delete globalThis.localStorage;
|
||||
}
|
||||
}
|
||||
|
||||
test("missing, malformed, and unreadable preferences default to split", async () => {
|
||||
for (const stored of [null, "side-by-side", "{bad-json"]) {
|
||||
await withStorage(
|
||||
{ getItem: (key) => (key === KEY ? stored : null), setItem() {} },
|
||||
({ getThreadViewMode }) => {
|
||||
assert.equal(getThreadViewMode(), "split");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await withStorage(
|
||||
{
|
||||
getItem() {
|
||||
throw new Error("storage unavailable");
|
||||
},
|
||||
setItem() {},
|
||||
},
|
||||
({ getThreadViewMode }) => {
|
||||
assert.equal(getThreadViewMode(), "split");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("loads and writes the stored split preference", async () => {
|
||||
const writes = [];
|
||||
await withStorage(
|
||||
{
|
||||
getItem: (key) => (key === KEY ? "split" : null),
|
||||
setItem: (key, value) => writes.push([key, value]),
|
||||
},
|
||||
({ getThreadViewMode, setThreadViewMode }) => {
|
||||
assert.equal(getThreadViewMode(), "split");
|
||||
setThreadViewMode("focus");
|
||||
assert.equal(getThreadViewMode(), "focus");
|
||||
assert.deepEqual(writes, [[KEY, "focus"]]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the in-memory choice when persistence fails", async () => {
|
||||
await withStorage(
|
||||
{
|
||||
getItem: () => null,
|
||||
setItem() {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
},
|
||||
({ getThreadViewMode, setThreadViewMode }) => {
|
||||
assert.doesNotThrow(() => setThreadViewMode("split"));
|
||||
assert.equal(getThreadViewMode(), "split");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* User preference for how a thread opens inside a channel.
|
||||
*
|
||||
* - `focus` — a large right-anchored drawer overlays the channel content area,
|
||||
* leaving a narrow scrim-dimmed sliver of the channel visible as an
|
||||
* orientation cue and a click-target back to the channel.
|
||||
* - `split` — the thread opens in a resizable side panel next to the channel.
|
||||
*
|
||||
* Persisted in localStorage. This is a device-level UI preference, not
|
||||
* community-scoped data, so it is intentionally not reset on community switch.
|
||||
* Only applies at viewport widths wide enough for a two-pane channel view;
|
||||
* narrow viewports keep their single-panel/floating-overlay behavior.
|
||||
*/
|
||||
export type ThreadViewMode = "focus" | "split";
|
||||
|
||||
const STORAGE_KEY = "buzz.channels.threadViewMode";
|
||||
|
||||
/** Layout used when nothing is stored, or the stored value is unrecognized. */
|
||||
const DEFAULT_THREAD_VIEW_MODE: ThreadViewMode = "split";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let threadViewMode = readStoredThreadViewMode();
|
||||
|
||||
function parseThreadViewMode(value: string | null | undefined): ThreadViewMode {
|
||||
return value === "focus" || value === "split"
|
||||
? value
|
||||
: DEFAULT_THREAD_VIEW_MODE;
|
||||
}
|
||||
|
||||
function readStoredThreadViewMode(): ThreadViewMode {
|
||||
try {
|
||||
return parseThreadViewMode(globalThis.localStorage?.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return DEFAULT_THREAD_VIEW_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): ThreadViewMode {
|
||||
return threadViewMode;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): ThreadViewMode {
|
||||
return DEFAULT_THREAD_VIEW_MODE;
|
||||
}
|
||||
|
||||
/** Read the persisted thread layout preference outside of React. */
|
||||
export function getThreadViewMode(): ThreadViewMode {
|
||||
return threadViewMode;
|
||||
}
|
||||
|
||||
/** Update the thread layout preference and notify all subscribed components. */
|
||||
export function setThreadViewMode(mode: ThreadViewMode): void {
|
||||
threadViewMode = mode;
|
||||
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Persistence is best-effort; the in-memory value still applies.
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** How threads should open in a channel: as a focus drawer or a split pane. */
|
||||
export function useThreadViewMode(): ThreadViewMode {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Hash, LogIn } from "lucide-react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
@@ -8,10 +9,8 @@ import { useTimeoutState } from "@/features/moderation/lib/timeoutStore";
|
||||
import { isModerationDm } from "@/features/moderation/lib/moderationDm";
|
||||
import { useRelaySelfQuery } from "@/features/moderation/hooks";
|
||||
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
|
||||
import {
|
||||
MessageThreadPanel,
|
||||
MessageThreadPanelSkeleton,
|
||||
} from "@/features/messages/ui/MessageThreadPanel";
|
||||
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
|
||||
import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton";
|
||||
import {
|
||||
MessageTimeline,
|
||||
type MessageTimelineHandle,
|
||||
@@ -32,6 +31,13 @@ import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
|
||||
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
|
||||
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
|
||||
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
|
||||
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
|
||||
import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout";
|
||||
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
|
||||
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
|
||||
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
|
||||
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
|
||||
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
|
||||
import {
|
||||
@@ -507,6 +513,23 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
|
||||
const threadViewMode = useThreadViewMode();
|
||||
// Focus mode is a wide-viewport-only alternative to the split thread pane:
|
||||
// narrow viewports keep their existing single-panel / floating-overlay
|
||||
// behavior untouched. It applies to the thread panel only — channel
|
||||
// management, agent session and profile panels always use the split pane.
|
||||
const useFocusThreadDrawer =
|
||||
threadViewMode === "focus" &&
|
||||
useSplitAuxiliaryPane &&
|
||||
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
|
||||
const { channelIsCovered, markExitComplete } =
|
||||
useFocusDrawerPresence(useFocusThreadDrawer);
|
||||
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
|
||||
useThreadViewModeSwitch({
|
||||
externalScrollTargetId: threadScrollTargetId,
|
||||
onExternalTargetResolved: onThreadScrollTargetResolved,
|
||||
onModeChange: markExitComplete,
|
||||
});
|
||||
const selectedAgent = React.useMemo(
|
||||
() =>
|
||||
agentSessionSelection.resolveSelectedAgentSession({
|
||||
@@ -524,10 +547,15 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
shouldShowThreadSkeleton ||
|
||||
Boolean(activeChannel && selectedAgent) ||
|
||||
Boolean(profilePanelPubkey));
|
||||
const wrapAux = (panel: React.ReactNode, testId: string) =>
|
||||
const wrapAux = (
|
||||
panel: React.ReactNode,
|
||||
testId: string,
|
||||
options: { key?: string } = {},
|
||||
) =>
|
||||
useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
key={options.key ?? testId}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId={testId}
|
||||
@@ -536,8 +564,29 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
<React.Fragment key={options.key ?? testId}>{panel}</React.Fragment>
|
||||
);
|
||||
const wrapThreadPanel = (panel: React.ReactNode) =>
|
||||
useFocusThreadDrawer ? (
|
||||
<FocusThreadDrawer
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
key={THREAD_SURFACE_KEY}
|
||||
onClose={onCloseThread}
|
||||
>
|
||||
{panel}
|
||||
</FocusThreadDrawer>
|
||||
) : (
|
||||
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
|
||||
);
|
||||
const threadHeaderLeading = useSplitAuxiliaryPane ? (
|
||||
<ThreadViewModeToggle onChange={changeThreadViewMode} />
|
||||
) : undefined;
|
||||
const threadLayoutProps = getThreadPanelLayout({
|
||||
headerLeading: threadHeaderLeading,
|
||||
isFocusDrawer: useFocusThreadDrawer,
|
||||
isSinglePanelView,
|
||||
useSplitAuxiliaryPane,
|
||||
});
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
{!isSinglePanelView ? (
|
||||
@@ -554,7 +603,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
{!isSinglePanelView ? (
|
||||
<section
|
||||
aria-label="Channel messages and composer"
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
className="relative z-0 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
inert={channelIsCovered ? true : undefined}
|
||||
data-testid="channel-drop-zone"
|
||||
onDragEnter={
|
||||
canDropInMainColumn ? mainComposerMedia.handleDragEnter : undefined
|
||||
@@ -646,7 +696,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
searchQuery={channelFind.query}
|
||||
targetMessageId={targetMessageId}
|
||||
splitThreadPanelOpen={
|
||||
useSplitAuxiliaryPane && Boolean(openThreadHeadId)
|
||||
useSplitAuxiliaryPane &&
|
||||
!useFocusThreadDrawer &&
|
||||
Boolean(openThreadHeadId)
|
||||
}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
/>
|
||||
@@ -773,169 +825,175 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{channelManagementOpen && activeChannel ? (
|
||||
<ChannelManagementAuxiliaryPanel
|
||||
activeChannel={activeChannel}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
currentPubkey={currentPubkey}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
onChannelManagementDeleted={onChannelManagementDeleted}
|
||||
onCloseChannelManagement={onCloseChannelManagement}
|
||||
onResetThreadPanelWidth={onResetThreadPanelWidth}
|
||||
onThreadPanelResizeStart={onThreadPanelResizeStart}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
|
||||
transparentChrome={hasSplitAuxiliaryPane}
|
||||
/>
|
||||
) : threadHeadMessage ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<MessageThreadPanel
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
firstUnreadReplyId={threadFirstUnreadReplyId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
isSending={isSending}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSubmitComplete={handleAutoSubmitComplete}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
|
||||
onEditSave={onEditSave}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onExpandReplies={onExpandThreadReplies}
|
||||
onSelectReplyTarget={onSelectThreadReplyTarget}
|
||||
onSend={onSendThreadReply}
|
||||
onScrollTargetResolved={onThreadScrollTargetResolved}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetId={threadScrollTargetId}
|
||||
threadHead={threadHeadMessage}
|
||||
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadRepliesPending={threadMessagesPending}
|
||||
threadUnreadCount={threadUnreadCounts?.get(threadHeadMessage.id)}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
hasThreadComposerBotActivity ? (
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
workingBotPubkeys={threadComposerBotTypingPubkeys}
|
||||
variant="inline"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "message-thread-panel");
|
||||
})()
|
||||
) : shouldShowThreadSkeleton ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<MessageThreadPanelSkeleton
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
onClose={onCloseThread}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "message-thread-panel");
|
||||
})()
|
||||
) : activeChannel && selectedAgent ? (
|
||||
(() => {
|
||||
// When the panel was opened from a different channel than the
|
||||
// currently active one, re-scope it to the active channel so
|
||||
// that both the content/header AND channel-backed actions (e.g.
|
||||
// Stop current turn) operate on the same channel object.
|
||||
const effectiveAgentSessionChannelId =
|
||||
openAgentSessionChannelId &&
|
||||
activeChannel.id !== openAgentSessionChannelId
|
||||
? activeChannelId
|
||||
: openAgentSessionChannelId;
|
||||
const panel = (
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={
|
||||
effectiveAgentSessionChannelId
|
||||
? effectiveAgentSessionChannelId === activeChannel.id
|
||||
? activeChannel
|
||||
: null
|
||||
: agentSessionSelection.isAgentInActivityList({
|
||||
activityAgents,
|
||||
selectedAgent,
|
||||
})
|
||||
? activeChannel
|
||||
: null
|
||||
}
|
||||
channelId={effectiveAgentSessionChannelId}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
profiles={profiles}
|
||||
onBack={onBackFromAgentSession}
|
||||
onClose={onCloseAgentSession}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "agent-session-thread-panel");
|
||||
})()
|
||||
) : profilePanelPubkey ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<UserProfilePanel
|
||||
currentPubkey={currentPubkey}
|
||||
callerChannelId={activeChannelId}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
onOpenProfile={onOpenProfilePanel}
|
||||
onTabChange={onProfilePanelTabChange}
|
||||
onViewChange={onProfilePanelViewChange}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
tab={profilePanelTab}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "user-profile-panel");
|
||||
})()
|
||||
) : null}
|
||||
{/*
|
||||
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
|
||||
* animation — without it the drawer's own existence condition
|
||||
* (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes
|
||||
* false on the same frame as the close, and there is nothing left to
|
||||
* animate. It can hold the real thread through the exit rather than a
|
||||
* frozen snapshot because the panel is fully prop-driven.
|
||||
*/}
|
||||
<AnimatePresence onExitComplete={markExitComplete}>
|
||||
{channelManagementOpen && activeChannel ? (
|
||||
<ChannelManagementAuxiliaryPanel
|
||||
activeChannel={activeChannel}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
currentPubkey={currentPubkey}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
key="channel-management-panel"
|
||||
onChannelManagementDeleted={onChannelManagementDeleted}
|
||||
onCloseChannelManagement={onCloseChannelManagement}
|
||||
onResetThreadPanelWidth={onResetThreadPanelWidth}
|
||||
onThreadPanelResizeStart={onThreadPanelResizeStart}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
|
||||
transparentChrome={hasSplitAuxiliaryPane}
|
||||
/>
|
||||
) : threadHeadMessage ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<MessageThreadPanel
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
firstUnreadReplyId={threadFirstUnreadReplyId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
isSending={isSending}
|
||||
{...threadLayoutProps}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSubmitComplete={handleAutoSubmitComplete}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
|
||||
onEditSave={onEditSave}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onExpandReplies={onExpandThreadReplies}
|
||||
onSelectReplyTarget={onSelectThreadReplyTarget}
|
||||
onSend={onSendThreadReply}
|
||||
onScrollTargetResolved={resolveScrollTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetHighlights={!layoutScrollTargetId}
|
||||
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
|
||||
threadHead={threadHeadMessage}
|
||||
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadRepliesPending={threadMessagesPending}
|
||||
threadUnreadCount={threadUnreadCounts?.get(
|
||||
threadHeadMessage.id,
|
||||
)}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
hasThreadComposerBotActivity ? (
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
workingBotPubkeys={threadComposerBotTypingPubkeys}
|
||||
variant="inline"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
return wrapThreadPanel(panel);
|
||||
})()
|
||||
) : shouldShowThreadSkeleton ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<MessageThreadPanelSkeleton
|
||||
{...threadLayoutProps}
|
||||
onClose={onCloseThread}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapThreadPanel(panel);
|
||||
})()
|
||||
) : activeChannel && selectedAgent ? (
|
||||
(() => {
|
||||
// When the panel was opened from a different channel than the
|
||||
// currently active one, re-scope it to the active channel so
|
||||
// that both the content/header AND channel-backed actions (e.g.
|
||||
// Stop current turn) operate on the same channel object.
|
||||
const effectiveAgentSessionChannelId =
|
||||
openAgentSessionChannelId &&
|
||||
activeChannel.id !== openAgentSessionChannelId
|
||||
? activeChannelId
|
||||
: openAgentSessionChannelId;
|
||||
const panel = (
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={
|
||||
effectiveAgentSessionChannelId
|
||||
? effectiveAgentSessionChannelId === activeChannel.id
|
||||
? activeChannel
|
||||
: null
|
||||
: agentSessionSelection.isAgentInActivityList({
|
||||
activityAgents,
|
||||
selectedAgent,
|
||||
})
|
||||
? activeChannel
|
||||
: null
|
||||
}
|
||||
channelId={effectiveAgentSessionChannelId}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
profiles={profiles}
|
||||
onBack={onBackFromAgentSession}
|
||||
onClose={onCloseAgentSession}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "agent-session-thread-panel");
|
||||
})()
|
||||
) : profilePanelPubkey ? (
|
||||
(() => {
|
||||
const panel = (
|
||||
<UserProfilePanel
|
||||
currentPubkey={currentPubkey}
|
||||
callerChannelId={activeChannelId}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
transparentChrome={useSplitAuxiliaryPane}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
onOpenProfile={onOpenProfilePanel}
|
||||
onTabChange={onProfilePanelTabChange}
|
||||
onViewChange={onProfilePanelViewChange}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
tab={profilePanelTab}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return wrapAux(panel, "user-profile-panel");
|
||||
})()
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
THREAD_FOCUS_DRAWER_TRAVEL_PX,
|
||||
THREAD_FOCUS_SLIVER_WIDTH_PX,
|
||||
} from "@/features/channels/lib/threadFocusLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type FocusThreadDrawerProps = {
|
||||
channelName: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrim over the channel content area behind the focus drawer.
|
||||
*
|
||||
* Veil, not shadow, and no blur: the channel fades toward the surface colour
|
||||
* rather than being darkened. A black wash is a multiply — it scales text and
|
||||
* background down together, so dark-on-light text keeps its contrast ratio and
|
||||
* stays readable at any opacity short of a solid bar. Fading toward
|
||||
* `background` instead compresses text against the surface in both themes,
|
||||
* which is what pushes the sliver back to colour and shape. Matches the shared
|
||||
* header backdrop's `bg-background/80` vocabulary, a touch heavier because this
|
||||
* one has to defeat body text rather than sit over a gap.
|
||||
*/
|
||||
const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
|
||||
|
||||
/**
|
||||
* Hover eases the veil one step in both themes.
|
||||
*
|
||||
* Feedback that the sliver is a target — deliberately not a peek: one step is
|
||||
* enough to register as interactive without making the channel readable.
|
||||
*/
|
||||
const FOCUS_SCRIM_HOVER_CLASS =
|
||||
"hover:bg-background/65 dark:hover:bg-background/70";
|
||||
|
||||
/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
|
||||
const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
|
||||
|
||||
/**
|
||||
* Leave immediately. Shares the enter's fast-start shape rather than the
|
||||
* conventional accelerating ease-in for exits.
|
||||
*
|
||||
* The "exits accelerate away" rule assumes the whole travel is visible; an
|
||||
* ease-in spends its opening frames barely moving and pays that back at the end.
|
||||
* Here the tail is hidden under the opacity fade, so acceleration buys nothing
|
||||
* and those opening frames are the entire perception of responsiveness — a
|
||||
* dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
|
||||
* its total duration. Decisiveness comes from the duration below instead.
|
||||
*/
|
||||
const EXIT_EASE = ENTER_EASE;
|
||||
|
||||
const SCRIM_ENTER_SECONDS = 0.2;
|
||||
|
||||
/**
|
||||
* Slightly ahead of the drawer's exit, and deliberately so.
|
||||
*
|
||||
* A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
|
||||
* of it, which reads as lag at the exact moment the user has committed to
|
||||
* leaving. Undimming first hands the channel back the instant it is asked for.
|
||||
*/
|
||||
const SCRIM_EXIT_SECONDS = 0.12;
|
||||
|
||||
/**
|
||||
* Enter: opacity front-loaded, transform long.
|
||||
*
|
||||
* The two channels animate over deliberately different windows, and that
|
||||
* asymmetry is the whole point. Short travel *requires* an opacity fade — an
|
||||
* opaque surface this large appearing 120px off its mark with no fade is a hard
|
||||
* cut, not a slide. But pairing both properties on one timing function (as a
|
||||
* single CSS keyframe must) welds them together for the full duration, and since
|
||||
* opacity covers 100% of its range while transform covers ~3% of the drawer's
|
||||
* width, the fade is what the eye reads. Resolving opacity in the first ~90ms
|
||||
* leaves the remaining ~190ms as pure travel: the fade is over before it
|
||||
* registers, and what's perceived is sliding.
|
||||
*
|
||||
* It also keeps the drawer's own entrance from exposing its contents' load
|
||||
* order. Anything arriving late (replies resolving, media decoding) lands on an
|
||||
* already-opaque surface and reads as "the thread is loading" rather than the UI
|
||||
* assembling itself.
|
||||
*/
|
||||
const ENTER_TRANSITION = {
|
||||
opacity: { duration: 0.09, ease: "linear" },
|
||||
x: { duration: 0.28, ease: ENTER_EASE },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Exit: half the enter's duration, opacity barely back-loaded.
|
||||
*
|
||||
* Opening and closing are not symmetric tasks. The enter has something to say —
|
||||
* it establishes where the thread came from and that the channel is still behind
|
||||
* it. The exit has nothing to say: attention has already left for the channel,
|
||||
* so its only job is to get out of the way without popping. That makes duration
|
||||
* the thing to spend, and 140ms is about the floor before the drawer reads as
|
||||
* vanishing rather than leaving.
|
||||
*
|
||||
* The opacity hold shrinks with it. Its purpose is to let the drawer commit to
|
||||
* moving before it dissolves, so it reads as sliding out — but at this duration a
|
||||
* hold proportional to the old one would eat half the animation. 20ms is enough
|
||||
* to register solidity in the first frame or two.
|
||||
*/
|
||||
const EXIT_TRANSITION = {
|
||||
opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
|
||||
x: { duration: 0.14, ease: EXIT_EASE },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Reduced motion keeps a crossfade and drops the travel.
|
||||
*
|
||||
* Travel is the part that's motion; the fade is what makes appearing and
|
||||
* disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
|
||||
* timings would read as dead air on a stationary surface, so both collapse to
|
||||
* one short symmetric fade.
|
||||
*/
|
||||
const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
|
||||
|
||||
/**
|
||||
* Right-anchored thread drawer that overlays the channel content area.
|
||||
*
|
||||
* Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
|
||||
* `AnimatePresence` so the exit animation can run: everything here is absolutely
|
||||
* positioned against the channel content area, so the app sidebar is never
|
||||
* covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
|
||||
* of it remains visible for depth, and the whole scrim (sliver included) is one
|
||||
* tall click target back to the channel. Orientation lives in the drawer
|
||||
* header's breadcrumb, where the eye already is — the sliver carries no label of
|
||||
* its own.
|
||||
*
|
||||
* `z-41` puts the overlay above the channel timeline, its `z-40` composer
|
||||
* overlay and the `z-30` shared header backdrop, while staying below the global
|
||||
* `z-45` top chrome. Setting z-index on the positioned container also gives the
|
||||
* drawer its own stacking context, so the panel chrome inside it is isolated.
|
||||
*/
|
||||
export function FocusThreadDrawer({
|
||||
channelName,
|
||||
children,
|
||||
onClose,
|
||||
}: FocusThreadDrawerProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
|
||||
const drawerRef = React.useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
previousFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
drawerRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
const previousFocus = previousFocusRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
previousFocus?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-41"
|
||||
data-testid="focus-thread-drawer-overlay"
|
||||
>
|
||||
<motion.button
|
||||
animate={{ opacity: 1 }}
|
||||
aria-label={`Back to #${channelName}`}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer transition-colors duration-150",
|
||||
FOCUS_SCRIM_CLASS,
|
||||
FOCUS_SCRIM_HOVER_CLASS,
|
||||
)}
|
||||
data-testid="focus-thread-drawer-scrim"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: prefersReducedMotion
|
||||
? REDUCED_MOTION_TRANSITION
|
||||
: { duration: SCRIM_EXIT_SECONDS, ease: "linear" },
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? REDUCED_MOTION_TRANSITION
|
||||
: { duration: SCRIM_ENTER_SECONDS, ease: "linear" }
|
||||
}
|
||||
type="button"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={cn(
|
||||
// Left corners only, at the app content surface's own `rounded-2xl`:
|
||||
// the drawer is flush to that surface's right edge, so it is *clipped*
|
||||
// to its right corners rather than nesting inside them. Flush edges
|
||||
// share a radius — a smaller one here would put two radii on one
|
||||
// element. `shadow-panel-left` draws the left edge and its corners;
|
||||
// see the token for why a `border-l` cannot.
|
||||
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
|
||||
)}
|
||||
aria-label="Thread"
|
||||
data-testid="focus-thread-drawer"
|
||||
ref={drawerRef}
|
||||
role="complementary"
|
||||
tabIndex={-1}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: prefersReducedMotion
|
||||
? REDUCED_MOTION_TRANSITION
|
||||
: EXIT_TRANSITION,
|
||||
x: travelPx,
|
||||
}}
|
||||
initial={{ opacity: 0, x: travelPx }}
|
||||
style={{ left: THREAD_FOCUS_SLIVER_WIDTH_PX }}
|
||||
transition={
|
||||
prefersReducedMotion ? REDUCED_MOTION_TRANSITION : ENTER_TRANSITION
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Columns2, PanelRightOpen } from "lucide-react";
|
||||
|
||||
import {
|
||||
type ThreadViewMode,
|
||||
useThreadViewMode,
|
||||
} from "@/features/channels/lib/threadViewModePreference";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Both glyphs depict the layout the button switches *to*, never the current one.
|
||||
*
|
||||
* They also come from one family — each is a picture of a destination, not a verb
|
||||
* — because the user watches these two alternate in the same 28px slot. A diagram
|
||||
* flipping to an action icon reads as two different controls sharing a position.
|
||||
*
|
||||
* `columns-2` depicts the split destination, while `panel-right-open` depicts
|
||||
* the thread expanding from its right-hand pane into the larger focus surface.
|
||||
* The latter preserves the thread's spatial origin without implying browser
|
||||
* fullscreen or a separate app window.
|
||||
*/
|
||||
const THREAD_VIEW_MODE_TOGGLE = {
|
||||
focus: {
|
||||
// Viewing the drawer → offer the pane.
|
||||
icon: Columns2,
|
||||
label: "Show thread beside channel",
|
||||
target: "split",
|
||||
},
|
||||
split: {
|
||||
// Viewing the pane → offer the drawer.
|
||||
icon: PanelRightOpen,
|
||||
label: "Expand thread",
|
||||
target: "focus",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Switches an open thread between the focus drawer and the split pane.
|
||||
*
|
||||
* Writes straight to the persisted preference, so the control doubles as the
|
||||
* setting: choosing a layout here is choosing how threads open from now on. That
|
||||
* is the intended behaviour — the place you form the opinion is the place you are
|
||||
* looking at the thread, not a settings page — and it is why the label names an
|
||||
* action rather than a state.
|
||||
*
|
||||
* A tooltip is mandatory, not decoration. A lone toggle showing its target is the
|
||||
* conventional pattern and still routinely misread as showing the current state;
|
||||
* the glyph cannot disambiguate itself, so the label has to.
|
||||
*/
|
||||
export function ThreadViewModeToggle({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (mode: ThreadViewMode) => void;
|
||||
}) {
|
||||
const viewMode = useThreadViewMode();
|
||||
const { icon: Icon, label, target } = THREAD_VIEW_MODE_TOGGLE[viewMode];
|
||||
|
||||
return (
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="shrink-0"
|
||||
data-testid="thread-view-mode-toggle"
|
||||
onClick={() => onChange(target)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Keeps the covered channel inert until the focus drawer finishes exiting. */
|
||||
export function useFocusDrawerPresence(open: boolean) {
|
||||
const [present, setPresent] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setPresent(true);
|
||||
}, [open]);
|
||||
|
||||
const markExitComplete = React.useCallback(() => setPresent(false), []);
|
||||
return {
|
||||
channelIsCovered: open || present,
|
||||
markExitComplete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
findTopVisibleThreadMessageId,
|
||||
getResolvedThreadTargets,
|
||||
} from "./useThreadViewModeSwitch.ts";
|
||||
|
||||
function row(id, top, bottom) {
|
||||
return {
|
||||
dataset: { messageId: id },
|
||||
getBoundingClientRect: () => ({ bottom, top }),
|
||||
};
|
||||
}
|
||||
|
||||
test("finds the first thread message crossing the viewport top", () => {
|
||||
const rows = [
|
||||
row("above", -80, -5),
|
||||
row("crossing", -20, 30),
|
||||
row("below", 40, 90),
|
||||
];
|
||||
const body = {
|
||||
getBoundingClientRect: () => ({ top: 0 }),
|
||||
querySelectorAll: () => rows,
|
||||
};
|
||||
|
||||
assert.equal(findTopVisibleThreadMessageId(body), "crossing");
|
||||
});
|
||||
|
||||
test("resolves both sources when a layout anchor matches the external target", () => {
|
||||
assert.deepEqual(
|
||||
getResolvedThreadTargets({
|
||||
externalTargetId: "reply-b",
|
||||
layoutTargetId: "reply-b",
|
||||
}),
|
||||
{ resolveExternal: true, resolveLayout: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
getResolvedThreadTargets({
|
||||
externalTargetId: "reply-b",
|
||||
layoutTargetId: "reply-c",
|
||||
}),
|
||||
{ resolveExternal: false, resolveLayout: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null without a mounted thread body or visible message", () => {
|
||||
assert.equal(findTopVisibleThreadMessageId(null), null);
|
||||
assert.equal(
|
||||
findTopVisibleThreadMessageId({
|
||||
getBoundingClientRect: () => ({ top: 0 }),
|
||||
querySelectorAll: () => [row("above", -80, -1)],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
setThreadViewMode,
|
||||
type ThreadViewMode,
|
||||
} from "@/features/channels/lib/threadViewModePreference";
|
||||
|
||||
export function findTopVisibleThreadMessageId(
|
||||
body: HTMLElement | null,
|
||||
): string | null {
|
||||
if (!body) return null;
|
||||
|
||||
const bodyTop = body.getBoundingClientRect().top;
|
||||
const visibleReply = Array.from(
|
||||
body.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find((row) => row.getBoundingClientRect().bottom > bodyTop);
|
||||
return visibleReply?.dataset.messageId ?? null;
|
||||
}
|
||||
|
||||
export function getResolvedThreadTargets({
|
||||
externalTargetId,
|
||||
layoutTargetId,
|
||||
}: {
|
||||
externalTargetId: string | null;
|
||||
layoutTargetId: string | null;
|
||||
}) {
|
||||
return {
|
||||
resolveExternal:
|
||||
layoutTargetId === null || layoutTargetId === externalTargetId,
|
||||
resolveLayout: layoutTargetId !== null,
|
||||
};
|
||||
}
|
||||
|
||||
type ThreadViewModeSwitchOptions = {
|
||||
externalScrollTargetId: string | null;
|
||||
onExternalTargetResolved: () => void;
|
||||
onModeChange?: (mode: ThreadViewMode) => void;
|
||||
};
|
||||
|
||||
/** Preserves the reply being read while the thread changes presentation. */
|
||||
export function useThreadViewModeSwitch({
|
||||
externalScrollTargetId,
|
||||
onExternalTargetResolved,
|
||||
onModeChange,
|
||||
}: ThreadViewModeSwitchOptions) {
|
||||
const [layoutScrollTargetId, setLayoutScrollTargetId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const changeThreadViewMode = React.useCallback(
|
||||
(mode: ThreadViewMode) => {
|
||||
const body = document.querySelector<HTMLElement>(
|
||||
'[data-testid="message-thread-body"]',
|
||||
);
|
||||
const anchorId = findTopVisibleThreadMessageId(body);
|
||||
|
||||
setLayoutScrollTargetId(anchorId);
|
||||
onModeChange?.(mode);
|
||||
setThreadViewMode(mode);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(
|
||||
'[data-testid="thread-view-mode-toggle"]',
|
||||
)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
[onModeChange],
|
||||
);
|
||||
|
||||
const resolveScrollTarget = React.useCallback(() => {
|
||||
const resolution = getResolvedThreadTargets({
|
||||
externalTargetId: externalScrollTargetId,
|
||||
layoutTargetId: layoutScrollTargetId,
|
||||
});
|
||||
if (resolution.resolveLayout) setLayoutScrollTargetId(null);
|
||||
if (resolution.resolveExternal) onExternalTargetResolved();
|
||||
}, [externalScrollTargetId, layoutScrollTargetId, onExternalTargetResolved]);
|
||||
|
||||
return {
|
||||
changeThreadViewMode,
|
||||
layoutScrollTargetId,
|
||||
resolveScrollTarget,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared inline-gutter classes for the thread panel, so the real panel and its
|
||||
* loading skeleton stay pixel-aligned as content swaps in.
|
||||
*/
|
||||
|
||||
/** Inline gutter around thread message rows. */
|
||||
export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2";
|
||||
|
||||
/** Inline gutter around the thread composer and its activity row. */
|
||||
export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5";
|
||||
|
||||
/**
|
||||
* Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode
|
||||
* drawer). `px-10` (40px) is the inline gutter between the column and the drawer
|
||||
* edges; the max-width itself is applied inline since it is a caller-provided
|
||||
* pixel value.
|
||||
*/
|
||||
export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10";
|
||||
@@ -948,7 +948,7 @@ export function useMentions(
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMentionQuery(null);
|
||||
cancelMentionAutocomplete(); // full cancel incl. pending debounce
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
@@ -956,6 +956,7 @@ export function useMentions(
|
||||
},
|
||||
[
|
||||
activePersonaIds,
|
||||
cancelMentionAutocomplete,
|
||||
currentPubkey,
|
||||
isMentionOpen,
|
||||
mentionCandidatesWithTeams,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManag
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
|
||||
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -29,10 +30,16 @@ import {
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
} from "@/shared/layout/AuxiliaryPanel";
|
||||
import {
|
||||
THREAD_PANEL_COLUMN_CLASS,
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
|
||||
} from "@/features/messages/lib/messageThreadPanelLayout";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
|
||||
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
@@ -41,7 +48,7 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
type MessageThreadPanelProps = {
|
||||
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
channel: Channel | null;
|
||||
channelId: string | null;
|
||||
channelName: string;
|
||||
@@ -50,7 +57,6 @@ type MessageThreadPanelProps = {
|
||||
firstUnreadReplyId?: string | null;
|
||||
huddleMemberPubkeys?: readonly string[];
|
||||
huddleMemberPubkeysPending?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
editTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
@@ -58,7 +64,6 @@ type MessageThreadPanelProps = {
|
||||
imetaMedia?: ImetaMedia[];
|
||||
} | null;
|
||||
isSending: boolean;
|
||||
isSinglePanelView?: boolean;
|
||||
onCancelEdit?: () => void;
|
||||
onCancelReply: () => void;
|
||||
onClose: () => void;
|
||||
@@ -74,6 +79,7 @@ type MessageThreadPanelProps = {
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onExpandReplies: (message: TimelineMessage) => void;
|
||||
onScrollTargetResolved: () => void;
|
||||
scrollTargetHighlights?: boolean;
|
||||
onSelectReplyTarget: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -102,7 +108,6 @@ type MessageThreadPanelProps = {
|
||||
threadHeadVideoReviewContext?: VideoReviewContext;
|
||||
toolbarExtraActions?: React.ReactNode;
|
||||
widthPx: number;
|
||||
transparentChrome?: boolean;
|
||||
isFollowingThread?: boolean;
|
||||
isMessageUnreadById?: (messageId: string) => boolean;
|
||||
onFollowThread?: () => void;
|
||||
@@ -119,16 +124,7 @@ type MessageThreadPanelProps = {
|
||||
};
|
||||
|
||||
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_REM = 0;
|
||||
type MessageThreadPanelSkeletonProps = {
|
||||
isSinglePanelView?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
onClose: () => void;
|
||||
widthPx: number;
|
||||
transparentChrome?: boolean;
|
||||
};
|
||||
|
||||
function hasLaterVisibleSibling(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
@@ -181,131 +177,11 @@ function getActiveContinuationDepths({
|
||||
return depths;
|
||||
}
|
||||
|
||||
function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) {
|
||||
return (
|
||||
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||
<div className="-mt-1 min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||
<Skeleton className="h-[15px] w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5 pb-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className={isHead ? "h-4 w-4/5" : "h-4 w-2/3"} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadComposerSkeleton() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="pointer-events-auto">
|
||||
<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">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 bg-background pb-1 pt-0",
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageThreadPanelSkeleton({
|
||||
isSinglePanelView = false,
|
||||
layout = "standalone",
|
||||
onClose,
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
}: MessageThreadPanelSkeletonProps) {
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
|
||||
const threadHeaderContent = (
|
||||
<>
|
||||
<AuxiliaryPanelHeaderGroup
|
||||
backButtonAriaLabel="Back to conversation"
|
||||
onBack={isSinglePanelView ? onClose : undefined}
|
||||
>
|
||||
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
const threadBody = (
|
||||
<AuxiliaryPanelBody
|
||||
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
|
||||
data-testid="message-thread-loading"
|
||||
>
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head-loading"
|
||||
>
|
||||
<ThreadMessageSkeleton isHead />
|
||||
</div>
|
||||
<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">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-4 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</AuxiliaryPanelBody>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuxiliaryPanel
|
||||
className="relative"
|
||||
footer={<ThreadComposerSkeleton />}
|
||||
header={
|
||||
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
layout={layout}
|
||||
onClose={onClose}
|
||||
testId="message-thread-panel"
|
||||
transparentChrome={transparentChrome}
|
||||
widthPx={widthPx}
|
||||
>
|
||||
{threadBody}
|
||||
</AuxiliaryPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageThreadPanel({
|
||||
channel,
|
||||
channelId,
|
||||
channelName,
|
||||
columnMaxWidthPx,
|
||||
currentPubkey,
|
||||
disabled = false,
|
||||
firstUnreadReplyId,
|
||||
@@ -313,7 +189,9 @@ export function MessageThreadPanel({
|
||||
huddleMemberPubkeysPending = false,
|
||||
layout = "standalone",
|
||||
editTarget,
|
||||
headerLeading,
|
||||
isSending,
|
||||
isFocusMode,
|
||||
isSinglePanelView = false,
|
||||
isFollowingThread,
|
||||
isMessageUnreadById,
|
||||
@@ -336,6 +214,7 @@ export function MessageThreadPanel({
|
||||
profiles,
|
||||
replyTargetMessage,
|
||||
scrollTargetId,
|
||||
scrollTargetHighlights = true,
|
||||
threadHead,
|
||||
threadHeadVideoReviewContext,
|
||||
threadReplies,
|
||||
@@ -360,7 +239,8 @@ export function MessageThreadPanel({
|
||||
>(null);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const threadHeadId = threadHead?.id ?? null;
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);
|
||||
const hasConstrainedColumn = columnMaxWidthPx != null;
|
||||
useComposerHeightPadding(
|
||||
threadBodyRef,
|
||||
threadComposerWrapperRef,
|
||||
@@ -468,6 +348,13 @@ export function MessageThreadPanel({
|
||||
const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed
|
||||
? threadHeadSummary
|
||||
: null;
|
||||
// Focus mode gives the thread a subject/body structure: the head is what the
|
||||
// thread is about, the replies are the conversation about it. Only draw the
|
||||
// rule when there is actually conversation under it — the "no replies yet"
|
||||
// card and the streaming-in `pending` state would both leave a rule hanging
|
||||
// over an empty region or a placeholder.
|
||||
const showThreadHeadDivider =
|
||||
isFocusMode && (threadRepliesPending || repliesRenderState === "list");
|
||||
|
||||
const threadMessages = React.useMemo(
|
||||
() => deferredThreadReplies.map((entry) => entry.message),
|
||||
@@ -599,6 +486,7 @@ export function MessageThreadPanel({
|
||||
contentRef: threadContentRef,
|
||||
isLoading: threadRepliesPending || repliesRenderState === "pending",
|
||||
messages: threadMessages,
|
||||
highlightTargetMessage: scrollTargetHighlights,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
@@ -640,7 +528,13 @@ export function MessageThreadPanel({
|
||||
onScroll={onScroll}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={threadContentRef}>
|
||||
<div
|
||||
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
|
||||
ref={threadContentRef}
|
||||
style={
|
||||
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head"
|
||||
@@ -691,6 +585,15 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showThreadHeadDivider ? (
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
|
||||
data-testid="message-thread-head-divider"
|
||||
>
|
||||
<Separator className="bg-border/60" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
|
||||
data-testid="message-thread-replies"
|
||||
@@ -911,7 +814,15 @@ export function MessageThreadPanel({
|
||||
data-testid="thread-composer-overlay"
|
||||
ref={threadComposerWrapperRef}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto",
|
||||
hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS,
|
||||
)}
|
||||
style={
|
||||
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
||||
}
|
||||
>
|
||||
<MessageComposer
|
||||
audienceContext={{
|
||||
type: "thread",
|
||||
@@ -974,7 +885,12 @@ export function MessageThreadPanel({
|
||||
<AuxiliaryPanelHeaderGroup
|
||||
backButtonAriaLabel="Back to conversation"
|
||||
backButtonTestId="message-thread-back"
|
||||
onBack={isSinglePanelView ? onClose : undefined}
|
||||
// A focus drawer only sets `isSinglePanelView` to fill its container's
|
||||
// width — it isn't the narrow single-column view, and it has the scrimmed
|
||||
// sliver as its way back, so it takes no back control of its own. The
|
||||
// narrow view still needs one.
|
||||
leading={headerLeading}
|
||||
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
|
||||
>
|
||||
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
@@ -984,6 +900,8 @@ export function MessageThreadPanel({
|
||||
return (
|
||||
<AuxiliaryPanel
|
||||
className="relative"
|
||||
// The focus drawer animates itself; a second slide here would compound.
|
||||
enterMotion={!isFocusMode}
|
||||
footer={threadFooter}
|
||||
header={
|
||||
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
|
||||
import {
|
||||
THREAD_PANEL_COLUMN_CLASS,
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
|
||||
} from "@/features/messages/lib/messageThreadPanelLayout";
|
||||
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AuxiliaryPanel,
|
||||
AuxiliaryPanelBody,
|
||||
AuxiliaryPanelHeader,
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
} from "@/shared/layout/AuxiliaryPanel";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
type MessageThreadPanelSkeletonProps = ThreadPanelLayoutProps & {
|
||||
onClose: () => void;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
/** Placeholder row standing in for a thread message while replies load. */
|
||||
export function ThreadMessageSkeleton({
|
||||
isHead = false,
|
||||
}: {
|
||||
isHead?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||
<div className="-mt-1 min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||
<Skeleton className="h-[15px] w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5 pb-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className={isHead ? "h-4 w-4/5" : "h-4 w-2/3"} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadComposerSkeleton({
|
||||
columnMaxWidthPx,
|
||||
}: {
|
||||
columnMaxWidthPx?: number;
|
||||
}) {
|
||||
const hasConstrainedColumn = columnMaxWidthPx != null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto",
|
||||
hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS,
|
||||
)}
|
||||
style={
|
||||
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 bg-background pb-1 pt-0",
|
||||
THREAD_PANEL_COMPOSER_GUTTER_CLASS,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Loading state for the thread panel, in every layout the real panel supports. */
|
||||
export function MessageThreadPanelSkeleton({
|
||||
columnMaxWidthPx,
|
||||
headerLeading,
|
||||
isFocusMode,
|
||||
isSinglePanelView = false,
|
||||
layout = "standalone",
|
||||
onClose,
|
||||
widthPx,
|
||||
transparentChrome = false,
|
||||
}: MessageThreadPanelSkeletonProps) {
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const hasConstrainedColumn = columnMaxWidthPx != null;
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);
|
||||
|
||||
const threadHeaderContent = (
|
||||
<AuxiliaryPanelHeaderGroup
|
||||
backButtonAriaLabel="Back to conversation"
|
||||
// Matches the loaded panel's header so it doesn't shift on resolve.
|
||||
leading={headerLeading}
|
||||
onBack={isSinglePanelView && !isFocusMode ? onClose : undefined}
|
||||
>
|
||||
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
);
|
||||
|
||||
const threadBody = (
|
||||
<AuxiliaryPanelBody
|
||||
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
|
||||
data-testid="message-thread-loading"
|
||||
>
|
||||
<div
|
||||
className={cn(hasConstrainedColumn && THREAD_PANEL_COLUMN_CLASS)}
|
||||
style={
|
||||
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
|
||||
data-testid="message-thread-head-loading"
|
||||
>
|
||||
<ThreadMessageSkeleton isHead />
|
||||
</div>
|
||||
<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">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<Skeleton className="h-4 w-28 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuxiliaryPanelBody>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuxiliaryPanel
|
||||
className="relative"
|
||||
// See `MessageThreadPanel`: the focus drawer owns the slide.
|
||||
enterMotion={!isFocusMode}
|
||||
footer={<ThreadComposerSkeleton columnMaxWidthPx={columnMaxWidthPx} />}
|
||||
header={
|
||||
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
layout={layout}
|
||||
onClose={onClose}
|
||||
testId="message-thread-panel"
|
||||
transparentChrome={transparentChrome}
|
||||
widthPx={widthPx}
|
||||
>
|
||||
{threadBody}
|
||||
</AuxiliaryPanel>
|
||||
);
|
||||
}
|
||||
@@ -313,7 +313,13 @@ test("auto-pins to bottom once loading clears, against content that already comm
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Flush the rAF-deferred scrollToBottomImperative.
|
||||
assert.equal(
|
||||
refs.container.current.scrollTop,
|
||||
refs.container.current.scrollHeight,
|
||||
"the first bottom pin happens in the layout effect before the next frame",
|
||||
);
|
||||
|
||||
// Flush the rAF settling pass. Late measurements must preserve the same floor.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
@@ -321,7 +327,7 @@ test("auto-pins to bottom once loading clears, against content that already comm
|
||||
assert.equal(
|
||||
refs.container.current.scrollTop,
|
||||
refs.container.current.scrollHeight,
|
||||
"pin fires against the DOM as it exists when loading clears, not a stale mount snapshot",
|
||||
"the settling pass keeps the view pinned against the committed DOM",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -102,8 +102,10 @@ type UseAnchoredScrollOptions = {
|
||||
messages: Array<{ id: string }>;
|
||||
splitPanelOpen?: boolean;
|
||||
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
/** When set, scroll to this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
/** Whether a targeted message should pulse after scrolling to it. */
|
||||
highlightTargetMessage?: boolean;
|
||||
/** Keeps a targeted message centered until the user deliberately scrolls. */
|
||||
pinTargetCentered?: boolean;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
@@ -224,6 +226,7 @@ export function useAnchoredScroll({
|
||||
splitPanelOpen = false,
|
||||
|
||||
targetMessageId = null,
|
||||
highlightTargetMessage = true,
|
||||
pinTargetCentered = false,
|
||||
onTargetReached,
|
||||
virtualScrollToMessage,
|
||||
@@ -604,10 +607,13 @@ export function useAnchoredScroll({
|
||||
// to the requested target message, or to the bottom by default.
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) return;
|
||||
// Defer the scroll out of the layout effect so the current paint commits
|
||||
// first; cancelled on channel switch via the reset effect's rAF guard.
|
||||
// Establish the initial position before the browser paints. The follow-up
|
||||
// frame is a settling pass for content whose measurements land with the
|
||||
// commit (fonts, deferred rows, media), not the first bottom pin. Keeping
|
||||
// both writes in the shared scroll owner gives every conversation surface
|
||||
// the same first-frame behavior regardless of its surrounding animation.
|
||||
const pinToBottomOnMount = () => {
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
scrollToBottomImperative("auto");
|
||||
mountPinRafIdRef.current = requestAnimationFrame(() => {
|
||||
mountPinRafIdRef.current = null;
|
||||
scrollToBottomImperative("auto");
|
||||
@@ -619,7 +625,11 @@ export function useAnchoredScroll({
|
||||
// render or two later. If centering fails now, leave the timeline at
|
||||
// its default position and let the post-mount target effect (keyed on
|
||||
// `messages`) retry once the row lands, rather than marking it handled.
|
||||
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
||||
if (
|
||||
scrollToMessageImperative(targetMessageId, {
|
||||
highlight: highlightTargetMessage,
|
||||
})
|
||||
) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
onTargetReached?.(targetMessageId);
|
||||
} else {
|
||||
@@ -726,6 +736,7 @@ export function useAnchoredScroll({
|
||||
prevMessageCountRef.current = messages.length;
|
||||
prevMessagesRef.current = messages;
|
||||
}, [
|
||||
highlightTargetMessage,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
@@ -831,7 +842,11 @@ export function useAnchoredScroll({
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!el && virtualizerOwnsPrependAnchoring) {
|
||||
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
||||
if (
|
||||
scrollToMessageImperative(targetMessageId, {
|
||||
highlight: highlightTargetMessage,
|
||||
})
|
||||
) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
onTargetReached?.(targetMessageId);
|
||||
}
|
||||
@@ -844,9 +859,12 @@ export function useAnchoredScroll({
|
||||
return;
|
||||
}
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
scrollToMessageImperative(targetMessageId, { highlight: true });
|
||||
scrollToMessageImperative(targetMessageId, {
|
||||
highlight: highlightTargetMessage,
|
||||
});
|
||||
onTargetReached?.(targetMessageId);
|
||||
}, [
|
||||
highlightTargetMessage,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BellRing,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Cpu,
|
||||
Download,
|
||||
FlaskConical,
|
||||
@@ -31,7 +32,20 @@ import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
|
||||
import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard";
|
||||
import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard";
|
||||
import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard";
|
||||
import {
|
||||
setThreadViewMode,
|
||||
useThreadViewMode,
|
||||
type ThreadViewMode,
|
||||
} from "@/features/channels/lib/threadViewModePreference";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
isBuzzTheme,
|
||||
@@ -66,6 +80,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
|
||||
import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard";
|
||||
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
|
||||
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
import { UpdateChecker } from "../UpdateChecker";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
@@ -639,10 +654,92 @@ function ThemeSettingsCard() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
<ThreadLayoutSetting />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const THREAD_VIEW_MODE_OPTIONS: {
|
||||
value: ThreadViewMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "focus",
|
||||
label: "Focus",
|
||||
description: "Threads open over the channel, full width",
|
||||
},
|
||||
{
|
||||
value: "split",
|
||||
label: "Split",
|
||||
description: "Threads open in a side panel next to the channel",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Thread layout picker. Uses the same dropdown radio group vocabulary as the
|
||||
* other enumerated Settings rows (e.g. {@link SoundPicker}) so each option can
|
||||
* carry its own description.
|
||||
*/
|
||||
function ThreadLayoutSetting() {
|
||||
const threadViewMode = useThreadViewMode();
|
||||
const activeOption =
|
||||
THREAD_VIEW_MODE_OPTIONS.find(
|
||||
(option) => option.value === threadViewMode,
|
||||
) ?? THREAD_VIEW_MODE_OPTIONS[0];
|
||||
|
||||
return (
|
||||
<SettingsOptionGroup className="mt-8">
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Thread layout</p>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
{activeOption.description}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="h-7 min-w-28 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
data-testid="thread-layout-trigger"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{activeOption.label}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(next) =>
|
||||
setThreadViewMode(next as ThreadViewMode)
|
||||
}
|
||||
value={threadViewMode}
|
||||
>
|
||||
{THREAD_VIEW_MODE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`thread-layout-${option.value}`}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/** Accent swatch grid — shared by the animated and reduced-motion reveal paths. */
|
||||
function AccentPickerContent({
|
||||
accentColor,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
acquireEscapeSurface,
|
||||
hasActiveEscapeSurface,
|
||||
} from "./escapeSurfaces.ts";
|
||||
|
||||
test("no surfaces means background shortcuts may act", () => {
|
||||
assert.equal(hasActiveEscapeSurface(), false);
|
||||
});
|
||||
|
||||
test("acquire and release track open surfaces", () => {
|
||||
const releaseA = acquireEscapeSurface();
|
||||
assert.equal(hasActiveEscapeSurface(), true);
|
||||
|
||||
const releaseB = acquireEscapeSurface();
|
||||
releaseA();
|
||||
assert.equal(
|
||||
hasActiveEscapeSurface(),
|
||||
true,
|
||||
"one surface closing must not release the other's claim",
|
||||
);
|
||||
|
||||
releaseB();
|
||||
assert.equal(hasActiveEscapeSurface(), false);
|
||||
});
|
||||
|
||||
test("double release cannot corrupt the count", () => {
|
||||
const releaseA = acquireEscapeSurface();
|
||||
releaseA();
|
||||
releaseA();
|
||||
assert.equal(hasActiveEscapeSurface(), false);
|
||||
|
||||
const releaseB = acquireEscapeSurface();
|
||||
assert.equal(
|
||||
hasActiveEscapeSurface(),
|
||||
true,
|
||||
"a leaked double-release must not mask a genuinely open surface",
|
||||
);
|
||||
releaseB();
|
||||
assert.equal(hasActiveEscapeSurface(), false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Tracks closable foreground surfaces currently listening for Escape.
|
||||
*
|
||||
* Escape has app-wide meaning (mark channel read) *and* surface-local meaning
|
||||
* (close the panel above the channel). Window listeners fire in registration
|
||||
* order, so the app-level shortcut — registered at mount — would otherwise
|
||||
* always win the key over a panel that opened later. Instead of racing,
|
||||
* background shortcuts ask "is any closable surface open?" and yield.
|
||||
*
|
||||
* Nested controls (autocomplete, edit mode) still take priority over the
|
||||
* surfaces themselves: they handle Escape on the element and mark it
|
||||
* `defaultPrevented`, which every surface listener already respects.
|
||||
*/
|
||||
let activeEscapeSurfaceCount = 0;
|
||||
|
||||
/** True while at least one closable surface is listening for Escape. */
|
||||
export function hasActiveEscapeSurface(): boolean {
|
||||
return activeEscapeSurfaceCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a closable surface. Returns a release function that must be
|
||||
* called exactly once when the surface stops listening (idempotent — extra
|
||||
* calls are ignored so a double-cleanup cannot corrupt the count).
|
||||
*/
|
||||
export function acquireEscapeSurface(): () => void {
|
||||
activeEscapeSurfaceCount += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
activeEscapeSurfaceCount -= 1;
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
|
||||
|
||||
/**
|
||||
* Calls `onEscape` when the Escape key is pressed, unless the event
|
||||
* was already handled (`defaultPrevented`).
|
||||
* was already handled (`defaultPrevented`) — so nested controls
|
||||
* (autocomplete, edit mode) that claim Escape on the element always win.
|
||||
*
|
||||
* While enabled, the surface is registered with `escapeSurfaces` so
|
||||
* app-level Escape shortcuts (mark channel read) know to yield instead
|
||||
* of racing this listener on registration order.
|
||||
*
|
||||
* Pass `enabled: false` to skip registering the listener entirely.
|
||||
*/
|
||||
export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const releaseSurface = acquireEscapeSurface();
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
@@ -16,6 +24,9 @@ export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
releaseSurface();
|
||||
};
|
||||
}, [enabled, onEscape]);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ type AuxiliaryPanelHeaderGroupProps = Omit<
|
||||
align?: "center" | "start";
|
||||
backButtonAriaLabel?: string;
|
||||
backButtonTestId?: string;
|
||||
/**
|
||||
* Panel-specific control rendered ahead of the title, after any back button.
|
||||
*
|
||||
* A slot rather than a header-level feature because this header is shared by
|
||||
* every auxiliary panel and the controls that belong here are not: only the
|
||||
* thread has view modes. Each panel passes what it owns.
|
||||
*/
|
||||
leading?: React.ReactNode;
|
||||
mode?: AuxiliaryPanelMode;
|
||||
onBack?: () => void;
|
||||
};
|
||||
@@ -248,6 +256,7 @@ export function AuxiliaryPanelHeaderGroup({
|
||||
align = "center",
|
||||
backButtonAriaLabel = "Back",
|
||||
backButtonTestId,
|
||||
leading,
|
||||
mode,
|
||||
children,
|
||||
onBack,
|
||||
@@ -281,6 +290,15 @@ export function AuxiliaryPanelHeaderGroup({
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
{/*
|
||||
* Sits inside the header's normal inset rather than pulled flush like the
|
||||
* back button: a back button is chrome and belongs on the panel edge, but
|
||||
* this is a control acting on the panel's own content, so it reads as part
|
||||
* of the title row and lets the title shift right off it.
|
||||
*/}
|
||||
{leading ? (
|
||||
<div className="flex shrink-0 items-center">{leading}</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getAuxiliaryPanelMode } from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
OverlayPanelBackdrop,
|
||||
PANEL_BASE_CLASS,
|
||||
PANEL_ENTER_BASE_CLASS,
|
||||
PANEL_OVERLAY_CLASS,
|
||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||
@@ -24,6 +25,13 @@ type AuxiliaryPanelProps = {
|
||||
canResetWidth?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/**
|
||||
* When false, the panel skips its own slide-in animation.
|
||||
*
|
||||
* For panels rendered inside a container that already animates itself (the
|
||||
* focus-mode thread drawer), so the two don't compound into a double slide.
|
||||
*/
|
||||
enterMotion?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
isSinglePanelView?: boolean;
|
||||
@@ -46,6 +54,7 @@ export function AuxiliaryPanel({
|
||||
canResetWidth,
|
||||
children,
|
||||
className,
|
||||
enterMotion = true,
|
||||
footer,
|
||||
header,
|
||||
isSinglePanelView = false,
|
||||
@@ -137,7 +146,7 @@ export function AuxiliaryPanel({
|
||||
{isFloatingOverlay ? <OverlayPanelBackdrop onClose={onClose} /> : null}
|
||||
<aside
|
||||
className={cn(
|
||||
PANEL_ENTER_BASE_CLASS,
|
||||
enterMotion ? PANEL_ENTER_BASE_CLASS : PANEL_BASE_CLASS,
|
||||
isSinglePanelView && "border-l-0",
|
||||
isFloatingOverlay && PANEL_OVERLAY_CLASS,
|
||||
className,
|
||||
|
||||
@@ -18,6 +18,16 @@ export default {
|
||||
},
|
||||
boxShadow: {
|
||||
"content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)",
|
||||
// Edge + elevation for a surface anchored to the right of the content
|
||||
// area, whose only exposed edge faces left. Tailwind's stock shadows are
|
||||
// all y-offset, so they cast almost nothing sideways — `shadow-xl` on a
|
||||
// left-facing edge is nearly invisible. Both layers run -x so they wrap
|
||||
// the surface's rounded left corners: the hairline draws the boundary
|
||||
// (and carries dark mode, where a black shadow reads as nothing), the
|
||||
// soft layer carries the lift. A left-only `border` can't do this job —
|
||||
// it tapers out at each corner instead of turning it.
|
||||
"panel-left":
|
||||
"-1px 0 0 0 hsl(var(--border) / 0.8), -16px 0 32px -12px rgb(0 0 0 / 0.18)",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
async function seedLongThread(page: import("@playwright/test").Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
return page.evaluate(() => {
|
||||
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: "Focus mode integration thread",
|
||||
createdAt: 1_700_900_000,
|
||||
});
|
||||
if (!root) throw new Error("Failed to seed focus thread root");
|
||||
|
||||
for (let index = 0; index < 48; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `Focus reply ${index}: this deliberately wraps across several lines so changing the thread measure causes real layout reflow.`,
|
||||
parentEventId: root.id,
|
||||
createdAt: 1_700_900_001 + index,
|
||||
});
|
||||
}
|
||||
return root.id;
|
||||
});
|
||||
}
|
||||
|
||||
async function topVisibleMessageId(
|
||||
body: import("@playwright/test").Locator,
|
||||
): Promise<string> {
|
||||
return body.evaluate((element) => {
|
||||
const top = element.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find((candidate) => candidate.getBoundingClientRect().bottom > top);
|
||||
if (!row?.dataset.messageId) throw new Error("No visible thread anchor");
|
||||
return row.dataset.messageId;
|
||||
});
|
||||
}
|
||||
|
||||
test("focus and split preserve reading context and interaction ownership", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("buzz.channels.threadViewMode", "focus");
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
const rootId = await seedLongThread(page);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
const summary = page.locator(
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
|
||||
);
|
||||
await expect(summary).toBeVisible();
|
||||
await summary.click();
|
||||
|
||||
const channel = page.getByTestId("channel-drop-zone");
|
||||
const drawer = page.getByTestId("focus-thread-drawer");
|
||||
const body = page.getByTestId("message-thread-body");
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Boolean(
|
||||
document
|
||||
.querySelector('[data-testid="focus-thread-drawer"]')
|
||||
?.contains(document.activeElement),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(channel).toHaveAttribute("inert", "");
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight * 0.4;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
const anchorId = await topVisibleMessageId(body);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Show thread beside channel" })
|
||||
.click();
|
||||
await expect(drawer).toHaveCount(0);
|
||||
await expect(channel).not.toHaveAttribute("inert", "");
|
||||
await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused();
|
||||
await expect(
|
||||
body.locator(`[data-message-id="${anchorId}"]`),
|
||||
).toBeInViewport();
|
||||
await expect(
|
||||
body.locator(`[data-message-id="${anchorId}"]`),
|
||||
).not.toHaveAttribute("data-highlighted", "true");
|
||||
|
||||
await page.getByRole("button", { name: "Expand thread" }).click();
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(channel).toHaveAttribute("inert", "");
|
||||
await expect(page.getByTestId("thread-view-mode-toggle")).toBeFocused();
|
||||
await expect(
|
||||
body.locator(`[data-message-id="${anchorId}"]`),
|
||||
).toBeInViewport();
|
||||
|
||||
// Escape layering: a nested control inside the drawer claims Escape first.
|
||||
// With mention autocomplete open, Escape closes only the autocomplete — the
|
||||
// drawer must stay.
|
||||
const threadInput = page
|
||||
.getByTestId("message-thread-panel")
|
||||
.getByTestId("message-input");
|
||||
await threadInput.click();
|
||||
await threadInput.pressSequentially("@al");
|
||||
const mentionDropdown = page
|
||||
.getByTestId("message-thread-panel")
|
||||
.getByTestId("mention-autocomplete");
|
||||
await expect(mentionDropdown).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(mentionDropdown).toHaveCount(0);
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Known gap (pre-existing, tracked as a follow-up): while the composer's
|
||||
// rich-text editor holds focus it claims Escape internally even with no
|
||||
// autocomplete open, so Escape-from-composer never reaches the drawer's
|
||||
// close listener. Move focus to the drawer itself before asserting
|
||||
// Escape-to-close. When the editor is taught to release an idle Escape,
|
||||
// this focus hop can be removed.
|
||||
await drawer.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
|
||||
await expect(channel).not.toHaveAttribute("inert", "");
|
||||
|
||||
await summary.click();
|
||||
await expect(drawer).toBeVisible();
|
||||
await page.getByTestId("focus-thread-drawer-scrim").click({
|
||||
position: { x: 24, y: 200 },
|
||||
});
|
||||
await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
|
||||
await expect(channel).not.toHaveAttribute("inert", "");
|
||||
});
|
||||
|
||||
test("narrow threads do not offer an unavailable layout switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 860, height: 720 });
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
const rootId = await seedLongThread(page);
|
||||
await page.getByTestId("channel-general").click();
|
||||
const summary = page.locator(
|
||||
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
|
||||
);
|
||||
await expect(summary).toBeVisible();
|
||||
await summary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
|
||||
});
|
||||
@@ -41,11 +41,23 @@ const { values: args } = parseArgs({
|
||||
viewport: { type: "string", default: "1280x720" },
|
||||
outdir: { type: "string", default: "test-results/screenshots" },
|
||||
messages: { type: "string" },
|
||||
"local-storage": { type: "string", multiple: true, default: [] },
|
||||
"update-ready": { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
// `--local-storage key=value` (repeatable) seeds a localStorage entry before the
|
||||
// app mounts, so device-level preferences (thread layout, transcript options, …)
|
||||
// can be captured in their non-default state.
|
||||
const localStorageSeeds = args["local-storage"].map((entry) => {
|
||||
const separatorIndex = entry.indexOf("=");
|
||||
if (separatorIndex < 1) {
|
||||
throw new Error(`--local-storage expects key=value, received "${entry}"`);
|
||||
}
|
||||
return [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)];
|
||||
});
|
||||
|
||||
const activeChannel = args["active-channel"];
|
||||
const rightClick = args["right-click"];
|
||||
|
||||
@@ -80,7 +92,16 @@ const TEST_PUBKEYS = [
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
// `BUZZ_HEADED=1` runs a real browser window instead of headless.
|
||||
//
|
||||
// Headless Chromium rasterizes in software (SwiftShader), which mis-renders
|
||||
// `backdrop-filter` when an ancestor establishes a rounded clip
|
||||
// (`overflow-hidden` + `border-radius`) — the blurred surface paints as solid
|
||||
// cyan garbage. The app's blurred chrome (panel headers, composers) hits this
|
||||
// inside the rounded focus drawer. Headed rendering is correct, as is the real
|
||||
// app's WKWebView, so this is a capture-only artifact. Default stays headless so
|
||||
// CI is unaffected.
|
||||
const browser = await chromium.launch({ headless: !process.env.BUZZ_HEADED });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: vpWidth, height: vpHeight },
|
||||
});
|
||||
@@ -108,6 +129,16 @@ await page.addInitScript(
|
||||
{ prefix: ONBOARDING_PREFIX, pubkeys: TEST_PUBKEYS },
|
||||
);
|
||||
|
||||
// Seed caller-supplied preferences. Must run before the bridge init script:
|
||||
// React reads persisted state on mount, and the bridge triggers mount.
|
||||
if (localStorageSeeds.length > 0) {
|
||||
await page.addInitScript((seeds) => {
|
||||
for (const [key, value] of seeds) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
}, localStorageSeeds);
|
||||
}
|
||||
|
||||
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
|
||||
await page.addInitScript(
|
||||
({ updateReady }) => {
|
||||
|
||||
Reference in New Issue
Block a user