InboxRow.tsx13.3 KBView on GitHub
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react';

import { Archive, Clock, Star } from '@/components/icons/icons';
import { BimiAvatar } from '@/components/ui/bimi-avatar';
import { Checkbox } from '@/components/ui/checkbox';
import { useCedarStore } from '@/modules/store';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ChannelBadgeIcon } from '@/modules/inbox/components/channel-icons';
import {
  slackTextToPlain,
  type ResolveSlackUserId,
} from '@/modules/conversations/components/timeline/SlackBodyRenderer';
import type { InboxItem } from '@/modules/inbox/types';
import { cn, formatDate } from '@/lib/utils';

interface InboxRowProps {
  item: InboxItem;
  onOpen: (item: InboxItem) => void;
  /** Resolved avatar URL override (e.g. a live-resolved Slack profile picture). */
  avatarUrl?: string;
  /** Resolve a Slack `(workspaceId, userId)` → display name, for decoding `<@U…>` mentions. */
  resolveSlackName?: (ws?: string, uid?: string) => string | undefined;
  /** Optional row actions — wired for channel items (email keeps its own path). */
  onMarkDone?: (item: InboxItem) => void;
  onSnooze?: (item: InboxItem) => void;
  onToggleStar?: (item: InboxItem) => void;
}

function RowAction({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            onClick();
          }}
          className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors"
        >
          {children}
        </button>
      </TooltipTrigger>
      <TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
        {label}
      </TooltipContent>
    </Tooltip>
  );
}

/**
 * One channel-agnostic inbox row, styled to match the email `Thread` row:
 * `[avatar] [name] [channel icon] [· subtitle] - [snippet] [date]`. Non-email
 * channels show their brand badge; email rows omit it (email is the base).
 * Empty-safe — every field falls back. See omni-channel-inbox.md Phase 3.
 */
export const InboxRow = memo(function InboxRow({
  item,
  onOpen,
  avatarUrl,
  resolveSlackName,
  onMarkDone,
  onSnooze,
  onToggleStar,
}: InboxRowProps) {
  // Slack ingest doesn't always resolve the last sender's display name (it stores
  // the slack_user_id but the name backfill can lag / miss). Fall back to the
  // live-resolved principal name (same source as the avatar) before the generic
  // server fallback, so rows show a real name instead of "Slack".
  const resolvedSlackSender =
    item.channel === 'slack' && item.ref.kind === 'slack'
      ? resolveSlackName?.(item.ref.workspaceId, item.ref.slackUserId)
      : undefined;
  const name =
    resolvedSlackSender || item.counterpart.name || item.counterpart.email || 'Unknown';
  // Mirror the email row's "me, <participant>" when we sent the last message, so
  // you can tell at a glance that you already responded (chat channels only).
  const displayName = item.channel !== 'email' && item.lastFromSelf ? `me, ${name}` : name;
  // Channel items carry per-item state; email keeps its own Gmail-backed path.
  const hasActions = item.channel !== 'email' && (!!onMarkDone || !!onSnooze || !!onToggleStar);
  // Exit animation — reuses the same `threadExitAnimation` event email rows use, so
  // an inbox action slides/collapses the row out before it's dropped from the cache.
  const [exitAnimation, setExitAnimation] = useState<'archive' | 'delete' | null>(null);
  useEffect(() => {
    const handler = (event: Event) => {
      const detail = (event as CustomEvent<{ threadIds: string[]; animationType: 'archive' | 'delete' | null }>).detail;
      if (detail.threadIds.includes(item.id)) setExitAnimation(detail.animationType);
    };
    window.addEventListener('threadExitAnimation', handler);
    return () => window.removeEventListener('threadExitAnimation', handler);
  }, [item.id]);

  const isSlack = item.channel === 'slack';
  // Slack DMs (channel id prefix `D`) have no channel to label — show just the
  // person's name (the primary `displayName` above), so suppress the subtitle.
  const isSlackDm = item.ref.kind === 'slack' && item.ref.slackChannelId?.startsWith('D');
  const showSubtitle = !!item.counterpart.subtitle && !isSlackDm;
  // Slack snippets are raw mrkdwn (`<@U…>`, `<#C…|name>`, `*bold*`); decode to
  // readable text like the thread view does (mentions → @Name when resolved).
  const workspaceId = item.ref.kind === 'slack' ? item.ref.workspaceId : undefined;
  const mentionResolver = useMemo<ResolveSlackUserId>(
    () => (uid) => {
      const dn = resolveSlackName?.(workspaceId, uid);
      return dn ? { displayName: dn } : null;
    },
    [resolveSlackName, workspaceId],
  );
  const snippetText = isSlack ? slackTextToPlain(item.snippet, mentionResolver) : item.snippet;

  // Cross-channel selection: rows live in the inbox slice's merged order, and
  // `bulkSelected` holds unified ids — so a checkbox here participates in the same
  // selection as email `Thread` rows (Slack + LinkedIn + email select together).
  const [isHovered, setIsHovered] = useState(false);
  const isBulkSelected = useCedarStore((s) => s.bulkSelected.includes(item.id));
  // Single-select (the `s` hotkey) writes `selectedThreadId`, not `bulkSelected`,
  // so reflect it here too — otherwise `s` highlights email `<Thread>` rows (which
  // read selectedThreadId) but not channel rows.
  const isSelected = useCedarStore((s) => s.selectedThreadId === item.id);
  const setBulkSelected = useCedarStore((s) => s.setBulkSelected);
  const selectThreadId = useCedarStore((s) => s.selectThreadId);
  const handleCheckboxChange = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      const store = useCedarStore.getState();
      const list = store.getUnifiedInboxList();
      const clickedIndex = list.findIndex((i) => i.id === item.id);
      if (clickedIndex === -1) return;
      const currentBulk = store.bulkSelected;
      if (e.shiftKey && currentBulk.length > 0) {
        // Range from the first selected row to this one (across channels).
        const anchorIndex = list.findIndex((i) => i.id === currentBulk[0]);
        if (anchorIndex !== -1) {
          const start = Math.min(anchorIndex, clickedIndex);
          const end = Math.max(anchorIndex, clickedIndex);
          const rangeIds = list.slice(start, end + 1).map((i) => i.id);
          const next = [...new Set([...currentBulk, ...rangeIds])];
          setBulkSelected(next);
          selectThreadId(next[0]);
          return;
        }
      }
      const next = currentBulk.includes(item.id)
        ? currentBulk.filter((id) => id !== item.id)
        : [...currentBulk, item.id];
      setBulkSelected(next);
      if (next.length > 0) selectThreadId(next[0]);
    },
    [item.id, setBulkSelected, selectThreadId],
  );

  const exitVariants = {
    initial: { x: 0, opacity: 1 },
    delete: { x: '-100%', opacity: 0, height: 0, transition: { duration: 0.2, ease: 'easeOut' as const } },
    archive: { x: '100%', opacity: 0, height: 0, transition: { duration: 0.2, ease: 'easeOut' as const } },
  };

  return (
    <motion.div
      className="group relative"
      style={exitAnimation ? { overflow: 'hidden' } : undefined}
      initial="initial"
      animate={exitAnimation || 'initial'}
      variants={exitVariants}
    >
      <div className="border-border/75 mx-14 border-t" />
      <div
        // Keyboard nav scrolls the focused row into view by this attribute (same as the
        // email `<Thread>` row) — the unibox holds unified ids in the same selection state.
        data-thread-id={item.id}
        onClick={() => onOpen(item)}
        onMouseEnter={() => {
          setIsHovered(true);
          window.dispatchEvent(new CustomEvent('inboxItemHover', { detail: { item } }));
        }}
        onMouseLeave={() => {
          setIsHovered(false);
          window.dispatchEvent(new CustomEvent('inboxItemHover', { detail: { item: null } }));
        }}
        className={cn(
          'mx-12 flex cursor-pointer select-none items-center gap-4 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
          'hover:bg-action/12',
          (isBulkSelected || isSelected) && 'border-border bg-action/8 dark:bg-action/15',
        )}
      >
        {/* Avatar, or a checkbox on hover / when selected (mirrors the email row). */}
        <div
          className="relative flex h-6 w-6 shrink-0 items-center justify-center"
          onClick={handleCheckboxChange}
        >
          {isHovered || isBulkSelected ? (
            <>
              <div className="hover:bg-primary/20 absolute left-1/2 top-1/2 z-10 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors" />
              <Checkbox checked={isBulkSelected} className="relative h-4 w-4" />
            </>
          ) : (
            <BimiAvatar
              email={item.counterpart.email || ''}
              name={name}
              avatarUrl={avatarUrl ?? item.counterpart.avatarUrl}
              className="h-6 w-6 rounded-full"
            />
          )}
        </div>

        <div className="flex min-w-0 flex-1 items-center gap-2">
          {/* Participant name */}
          <span
            className={cn(
              'text-foreground w-36 shrink-0 truncate xl:w-44 2xl:w-52',
              item.unread ? 'font-bold' : 'font-normal',
            )}
          >
            {displayName}
          </span>

          {/* Unread dot — always reserves space so the row lines up */}
          <span
            className={cn(
              'mr-0.5 size-2 shrink-0 rounded-full bg-[#006FFE]',
              !item.unread && 'invisible',
            )}
          />

          {/* Channel badge — sits in the subject position, right after the dot.
              Email is the default surface, so email rows render like normal mail
              (no badge); only the other channels carry their brand mark. */}
          {item.channel !== 'email' && (
            <ChannelBadgeIcon channel={item.channel} className="h-4 w-4 shrink-0" />
          )}

          {/* Slack channel name / LinkedIn headline — capped so a long profile
              headline truncates instead of shoving the snippet off the row
              (shrink-0 + truncate can't ellipsize; a max-width can). Slack
              channel names read as the row's subject, so bold them; LinkedIn
              headlines stay subdued. */}
          {showSubtitle && (
            <span
              className={cn(
                'max-w-[16rem] shrink truncate text-sm',
                isSlack ? 'text-foreground font-semibold' : 'text-muted-foreground',
              )}
            >
              {item.counterpart.subtitle}
            </span>
          )}

          {/* Email subject */}
          {item.subject && (
            <span
              className={cn(
                'text-foreground truncate text-sm',
                item.unread ? 'font-bold' : 'font-normal',
              )}
            >
              {item.subject}
            </span>
          )}

          {/* Draft chip */}
          {item.hasDraft && <span className="shrink-0 text-[#d93025]">Draft</span>}

          {/* Snippet */}
          {snippetText && (
            <>
              <span className="text-muted-foreground shrink-0 text-sm">-</span>
              <span className="text-muted-foreground min-w-0 flex-1 truncate text-sm">
                {snippetText}
              </span>
            </>
          )}
        </div>

        {/* Hover actions — replace the date on hover (matches the mail row). */}
        {hasActions ? (
          <div className="relative w-16 shrink-0">
            <p className="text-muted-foreground text-right text-xs font-normal opacity-85 group-hover:opacity-0 dark:text-[#8C8C8C]">
              {item.sortedAt ? formatDate(item.sortedAt) : ''}
            </p>
            <div className="absolute inset-y-0 right-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
              {onToggleStar && (
                <RowAction label={item.starred ? 'Unstar' : 'Star'} onClick={() => onToggleStar(item)}>
                  <Star
                    className={cn(
                      'h-4 w-4',
                      item.starred
                        ? 'fill-yellow-400 stroke-yellow-400'
                        : 'fill-transparent stroke-[#9D9D9D]',
                    )}
                  />
                </RowAction>
              )}
              {onSnooze && (
                <RowAction label="Snooze" onClick={() => onSnooze(item)}>
                  <Clock className="fill-iconLight dark:fill-iconDark h-3.5 w-3.5" />
                </RowAction>
              )}
              {onMarkDone && (
                <RowAction label="Mark done" onClick={() => onMarkDone(item)}>
                  <Archive className="fill-iconLight dark:fill-iconDark h-4 w-4" />
                </RowAction>
              )}
            </div>
          </div>
        ) : (
          <p className="text-muted-foreground w-16 shrink-0 text-right text-xs font-normal opacity-85 dark:text-[#8C8C8C]">
            {item.sortedAt ? formatDate(item.sortedAt) : ''}
          </p>
        )}
      </div>
    </motion.div>
  );
});