SmartInboxLayout.tsx5.6 KBView on GitHub /**
* Smart Inbox Layout — flat date-grouped list of all active inbox threads.
* Mirrors MailList exactly, without priority sections.
*/
import { VList, type VListHandle } from 'virtua';
import { useCedarStore } from '@/modules/store';
import { Thread } from '@/modules/threads/threadList/threadItem';
import { useSmartInboxData } from './useSmartInboxData';
import type { ThreadSummary, ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { useCallback, useEffect, useMemo, useRef, startTransition } from 'react';
import { cn } from '@/lib/utils';
import { startThreadOpenProfiling } from '@/lib/thread-open-profiler';
import { EmptyStateIcon } from '@/components/icons/empty-state-svg';
import {
DATE_GROUP_ORDER,
getDateGroup,
useLocalDayStart,
type DateGroup,
} from '@/modules/threads/threadList/utils/date-groups';
type ListItem =
| { kind: 'date-header'; label: DateGroup; isFirst: boolean }
| { kind: 'thread'; item: ThreadSummary; threadIndex: number };
function DateGroupHeader({ label, isFirst }: { label: string; isFirst: boolean }) {
return (
<div className={cn('px-16 pb-2 text-sm font-medium text-foreground', isFirst ? 'pt-3' : 'pt-6')}>
{label}
</div>
);
}
export function SmartInboxLayout() {
const { threads, isLoading, isFetching, isFetchingNextPage, hasNextPage, loadMore } =
useSmartInboxData();
const selectThreadId = useCedarStore((state) => state.selectThreadId);
const setIsThreadOpen = useCedarStore((state) => state.setIsThreadOpen);
const vListRef = useRef<VListHandle>(null);
// Moving boundary — re-buckets the list when the local day rolls over so
// headers can't go stale on a tab left open overnight.
const todayStart = useLocalDayStart();
const listItems = useMemo<ListItem[]>(() => {
const buckets: Record<DateGroup, ThreadSummary[]> = {
Today: [],
Yesterday: [],
'Last 7 days': [],
Older: [],
};
for (const t of threads) {
buckets[getDateGroup(t.$raw?.latestReceivedOn, todayStart)].push(t);
}
const result: ListItem[] = [];
let threadIndex = 0;
let isFirstGroup = true;
for (const label of DATE_GROUP_ORDER) {
const bucket = buckets[label];
if (bucket.length === 0) continue;
result.push({ kind: 'date-header', label, isFirst: isFirstGroup });
isFirstGroup = false;
for (const item of bucket) {
result.push({ kind: 'thread', item, threadIndex });
threadIndex++;
}
}
return result;
}, [threads, todayStart]);
const handleScroll = useCallback(() => {
if (!vListRef.current || !hasNextPage || isLoading || isFetchingNextPage) return;
const endIndex = vListRef.current.findEndIndex();
if (listItems.length - 1 - endIndex < 10) void loadMore();
}, [listItems.length, hasNextPage, isLoading, isFetchingNextPage, loadMore]);
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || !vListRef.current) return;
const endIndex = vListRef.current.findEndIndex();
if (listItems.length - 1 - endIndex < 10) void loadMore();
}, [listItems.length, hasNextPage, isLoading, isFetchingNextPage, loadMore]);
const handleThreadClick = useCallback(
(message: ParsedMessage) => () => {
const threadId = message.threadId ?? message.id;
startThreadOpenProfiling(threadId, 'mouse');
startTransition(() => {
selectThreadId(threadId);
setIsThreadOpen(true);
});
},
[selectThreadId, setIsThreadOpen],
);
const vListRenderer = useCallback(
(index: number) => {
const listItem = listItems[index];
if (!listItem) return <></>;
if (listItem.kind === 'date-header') {
return <DateGroupHeader key=[redacted] label={listItem.label} isFirst={listItem.isFirst} />;
}
const { item, threadIndex } = listItem;
return (
<>
<Thread
key=[redacted]
message={item}
isFirst={threadIndex === 0}
onClick={handleThreadClick}
/>
{index === listItems.length - 1 && (isFetchingNextPage || isFetching) ? (
<div className="flex w-full justify-center py-4">
<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>
) : null}
</>
);
},
[listItems, isFetchingNextPage, isFetching, handleThreadClick],
);
if (isLoading) {
return (
<div className="flex h-32 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>
);
}
if (threads.length === 0 && !isFetching) {
return (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<EmptyStateIcon width={200} height={200} />
<div className="mt-5">
<p className="text-lg">Smart Inbox is empty</p>
<p className="text-md text-muted-foreground">You're all caught up</p>
</div>
</div>
</div>
);
}
return (
<div className="hide-link-indicator flex h-full w-full">
<div className="flex flex-1 flex-col">
<VList
ref={vListRef}
count={listItems.length}
overscan={15}
itemSize={100}
className="scrollbar-none flex-1 overflow-x-hidden"
onScroll={handleScroll}
>
{vListRenderer}
</VList>
</div>
</div>
);
}