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