Update inbox conversation experience (#608)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-18 13:48:47 -04:00
committed by GitHub
co-authored by Cursor
parent 70cb53e2c7
commit 137a4e268f
8 changed files with 414 additions and 224 deletions
+5
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
@@ -9,6 +10,7 @@ export const Route = createFileRoute("/")({
});
function HomeRouteComponent() {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
@@ -18,6 +20,9 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenContext={(channelId, messageId) => {
void goChannel(channelId, { messageId });
}}
/>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export function useHomeFeedQuery() {
queryKey: ["home-feed"],
queryFn: () =>
getHomeFeed({
limit: 12,
limit: 50,
types: "mentions,needs_action,activity,agent_activity",
}),
staleTime: 15_000,
@@ -5,11 +5,13 @@ import { HomeView } from "@/features/home/ui/HomeView";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenContext: (channelId: string, messageId: string) => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenContext,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -33,6 +35,7 @@ export function HomeScreen({
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
+47 -10
View File
@@ -11,6 +11,7 @@ import {
} from "@/features/home/lib/inbox";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import { useResizableInboxListWidth } from "@/features/home/useResizableInboxListWidth";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import {
@@ -104,6 +105,7 @@ type HomeViewProps = {
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenContext: (channelId: string, messageId: string) => void;
onRefresh: () => void;
};
@@ -113,6 +115,7 @@ export function HomeView({
errorMessage,
currentPubkey,
availableChannelIds,
onOpenContext,
onRefresh,
}: HomeViewProps) {
const [filter, setFilter] = React.useState<InboxFilter>("all");
@@ -124,6 +127,12 @@ export function HomeView({
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
Record<string, InboxReply[]>
>({});
const {
canResetInboxListWidth,
handleInboxListResizeStart,
handleInboxListWidthReset,
inboxListWidthPx,
} = useResizableInboxListWidth();
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
const feedItems = React.useMemo(
() =>
@@ -303,10 +312,12 @@ export function HomeView({
);
}
const canReply =
const canReact =
selectedItem !== null &&
selectedItem.item.channelId !== null &&
availableChannelIds.has(selectedItem.item.channelId) &&
availableChannelIds.has(selectedItem.item.channelId);
const canReply =
canReact &&
selectedItem.item.kind !== 45001 &&
selectedItem.item.kind !== 45003;
const disabledReplyReason =
@@ -323,10 +334,15 @@ export function HomeView({
selectedItem.item.pubkey.trim().toLowerCase();
return (
<div className="flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]"
className="relative grid min-h-0 flex-1 w-full lg:grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
data-testid="home-inbox"
style={
{
"--home-inbox-list-width": `${inboxListWidthPx}px`,
} as React.CSSProperties
}
>
<InboxListPane
doneSet={doneSet}
@@ -340,6 +356,25 @@ export function HomeView({
selectedId={selectedItemId}
/>
<button
aria-label="Resize inbox list"
className="group absolute inset-y-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize lg:block"
data-testid="home-inbox-list-resize-handle"
onDoubleClick={
canResetInboxListWidth ? handleInboxListWidthReset : undefined
}
onPointerDown={handleInboxListResizeStart}
style={{ left: `${inboxListWidthPx}px` }}
title={
canResetInboxListWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
</button>
<InboxDetailPane
canDelete={canDelete}
canOpenChannel={Boolean(
@@ -355,6 +390,7 @@ export function HomeView({
item={selectedItem}
messages={contextMessages}
replies={selectedItemReplies}
contextChannelName={selectedChannel?.name ?? null}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;
@@ -369,6 +405,7 @@ export function HomeView({
setIsDeletingMessage(false);
});
}}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
mediaTags,
@@ -420,13 +457,8 @@ export function HomeView({
setIsSendingReply(false);
}
}}
onToggleDone={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
}
}}
onToggleReaction={
canReply
canReact
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
@@ -438,6 +470,11 @@ export function HomeView({
}
: undefined
}
onToggleDone={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
}
}}
/>
</div>
</div>
+106 -210
View File
@@ -12,16 +12,18 @@ import type {
InboxItem,
InboxReply,
} from "@/features/home/lib/inbox";
import {
type InboxDisplayMessage,
InboxMessageRow,
} from "@/features/home/ui/InboxMessageRow";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
@@ -30,7 +32,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type InboxDetailPaneProps = {
canDelete: boolean;
@@ -44,38 +45,23 @@ type InboxDetailPaneProps = {
item: InboxItem | null;
messages?: InboxContextMessage[];
replies?: InboxReply[];
contextChannelName?: string | null;
onDelete: () => void;
onOpenContext?: (channelId: string, messageId: string) => void;
onSendReply: (input: {
content: string;
mediaTags?: string[][];
mentionPubkeys: string[];
parentEventId: string;
}) => Promise<void>;
onToggleDone: () => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
onToggleDone: () => void;
};
type InboxDisplayMessage = InboxContextMessage & {
depth: number;
};
function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage {
return {
id: message.id,
author: message.authorLabel,
avatarUrl: message.avatarUrl,
body: message.content,
createdAt: 0,
depth: message.depth,
reactions: message.reactions ?? [],
time: message.fullTimestampLabel,
};
}
export function InboxDetailPane({
canDelete,
canOpenChannel,
@@ -88,16 +74,28 @@ export function InboxDetailPane({
item,
messages = [],
replies = [],
contextChannelName = null,
onDelete,
onOpenContext,
onSendReply,
onToggleDone,
onToggleReaction,
onToggleDone,
}: InboxDetailPaneProps) {
const detailPaneRef = React.useRef<HTMLElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
React.useState(true);
const selectedItemId = item?.id ?? null;
const selectedMessageScrollKey = React.useMemo(() => {
if (!selectedItemId) {
return null;
}
const selectedMessageIndex = messages.findIndex(
(message) => message.isSelected,
);
return `${selectedItemId}:${selectedMessageIndex}:${messages.length}`;
}, [messages, selectedItemId]);
const focusComposer = React.useCallback(() => {
window.requestAnimationFrame(() => {
@@ -126,6 +124,20 @@ export function InboxDetailPane({
};
}, [selectedItemId]);
React.useEffect(() => {
if (!selectedMessageScrollKey) {
return;
}
window.requestAnimationFrame(() => {
detailPaneRef.current
?.querySelector<HTMLElement>(
'[data-testid="home-inbox-selected-message"]',
)
?.scrollIntoView({ block: "center" });
});
}, [selectedMessageScrollKey]);
if (!item) {
return (
<section
@@ -179,6 +191,11 @@ export function InboxDetailPane({
id: replyTarget.id,
}
: null;
const channelContextName = contextChannelName ?? item.channelLabel;
const contextLabel = channelContextName
? `#${channelContextName}`
: item.categoryLabel;
const contextChannelId = item.item.channelId;
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
setReplyTargetId((currentReplyTargetId) =>
@@ -193,75 +210,41 @@ export function InboxDetailPane({
data-testid="home-inbox-detail"
ref={detailPaneRef}
>
{!canOpenChannel ? (
<div className="px-6 pb-4 pt-14">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8 rounded-xl"
displayName={item.senderLabel}
size="md"
/>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-base font-semibold">
{item.senderLabel}
</p>
<span
className={cn(
"inline-flex items-center text-[10px] font-semibold uppercase tracking-[0.14em]",
item.isActionRequired
? "text-amber-600 dark:text-amber-300"
: "text-primary",
)}
>
{item.categoryLabel}
</span>
{item.channelLabel ? (
<span className="inline-flex items-center text-[11px] font-medium text-muted-foreground">
#{item.channelLabel}
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{item.fullTimestampLabel}</span>
<span>Inbox only</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-4">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-0.5">
<HeaderIconAction
label={isDone ? "Mark unread" : "Mark done"}
onClick={onToggleDone}
icon={
isDone ? (
<MailOpen className="h-4 w-4" />
) : (
<CheckCheck className="h-4 w-4" />
)
}
/>
</div>
{canDelete ? (
<HeaderMoreMenu
isDeletingMessage={isDeletingMessage}
onDelete={onDelete}
/>
) : null}
</div>
</TooltipProvider>
</div>
</div>
</div>
) : null}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-x-0 top-0 z-40 flex min-h-[44px] items-center justify-between gap-3 bg-background/70 py-[6px] pl-6 pr-3 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55">
<div className="min-w-0">
{canOpenChannel && contextChannelId && onOpenContext ? (
<button
className="truncate text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => onOpenContext(contextChannelId, item.id)}
title={item.fullTimestampLabel}
type="button"
>
{contextLabel}
</button>
) : (
<h2
className="truncate text-sm font-semibold leading-5 tracking-tight text-foreground"
title={item.fullTimestampLabel}
>
{contextLabel}
</h2>
)}
</div>
<TooltipProvider delayDuration={200}>
<div className="flex shrink-0 items-center gap-1">
<HeaderMoreMenu
canDelete={canDelete}
isDeletingMessage={isDeletingMessage}
isDone={isDone}
onDelete={onDelete}
onToggleDone={onToggleDone}
/>
</div>
</TooltipProvider>
</div>
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-14">
<div>
{isThreadContextLoading ? (
@@ -274,88 +257,14 @@ export function InboxDetailPane({
{index === 1 ? (
<div className="mx-6 my-3 border-t border-border/60" />
) : null}
<div className="px-6 py-2">
<article
className={cn(
"group/message relative flex items-start gap-2.5 px-2 py-1 transition-colors duration-1000",
message.isSelected
? cn(
isFocusHighlightVisible
? "bg-primary/[0.07]"
: "bg-transparent",
)
: "hover:bg-muted/20",
)}
data-testid={
message.isSelected
? "home-inbox-selected-message"
: "home-inbox-context-message"
}
>
{canReply || onToggleReaction ? (
<div className="absolute right-2 top-1 z-10">
<MessageActionBar
activeReplyTargetId={replyTargetId}
message={toActionBarMessage(message)}
onReactionSelect={
onToggleReaction
? (emoji) => {
const actionBarMessage =
toActionBarMessage(message);
const remove =
actionBarMessage.reactions?.some(
(reaction) =>
reaction.emoji === emoji &&
reaction.reactedByCurrentUser,
) ?? false;
return onToggleReaction(
actionBarMessage,
emoji,
remove,
);
}
: undefined
}
onReply={
canReply
? () => handleSelectReplyTarget(message)
: undefined
}
reactions={message.reactions ?? []}
/>
</div>
) : null}
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-8 w-8 shrink-0 rounded-xl"
displayName={message.authorLabel}
size="md"
/>
<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">
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
{message.authorLabel}
</p>
<p className="shrink-0 text-xs font-normal leading-none tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
{message.isSelected ? (
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
Inbox item
</span>
) : null}
</div>
<div className="mt-1">
<Markdown
className="max-w-full text-left text-sm text-foreground"
content={message.content}
mentionNames={message.mentionNames}
tight
/>
</div>
</div>
</article>
</div>
<InboxMessageRow
activeReplyTargetId={replyTargetId}
canReply={canReply}
isFocusHighlightVisible={isFocusHighlightVisible}
message={message}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
/>
</React.Fragment>
))}
</div>
@@ -396,42 +305,18 @@ export function InboxDetailPane({
);
}
function HeaderIconAction({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
}) {
const button = (
<Button
aria-label={label}
className="h-8 w-8 rounded-full p-0 text-muted-foreground"
onClick={onClick}
size="icon"
type="button"
variant="ghost"
>
{icon}
</Button>
);
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function HeaderMoreMenu({
canDelete,
isDeletingMessage,
isDone,
onDelete,
onToggleDone,
}: {
canDelete: boolean;
isDeletingMessage: boolean;
isDone: boolean;
onDelete: () => void;
onToggleDone: () => void;
}) {
const trigger = (
<Button
@@ -454,14 +339,25 @@ function HeaderMoreMenu({
<TooltipContent>More actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isDeletingMessage}
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
Delete message
<DropdownMenuItem onClick={onToggleDone}>
{isDone ? (
<MailOpen className="h-4 w-4" />
) : (
<CheckCheck className="h-4 w-4" />
)}
{isDone ? "Unmark as read" : "Mark as read"}
</DropdownMenuItem>
{canDelete ? <DropdownMenuSeparator /> : null}
{canDelete ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isDeletingMessage}
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
Delete message
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -29,8 +29,8 @@ export function InboxListPane({
selectedId,
}: InboxListPaneProps) {
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background/60">
<div className="px-4 pb-3 pt-14">
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
<div className="px-5 pb-3 pt-14">
<div className="flex flex-nowrap gap-1">
{FILTER_OPTIONS.map((option) => (
<Button
@@ -72,7 +72,7 @@ export function InboxListPane({
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
isSelected
? "border-l-primary bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
@@ -0,0 +1,153 @@
import * as React from "react";
import type { InboxContextMessage } from "@/features/home/lib/inbox";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type InboxDisplayMessage = InboxContextMessage & {
depth: number;
};
function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
return {
id: message.id,
author: message.authorLabel,
avatarUrl: message.avatarUrl,
body: message.content,
createdAt: 0,
depth: message.depth,
reactions: message.reactions ?? [],
time: message.fullTimestampLabel,
};
}
type InboxMessageRowProps = {
activeReplyTargetId: string | null;
canReply: boolean;
isFocusHighlightVisible: boolean;
message: InboxDisplayMessage;
onSelectReplyTarget: (message: InboxDisplayMessage) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
};
export function InboxMessageRow({
activeReplyTargetId,
canReply,
isFocusHighlightVisible,
message,
onSelectReplyTarget,
onToggleReaction,
}: InboxMessageRowProps) {
const timelineMessage = React.useMemo(
() => toTimelineMessage(message),
[message],
);
const {
reactions,
canToggle: canToggleReactions,
pending: reactionPending,
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(timelineMessage, onToggleReaction);
return (
<div className="px-6 py-2">
<article
className={cn(
"group/message relative flex items-start gap-2.5 px-2 py-1",
!message.isSelected && "hover:bg-muted/20",
)}
data-testid={
message.isSelected
? "home-inbox-selected-message"
: "home-inbox-context-message"
}
>
{message.isSelected ? (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute -inset-x-2 -inset-y-1 rounded-xl transition-opacity duration-1000",
isFocusHighlightVisible
? "bg-primary/[0.07] opacity-100"
: "bg-primary/[0.07] opacity-0",
)}
/>
) : null}
{canReply || canToggleReactions ? (
<div className="absolute right-2 top-1 z-10">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={timelineMessage}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={
canReply ? () => onSelectReplyTarget(message) : undefined
}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
</div>
) : null}
<UserAvatar
avatarUrl={message.avatarUrl}
className="!h-9 !w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
<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">
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
{message.authorLabel}
</p>
<p className="shrink-0 text-xs font-normal leading-none tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
{message.isSelected ? (
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
Inbox item
</span>
) : null}
</div>
<div className="mt-1">
<Markdown
className="max-w-full text-left text-sm text-foreground"
content={message.content}
mentionNames={message.mentionNames}
tight
/>
<MessageReactions
canToggle={canToggleReactions}
messageId={message.id}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
pending={reactionPending}
reactions={reactions}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
</div>
</div>
</article>
</div>
);
}
@@ -0,0 +1,96 @@
import * as React from "react";
const INBOX_LIST_DEFAULT_WIDTH_PX = 320;
const INBOX_LIST_MIN_WIDTH_PX = 260;
const INBOX_LIST_MAX_WIDTH_PX = 520;
const INBOX_LIST_WIDTH_SESSION_KEY = "sprout.desktop.home-inbox-list-width";
function clampInboxListWidth(width: number): number {
return Math.max(
INBOX_LIST_MIN_WIDTH_PX,
Math.min(INBOX_LIST_MAX_WIDTH_PX, width),
);
}
function getInitialInboxListWidth(): number {
if (typeof window === "undefined") {
return INBOX_LIST_DEFAULT_WIDTH_PX;
}
try {
const raw = window.sessionStorage.getItem(INBOX_LIST_WIDTH_SESSION_KEY);
if (!raw) {
return INBOX_LIST_DEFAULT_WIDTH_PX;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return INBOX_LIST_DEFAULT_WIDTH_PX;
}
return clampInboxListWidth(parsed);
} catch {
return INBOX_LIST_DEFAULT_WIDTH_PX;
}
}
export function useResizableInboxListWidth() {
const [inboxListWidthPx, setInboxListWidthPx] = React.useState<number>(() =>
getInitialInboxListWidth(),
);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(
INBOX_LIST_WIDTH_SESSION_KEY,
String(inboxListWidthPx),
);
} catch {
// Ignore storage failures and keep the chosen width in memory.
}
}, [inboxListWidthPx]);
const handleInboxListResizeStart = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = inboxListWidthPx;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
setInboxListWidthPx(clampInboxListWidth(startWidth + deltaX));
};
const handlePointerUp = () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[inboxListWidthPx],
);
const handleInboxListWidthReset = React.useCallback(() => {
setInboxListWidthPx(INBOX_LIST_DEFAULT_WIDTH_PX);
}, []);
return {
canResetInboxListWidth: inboxListWidthPx !== INBOX_LIST_DEFAULT_WIDTH_PX,
handleInboxListResizeStart,
handleInboxListWidthReset,
inboxListWidthPx,
};
}