inbox-section.tsx6.4 KBView on GitHub import { startTransition, useCallback, useMemo } from 'react';
import { motion } from 'framer-motion';
import { ChevronDown, ChevronRight, ChevronLeft } from 'lucide-react';
import { useInboxCounts } from '@/hooks/use-inbox-counts';
import { useCedarStore } from '@/modules/store';
import { formatInboxCount } from '@/modules/threads/lib/format-inbox-count';
import type { InboxConfig } from '@/modules/threads/hooks/use-inboxes';
import { useInboxThreads } from '@/modules/threads/threadList/hooks/use-inbox-threads';
import { usePageWindow } from '@/modules/threads/threadList/hooks/use-page-window';
import { Thread } from '@/modules/threads/threadList/threadItem';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { startThreadOpenProfiling } from '@/lib/thread-open-profiler';
import { cn } from '@/lib/utils';
const PAGE_SIZE = 25;
type Props = {
inbox: InboxConfig;
/**
* When false, the section's underlying listThreads query is gated off. The
* section still renders its collapsed/expanded chrome.
*
* NOTE: this must never be driven by a sibling section's progress. It used to
* be, and a container whose query never settled left every container below it
* permanently unfetched — see the comment in stacked-inbox-view.tsx.
*/
enabled?: boolean;
};
export function InboxSection({ inbox, enabled = true }: Props) {
const counts = useInboxCounts();
const unreadCount = counts.byId[inbox.id];
const collapsedById = useCedarStore((state) => state.collapsedInboxIds);
const toggleInboxCollapsed = useCedarStore((state) => state.toggleInboxCollapsed);
const collapsed = !!collapsedById[inbox.id];
const selectThreadId = useCedarStore((state) => state.selectThreadId);
const setIsThreadOpen = useCedarStore((state) => state.setIsThreadOpen);
const {
threadsByPage,
pageCount,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isLoading,
} = useInboxThreads(inbox, { pageSize: PAGE_SIZE, enabled: enabled && !collapsed });
const { pageIndex, canPrev, canNext, goPrev, goNext } = usePageWindow({
pageCount,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
});
const currentThreads = useMemo(
() => threadsByPage[pageIndex] ?? [],
[threadsByPage, pageIndex],
);
const handleMailClick = useCallback(
(message: ParsedMessage) => () => {
const threadId = message.threadId ?? message.id;
startThreadOpenProfiling(threadId, 'mouse');
startTransition(() => {
selectThreadId(threadId);
setIsThreadOpen(true);
});
},
[selectThreadId, setIsThreadOpen],
);
// Range display: "1-25 of 74", short final pages like "51-63 of 63".
// When the total is unknown (no count badge yet) we omit "of N".
// When there are zero threads, the whole pager collapses to "0".
const totalCount = unreadCount?.count ?? 0;
const totalIsExact = unreadCount?.isExact ?? true;
const rangeStart = pageCount === 0 ? 0 : pageIndex * PAGE_SIZE + 1;
const rangeEnd =
pageCount === 0 ? 0 : pageIndex * PAGE_SIZE + currentThreads.length;
const showRange = pageCount > 0 && currentThreads.length > 0;
const totalDisplay = unreadCount
? formatInboxCount(totalCount, totalIsExact)
: '';
const rangeText = showRange
? totalDisplay
? `${rangeStart}-${rangeEnd} of ${totalDisplay}`
: `${rangeStart}-${rangeEnd}`
: '0';
return (
<section className="border-b border-border/40">
<header className="sticky top-0 z-20 flex items-center gap-2 overflow-hidden bg-background px-12 py-2">
<button
type="button"
onClick={() => toggleInboxCollapsed(inbox.id)}
aria-label={collapsed ? `Expand ${inbox.name}` : `Collapse ${inbox.name}`}
className="flex min-w-0 items-center gap-1.5 text-left text-sm font-semibold text-foreground"
>
<motion.span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground"
animate={{ rotate: collapsed ? -90 : 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
<ChevronDown className="h-4 w-4" />
</motion.span>
<span className="truncate">{inbox.name}</span>
</button>
{!collapsed && (
<div className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<span className="tabular-nums">{rangeText}</span>
<button
type="button"
onClick={goPrev}
disabled={!canPrev}
aria-label="Previous page"
className={cn(
'ml-1 rounded p-1 hover:bg-accent/50 disabled:opacity-30 disabled:hover:bg-transparent',
)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void goNext()}
disabled={!canNext || isFetchingNextPage}
aria-label="Next page"
className={cn(
'rounded p-1 hover:bg-accent/50 disabled:opacity-30 disabled:hover:bg-transparent',
)}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</header>
{!collapsed && (
<div>
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
</div>
) : currentThreads.length === 0 ? (
// Match the title text's left edge (px-12 + chevron-sized spacer
// + gap-1.5) and use a single thread row's vertical padding so
// the empty state takes up roughly one row's worth of space.
<div className="flex items-center gap-1.5 px-12 py-2.5 text-sm text-muted-foreground">
<span className="inline-block h-4 w-4 shrink-0" aria-hidden />
<span>No threads</span>
</div>
) : (
<div className="flex flex-col">
{currentThreads.map((thread, idx) => (
<Thread
key=[redacted]
message={thread}
isFirst={idx === 0}
onClick={handleMailClick}
/>
))}
</div>
)}
</div>
)}
</section>
);
}