PreviewInbox.tsx6.4 KBView on GitHub
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
  getRowInboxOrder,
  type InboxConfig,
  type InboxLayout,
} from '@/modules/threads/hooks/use-inboxes';
import { MOCK_THREADS, bucketsForInboxName, type MockThread } from './mock-threads';

interface PreviewInboxProps {
  inboxes: InboxConfig[];
  inboxLayout: InboxLayout;
  hasCustomInboxOrder: boolean;
  /** Which tab is shown as selected. Falls back to the first. */
  activeInboxId?: string;
  onSelectInbox?: (id: string) => void;
  className?: string;
}

/**
 * Which mock threads land in a given tab.
 *
 * A sub-inbox PARTITIONS by default — mail it claims leaves the main feed —
 * unless it is set to also show in Inbox/Important, which mirrors instead. The
 * preview has to model that, because "where did my mail go" is the single thing
 * a user gets wrong about split inboxes.
 */
function partition(
  inboxes: InboxConfig[],
  inboxLayout: InboxLayout,
): Map<string, MockThread[]> {
  const result = new Map<string, MockThread[]>();
  const customs = inboxes.filter((inbox) => !inbox.system);

  // Threads a custom tab has carved out of the system feed.
  const claimed = new Set<string>();

  for (const inbox of customs) {
    const buckets = bucketsForInboxName(inbox.name);
    const threads = MOCK_THREADS.filter((t) => buckets.includes(t.bucket));
    result.set(inbox.id, threads);
    if (!inbox.alsoShowInImportant) threads.forEach((t) => claimed.add(t.id));
  }

  const remaining = MOCK_THREADS.filter((t) => !claimed.has(t.id));

  if (inboxLayout === 'important_other') {
    result.set('important', remaining.filter((t) => t.personal));
    result.set('other', remaining.filter((t) => !t.personal));
  } else {
    result.set('default', remaining);
  }

  return result;
}

function ThreadRow({ thread }: { thread: MockThread }) {
  const dim = !thread.unread;
  return (
    <div
      className={cn(
        'flex items-center gap-3 border-b px-3 py-2 text-xs last:border-b-0',
        dim && 'opacity-55',
      )}
    >
      <span
        className={cn(
          'h-1.5 w-1.5 shrink-0 rounded-full',
          thread.unread ? 'bg-[#006FFE]' : 'bg-transparent',
        )}
      />
      <span
        className={cn(
          'w-28 shrink-0 truncate',
          thread.unread ? 'text-foreground font-semibold' : 'text-foreground/80',
        )}
      >
        {thread.sender}
      </span>
      <span className="min-w-0 flex-1 truncate">
        <span className={cn('mr-1.5', thread.unread ? 'font-semibold' : 'font-medium')}>
          {thread.subject}
        </span>
        <span className="text-muted-foreground">{thread.snippet}</span>
      </span>
      {thread.starred && <span className="shrink-0 text-amber-400">★</span>}
      <span className="text-muted-foreground w-8 shrink-0 text-right tabular-nums">
        {thread.time}
      </span>
    </div>
  );
}

/**
 * A miniature, non-interactive mail client driven by the user's REAL inbox
 * settings and REAL sub-inboxes, filled with sample mail.
 *
 * Sample mail rather than the user's own is the point at this stage: the
 * settings step is about the layout rules, and a fixed cast of ten threads makes
 * the effect of flipping Important+Other legible in a way an unfamiliar slice of
 * someone's actual inbox does not.
 */
export function PreviewInbox({
  inboxes,
  inboxLayout,
  hasCustomInboxOrder,
  activeInboxId,
  onSelectInbox,
  className,
}: PreviewInboxProps) {
  const ordered = useMemo(
    () => getRowInboxOrder(inboxes, hasCustomInboxOrder),
    [inboxes, hasCustomInboxOrder],
  );
  const buckets = useMemo(() => partition(ordered, inboxLayout), [ordered, inboxLayout]);

  // A layout switch can retire the selected tab (`important` disappears when you go
  // back to one inbox). Fall through to the first tab rather than rendering empty.
  const activeId = ordered.some((inbox) => inbox.id === activeInboxId)
    ? activeInboxId
    : ordered[0]?.id;
  const activeThreads = (activeId ? buckets.get(activeId) : undefined) ?? [];

  // Unread, not total — the count you read to decide whether a tab needs you.
  const countFor = (id: string) => (buckets.get(id) ?? []).filter((t) => t.unread).length;

  const isStacked = inboxLayout === 'stacked';

  return (
    <div className={cn('flex h-full flex-col', className)}>
      {/* Tab strip — or, in stacked mode, nothing: sections carry the labels instead. */}
      {!isStacked && (
        <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 === activeId;
            const count = countFor(inbox.id);
            return (
              <button
                key=[redacted]
                type="button"
                onClick={() => onSelectInbox?.(inbox.id)}
                className={cn(
                  'flex shrink-0 cursor-pointer items-center gap-1.5 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}
                {count > 0 && <span className="tabular-nums opacity-60">{count}</span>}
              </button>
            );
          })}
        </div>
      )}

      <div className="min-h-0 flex-1 overflow-y-auto">
        {isStacked ? (
          ordered.map((inbox) => {
            const threads = buckets.get(inbox.id) ?? [];
            if (threads.length === 0) return null;
            return (
              <section key=[redacted]
                <h4 className="bg-sidebar text-muted-foreground sticky top-0 flex items-center gap-2 border-b px-3 py-1.5 text-xs font-semibold">
                  {inbox.name}
                  <span className="tabular-nums opacity-60">{countFor(inbox.id)}</span>
                </h4>
                {threads.map((thread) => (
                  <ThreadRow key=[redacted] thread={thread} />
                ))}
              </section>
            );
          })
        ) : activeThreads.length === 0 ? (
          <p className="text-muted-foreground p-6 text-center text-xs">
            Nothing lands here yet.
          </p>
        ) : (
          activeThreads.map((thread) => (
            <ThreadRow key=[redacted] thread={thread} />
          ))
        )}
      </div>
    </div>
  );
}