PreviewInbox.tsx6.4 KBView on GitHub import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
getRowInboxOrder,
type InboxConfig,
type InboxLayout,
} from '@/modules/threads/hooks/use-inboxes';
import { MOCK_THREADS, bucketsForInboxName, type MockThread } from './mock-threads';
interface PreviewInboxProps {
inboxes: InboxConfig[];
inboxLayout: InboxLayout;
hasCustomInboxOrder: boolean;
/** Which tab is shown as selected. Falls back to the first. */
activeInboxId?: string;
onSelectInbox?: (id: string) => void;
className?: string;
}
/**
* Which mock threads land in a given tab.
*
* A sub-inbox PARTITIONS by default — mail it claims leaves the main feed —
* unless it is set to also show in Inbox/Important, which mirrors instead. The
* preview has to model that, because "where did my mail go" is the single thing
* a user gets wrong about split inboxes.
*/
function partition(
inboxes: InboxConfig[],
inboxLayout: InboxLayout,
): Map<string, MockThread[]> {
const result = new Map<string, MockThread[]>();
const customs = inboxes.filter((inbox) => !inbox.system);
// Threads a custom tab has carved out of the system feed.
const claimed = new Set<string>();
for (const inbox of customs) {
const buckets = bucketsForInboxName(inbox.name);
const threads = MOCK_THREADS.filter((t) => buckets.includes(t.bucket));
result.set(inbox.id, threads);
if (!inbox.alsoShowInImportant) threads.forEach((t) => claimed.add(t.id));
}
const remaining = MOCK_THREADS.filter((t) => !claimed.has(t.id));
if (inboxLayout === 'important_other') {
result.set('important', remaining.filter((t) => t.personal));
result.set('other', remaining.filter((t) => !t.personal));
} else {
result.set('default', remaining);
}
return result;
}
function ThreadRow({ thread }: { thread: MockThread }) {
const dim = !thread.unread;
return (
<div
className={cn(
'flex items-center gap-3 border-b px-3 py-2 text-xs last:border-b-0',
dim && 'opacity-55',
)}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
thread.unread ? 'bg-[#006FFE]' : 'bg-transparent',
)}
/>
<span
className={cn(
'w-28 shrink-0 truncate',
thread.unread ? 'text-foreground font-semibold' : 'text-foreground/80',
)}
>
{thread.sender}
</span>
<span className="min-w-0 flex-1 truncate">
<span className={cn('mr-1.5', thread.unread ? 'font-semibold' : 'font-medium')}>
{thread.subject}
</span>
<span className="text-muted-foreground">{thread.snippet}</span>
</span>
{thread.starred && <span className="shrink-0 text-amber-400">★</span>}
<span className="text-muted-foreground w-8 shrink-0 text-right tabular-nums">
{thread.time}
</span>
</div>
);
}
/**
* A miniature, non-interactive mail client driven by the user's REAL inbox
* settings and REAL sub-inboxes, filled with sample mail.
*
* Sample mail rather than the user's own is the point at this stage: the
* settings step is about the layout rules, and a fixed cast of ten threads makes
* the effect of flipping Important+Other legible in a way an unfamiliar slice of
* someone's actual inbox does not.
*/
export function PreviewInbox({
inboxes,
inboxLayout,
hasCustomInboxOrder,
activeInboxId,
onSelectInbox,
className,
}: PreviewInboxProps) {
const ordered = useMemo(
() => getRowInboxOrder(inboxes, hasCustomInboxOrder),
[inboxes, hasCustomInboxOrder],
);
const buckets = useMemo(() => partition(ordered, inboxLayout), [ordered, inboxLayout]);
// A layout switch can retire the selected tab (`important` disappears when you go
// back to one inbox). Fall through to the first tab rather than rendering empty.
const activeId = ordered.some((inbox) => inbox.id === activeInboxId)
? activeInboxId
: ordered[0]?.id;
const activeThreads = (activeId ? buckets.get(activeId) : undefined) ?? [];
// Unread, not total — the count you read to decide whether a tab needs you.
const countFor = (id: string) => (buckets.get(id) ?? []).filter((t) => t.unread).length;
const isStacked = inboxLayout === 'stacked';
return (
<div className={cn('flex h-full flex-col', className)}>
{/* Tab strip — or, in stacked mode, nothing: sections carry the labels instead. */}
{!isStacked && (
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-2 py-1.5">
{ordered.map((inbox) => {
const isActive = inbox.id === activeId;
const count = countFor(inbox.id);
return (
<button
key=[redacted]
type="button"
onClick={() => onSelectInbox?.(inbox.id)}
className={cn(
'flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors',
isActive
? 'bg-black/5 text-foreground font-medium dark:bg-white/10'
: 'text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10',
)}
>
{inbox.name}
{count > 0 && <span className="tabular-nums opacity-60">{count}</span>}
</button>
);
})}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{isStacked ? (
ordered.map((inbox) => {
const threads = buckets.get(inbox.id) ?? [];
if (threads.length === 0) return null;
return (
<section key=[redacted]
<h4 className="bg-sidebar text-muted-foreground sticky top-0 flex items-center gap-2 border-b px-3 py-1.5 text-xs font-semibold">
{inbox.name}
<span className="tabular-nums opacity-60">{countFor(inbox.id)}</span>
</h4>
{threads.map((thread) => (
<ThreadRow key=[redacted] thread={thread} />
))}
</section>
);
})
) : activeThreads.length === 0 ? (
<p className="text-muted-foreground p-6 text-center text-xs">
Nothing lands here yet.
</p>
) : (
activeThreads.map((thread) => (
<ThreadRow key=[redacted] thread={thread} />
))
)}
</div>
</div>
);
}