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