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>
  );
}