LiveInboxPreview.tsx4.9 KBView on GitHub import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import {
getRowInboxOrder,
type InboxConfig,
} from '@/modules/threads/hooks/use-inboxes';
import { cn } from '@/lib/utils';
const PREVIEW_PAGE_SIZE = 12;
interface LiveInboxPreviewProps {
inboxes: InboxConfig[];
hasCustomInboxOrder: boolean;
activeInboxId?: string;
onSelectInbox: (id: string) => void;
className?: string;
}
interface PreviewRow {
id: string;
sender: string;
subject: string;
snippet: string;
unread: boolean;
}
function formatSender(sender?: { name?: string; email: string }): string {
if (!sender) return 'Unknown';
return sender.name?.trim() || sender.email;
}
/**
* The user's REAL mail, filtered by the selected tab's query.
*
* Asked with the tab's own `query` rather than through `useThreads`: this is a
* read-only sample, and going through the real feed hook would drag in its
* store writes, its selection state and its pagination — all of which belong to
* the actual inbox, not to a picture of one. A system tab has no stored query;
* an empty `q` is what `listThreads` already resolves to `label:INBOX`.
*/
export function LiveInboxPreview({
inboxes,
hasCustomInboxOrder,
activeInboxId,
onSelectInbox,
className,
}: LiveInboxPreviewProps) {
const trpc = useTRPC();
const ordered = useMemo(
() => getRowInboxOrder(inboxes, hasCustomInboxOrder),
[inboxes, hasCustomInboxOrder],
);
const activeInbox =
ordered.find((inbox) => inbox.id === activeInboxId) ?? ordered[0];
const query = activeInbox?.compiledQuery?.trim() || activeInbox?.query?.trim() || '';
const { data, isFetching } = useQuery(
trpc.mail.listThreads.queryOptions(
{ q: query, maxResults: PREVIEW_PAGE_SIZE },
{
// A tab the user just created has no server rows behind it for a beat.
// Keep the last result on screen while the new one loads rather than
// flashing an empty state that reads as "this tab caught nothing".
placeholderData: (previous) => previous,
staleTime: 30 * 1000,
},
),
);
const rows: PreviewRow[] = useMemo(
() =>
(data?.threads ?? []).map((thread) => ({
id: thread.id,
sender: formatSender(thread.$raw?.sender),
subject: thread.$raw?.subject?.trim() || '(no subject)',
snippet: thread.$raw?.snippet?.trim() ?? '',
unread: !!thread.$raw?.labels?.some((label) => label?.name === 'UNREAD'),
})),
[data],
);
return (
<div className={cn('flex h-full flex-col', className)}>
<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 === activeInbox?.id;
return (
<button
key=[redacted]
type="button"
onClick={() => onSelectInbox(inbox.id)}
className={cn(
'shrink-0 cursor-pointer 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}
</button>
);
})}
{isFetching && (
<Loader2 className="text-muted-foreground ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{rows.length === 0 ? (
<p className="text-muted-foreground p-6 text-center text-xs">
{isFetching
? 'Loading your mail…'
: 'No mail matches this tab in your recent inbox.'}
</p>
) : (
rows.map((row) => (
<div
key=[redacted]
className={cn(
'flex items-center gap-3 border-b px-3 py-2 text-xs last:border-b-0',
!row.unread && 'opacity-55',
)}
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
row.unread ? 'bg-[#006FFE]' : 'bg-transparent',
)}
/>
<span
className={cn(
'w-28 shrink-0 truncate',
row.unread ? 'text-foreground font-semibold' : 'text-foreground/80',
)}
>
{row.sender}
</span>
<span className="min-w-0 flex-1 truncate">
<span className={cn('mr-1.5', row.unread ? 'font-semibold' : 'font-medium')}>
{row.subject}
</span>
<span className="text-muted-foreground">{row.snippet}</span>
</span>
</div>
))
)}
</div>
</div>
);
}