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