TaskEmailItem.tsx22.1 KBView on GitHub import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { MailLabels } from '@/modules/threads/threadList/threadItem/components/mail-labels';
import { useOptimisticActions } from '@/modules/threads/rendering/use-optimistic-actions';
import { RenderLabels } from '@/modules/threads/threadList/components/render-labels';
import type { SortableAttribute } from '@/modules/userTasks/slice/userTasksSlice';
import { Clock, Trash, Archive2, Star2, CircleX } from '@/components/icons/icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAllConversationEnumOptions } from '@/modules/crm/hooks/use-crm';
import { useThreadLabels } from '@/modules/labels/hooks/use-labels';
import { useCedarStore, useThreadData } from '@/modules/store';
import type { HydratedUserTask } from '@/modules/userTasks';
import { taskOutputLabel } from '@/modules/userTasks/utils/task-output';
import { BimiAvatar } from '@/components/ui/bimi-avatar';
import { getEnumColor } from '@/modules/crm/field-enums';
import { Skeleton } from '@/components/ui/skeleton';
import { getTypeColor } from '@/modules/crm/utils';
import { memo, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { formatDate, cn } from '@/lib/utils';
// Memoized hover actions component for tasks
const TaskHoverActions = memo(
function TaskHoverActions({
taskId,
threadId,
conversationId,
displayStarred,
isFirst,
onToggleStar,
onArchive,
onSnooze,
onDeleteTask,
onDeleteThread,
}: {
taskId: string;
threadId?: string;
conversationId?: string;
displayStarred: boolean;
isFirst: boolean;
onToggleStar: (e: React.MouseEvent) => void;
onArchive: (e: React.MouseEvent) => Promise<void>;
onSnooze: (e: React.MouseEvent) => void;
onDeleteTask: (e: React.MouseEvent) => Promise<void>;
onDeleteThread: (e: React.MouseEvent) => Promise<void>;
}) {
return (
<div
className={cn(
'bg-raised z-25 absolute right-2 flex -translate-y-1/2 items-center gap-1 rounded-xl border p-1 opacity-0 shadow-sm group-hover:opacity-100',
isFirst ? 'top-4' : 'top-[-1px]',
)}
>
{/* Debug buttons - only in development */}
{process.env.NODE_ENV === 'development' && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 font-mono text-xs"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(taskId);
}}
>
<span className="text-green-600 dark:text-green-400">{taskId}</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="mb-1 bg-white dark:bg-[#1A1A1A]"
>
Copy Task ID
</TooltipContent>
</Tooltip>
{threadId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 font-mono text-xs"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(threadId);
}}
>
<span className="text-blue-600 dark:text-blue-400">{threadId}</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="mb-1 bg-white dark:bg-[#1A1A1A]"
>
Copy Thread ID
</TooltipContent>
</Tooltip>
)}
{conversationId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 font-mono text-xs"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversationId);
}}
>
<span className="text-purple-600 dark:text-purple-400">{conversationId}</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="mb-1 bg-white dark:bg-[#1A1A1A]"
>
Copy Conversation ID
</TooltipContent>
</Tooltip>
)}
{/* Separator */}
<div className="mx-0.5 h-4 w-px bg-gray-200 dark:bg-gray-700" />
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 gap-1 px-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 [&_svg]:size-3.5"
onClick={onSnooze}
>
<Clock className="fill-orange-600 dark:fill-orange-400" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">
Snooze
</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="bg-raised mb-1"
>
Snooze this task
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-6 gap-1 px-2 hover:bg-[#FDE4E9] dark:hover:bg-[#411D23] [&_svg]:size-3.5"
onClick={onDeleteTask}
>
<CircleX className="fill-[#F43F5E]" />
<span className="text-xs font-medium text-[#F43F5E]">Delete Task</span>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="bg-raised mb-1"
>
Remove this task (keeps email)
</TooltipContent>
</Tooltip>
{/* Separator before delete thread */}
<div className="mx-0.5 h-4 w-px bg-gray-200 dark:bg-gray-600" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 overflow-visible [&_svg]:size-3.5"
onClick={onToggleStar}
>
<Star2
className={cn(
'h-4 w-4',
displayStarred
? 'fill-yellow-400 stroke-yellow-400'
: 'fill-transparent stroke-[#9D9D9D] dark:stroke-[#9D9D9D]',
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="mb-1 bg-white dark:bg-[#1A1A1A]"
>
{displayStarred ? 'Unstar' : 'Star'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 [&_svg]:size-3.5"
onClick={onArchive}
>
<Archive2 className="fill-[#9D9D9D]" />
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="bg-raised mb-1"
>
Archive
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-[#FDE4E9] dark:hover:bg-[#411D23] [&_svg]:size-3.5"
onClick={onDeleteThread}
>
<Trash className="fill-[#F43F5E]" />
</Button>
</TooltipTrigger>
<TooltipContent
side={isFirst ? 'bottom' : 'top'}
className="bg-raised mb-1"
>
Delete thread
</TooltipContent>
</Tooltip>
</div>
);
},
(prev, next) => {
return (
prev.taskId === next.taskId &&
prev.threadId === next.threadId &&
prev.conversationId === next.conversationId &&
prev.displayStarred === next.displayStarred &&
prev.isFirst === next.isFirst
);
},
);
// Task email item that displays thread data from a user task
export const TaskEmailItem = memo(
({
task,
onClick,
isSelected,
isFirst = false,
}: {
task: HydratedUserTask;
onClick: () => void;
isSelected: boolean;
isFirst?: boolean;
}) => {
// Get thread ID from the task's output payload - only for tasks that produce an email
const threadId = task.taskOutput?.kind === 'email' ? task.taskOutput.threadId : undefined;
// Use useThreadData hook (like thread.tsx) - automatically updates on thread changes
const threadData = useThreadData(threadId || '');
// Get sorting configuration for displaying sorted by badges
const sortingConfiguration = useCedarStore((state) => state.sortingConfiguration);
// Calculate derived values from threadData (like thread.tsx)
// Handle draft-only threads: if latest is undefined, use the first message
const { latestMessage, displayStarred, displayUnread, threadLabels, isDraftOnly } =
useMemo(() => {
if (!threadData) {
return {
latestMessage: undefined,
displayStarred: false,
displayUnread: false,
threadLabels: [],
isDraftOnly: false,
};
}
// Check if this is a draft-only thread
const latestMessage = threadData.latest || threadData.messages?.[0];
const isDraftOnly = !threadData.latest && threadData.messages?.[0]?.isDraft;
if (!latestMessage) {
return {
latestMessage: undefined,
displayStarred: false,
displayUnread: false,
threadLabels: [],
isDraftOnly: false,
};
}
const latestTags = latestMessage.tags || [];
const displayStarred = latestTags.some((tag) => tag.name === 'STARRED');
const displayUnread = !task.isRead;
const threadLabels = threadData.labels || [];
return {
latestMessage,
displayStarred,
displayUnread,
threadLabels,
isDraftOnly,
};
}, [threadData, task.isRead]);
// Enrich thread labels (same as thread.tsx)
const { labels: enrichedLabels } = useThreadLabels(
threadLabels ? threadLabels.map((l) => l.id) : [],
);
// Fetch dynamic enum options from AOPs
const { data: statusOptionsData } = useAllConversationEnumOptions('status');
const { data: priorityOptionsData } = useAllConversationEnumOptions('priority');
// Helper to get display value for each attribute
const getAttributeValue = useCallback(
(attr: SortableAttribute): string => {
switch (attr) {
case 'conversationStatus':
return task.conversation?.status || 'Unknown';
case 'conversationPriority':
return task.conversation?.priority || 'None';
case 'conversationType':
// crm_conversations has no `type` column — nothing to display.
return 'Unknown';
case 'daysSinceLastContact': {
const lastContactedAt = task.conversation?.lastContactedAt;
if (!lastContactedAt) return 'Never';
const days = Math.floor(
(Date.now() - new Date(lastContactedAt).getTime()) / (1000 * 60 * 60 * 24),
);
return days === 0 ? 'Today' : days === 1 ? '1 day ago' : `${days} days ago`;
}
case 'taskType':
return task.taskType || 'None';
case 'metadataType': {
// The output axis names what the task produces
return taskOutputLabel(task);
}
case 'threadLabels': {
const labelNames = threadLabels.map((l) => l.name).join(', ');
return labelNames || 'None';
}
case 'threadDate':
// For draft-only threads, use current date
if (isDraftOnly) {
return formatDate(new Date().toISOString());
}
return latestMessage?.receivedOn
? formatDate(latestMessage.receivedOn.split('.')[0] || '')
: 'No date';
default:
return '';
}
},
[task, threadLabels, latestMessage, isDraftOnly],
);
// Helper to get color class for each attribute value
const getAttributeColor = useCallback((attr: SortableAttribute, value: string): string => {
switch (attr) {
case 'conversationStatus':
return getEnumColor(value, 'status', statusOptionsData?.options);
case 'conversationPriority':
return getEnumColor(value, 'priority', priorityOptionsData?.options);
case 'conversationType':
return getTypeColor(value);
case 'daysSinceLastContact':
return 'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900 dark:text-orange-200 dark:hover:bg-orange-800';
case 'taskType':
return 'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900 dark:text-orange-200 dark:hover:bg-orange-800';
case 'metadataType':
return 'bg-cyan-100 text-cyan-800 hover:bg-cyan-200 dark:bg-cyan-900 dark:text-cyan-200 dark:hover:bg-cyan-800';
case 'threadLabels':
return 'bg-pink-100 text-pink-800 hover:bg-pink-200 dark:bg-pink-900 dark:text-pink-200 dark:hover:bg-pink-800';
case 'threadDate':
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800';
default:
return 'bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700';
}
}, []);
// Get optimistic actions for task and thread deletion
const { optimisticDeleteTask, optimisticDeleteTaskAndThread } = useOptimisticTaskActions();
const { optimisticToggleStar, optimisticMoveThreadsTo } = useOptimisticActions();
// Get global snooze dialog opener
const openSnoozeDialog = useCedarStore((state) => state.openSnoozeDialog);
// Handler to toggle star (thread action)
const handleToggleStar = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!threadId) return;
const newStarredState = !displayStarred;
optimisticToggleStar([threadId], newStarredState);
},
[threadId, displayStarred, optimisticToggleStar],
);
// Handler to archive (thread action)
const handleArchive = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!threadId) return;
optimisticMoveThreadsTo([threadId], '', 'archive');
},
[threadId, optimisticMoveThreadsTo],
);
// Handler to open snooze dialog
const handleSnooze = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
openSnoozeDialog(task.id);
},
[task.id, openSnoozeDialog],
);
// Handler to delete just the task
const handleDeleteTask = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
await optimisticDeleteTask(task.id, true);
},
[task.id, optimisticDeleteTask],
);
// Handler to delete the entire thread
const handleDeleteThread = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (threadId) {
await optimisticDeleteTaskAndThread(task.id, threadId);
}
},
[task.id, threadId, optimisticDeleteTaskAndThread],
);
// For draft-only threads, show recipient instead of sender
const displayName = useMemo(() => {
if (!latestMessage) return 'Unknown';
if (isDraftOnly && latestMessage.to && latestMessage.to.length > 0) {
// Show "to:" recipient for drafts
const recipient = latestMessage.to[0];
return recipient.name || recipient.email;
}
// Regular threads - show sender
return latestMessage.sender?.name
? latestMessage.sender.name.trim().replace(/^['"]|['"]$/g, '')
: latestMessage.sender?.email || 'Unknown';
}, [isDraftOnly, latestMessage]);
const displayEmail = useMemo(() => {
if (!latestMessage) return '';
if (isDraftOnly && latestMessage.to && latestMessage.to.length > 0) {
return latestMessage.to[0].email;
}
return latestMessage.sender?.email || '';
}, [isDraftOnly, latestMessage]);
// Show skeleton only if thread data not loaded yet AND it's not just waiting for thread fetch
if (!latestMessage) {
return (
<div className="mx-1 px-4 py-1.5">
<Skeleton className="bg-muted h-8 w-full rounded" />
</div>
);
}
return (
<div
data-thread-id={threadId}
onClick={onClick}
className={cn(
'hover:bg-muted/50 group relative mx-1 flex cursor-pointer items-center rounded-lg border-b py-1.5 text-left text-sm transition-all hover:opacity-100 md:my-1 md:border-none',
isSelected && 'border-border bg-primary/10 opacity-100',
)}
>
<TaskHoverActions
taskId={task.id}
threadId={threadId}
conversationId={task.conversationId}
displayStarred={displayStarred}
isFirst={isFirst}
onToggleStar={handleToggleStar}
onArchive={handleArchive}
onSnooze={handleSnooze}
onDeleteTask={handleDeleteTask}
onDeleteThread={handleDeleteThread}
/>
<div
className={`relative flex w-full items-center gap-4 px-2 ${displayUnread && !isSelected ? '' : 'opacity-80'}`}
>
{/* Profile pic */}
<div className="flex shrink-0 items-center">
<BimiAvatar email={displayEmail} name={displayName} className="h-6 w-6 rounded-full" />
</div>
{/* Single line content - Gmail style */}
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* Draft badges */}
{isDraftOnly ? (
<Badge
variant="secondary"
className="shrink-0 rounded-sm bg-blue-100 text-xs font-medium text-blue-800 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:hover:bg-blue-800"
>
New Draft
</Badge>
) : task.taskOutput?.kind === 'email' &&
task.taskOutput.draftId &&
threadData?.messages?.some((msg) => msg.isDraft) ? (
<Badge
variant="secondary"
className="shrink-0 rounded-sm bg-green-100 text-xs font-medium text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800"
>
Draft Ready
</Badge>
) : null}
{/* Recipient/Sender name */}
<span
className={cn(
displayUnread && !isSelected ? 'font-semibold' : 'font-normal',
'text-foreground flex shrink-0 items-center gap-1 text-sm',
)}
>
{isDraftOnly && <span className="text-muted-foreground text-xs">to:</span>}
<span className="max-w-[12ch] truncate md:max-w-[20ch]">{displayName}</span>
{displayUnread && !isSelected && (
<span className="ml-0.5 size-2 shrink-0 rounded-full bg-[#006FFE]" />
)}
</span>
{/* Subject */}
{latestMessage.subject && (
<>
<span className="text-muted-foreground shrink-0 text-sm">-</span>
<span
className={cn(
displayUnread && !isSelected ? 'font-medium' : 'font-normal',
'text-foreground truncate text-sm',
)}
>
{latestMessage.subject}
</span>
</>
)}
{/* Labels - render inline with content, will hide first when space is tight */}
<div className="flex min-w-0 flex-shrink-[999] items-center gap-1 overflow-hidden">
<MailLabels labels={threadLabels} />
{enrichedLabels && <RenderLabels labels={enrichedLabels} />}
</div>
{/* Body preview */}
{latestMessage.snippet && (
<>
<span className="text-muted-foreground shrink-0 text-sm">-</span>
<span className="text-muted-foreground min-w-0 flex-1 truncate text-sm">
{latestMessage.snippet}
</span>
</>
)}
</div>
{/* Sorted By Badges - show sorting configuration */}
{sortingConfiguration.sort.length > 0 && (
<div className="flex shrink-0 items-center gap-2">
{sortingConfiguration.sort
.filter((attr) => sortingConfiguration.badgeVisibility?.[attr] !== false)
.map((attr) => {
const value = getAttributeValue(attr);
return (
<Badge
key=[redacted]
variant="secondary"
className={cn(
'rounded-sm text-xs font-medium',
getAttributeColor(attr, value),
)}
>
{value}
</Badge>
);
})}
</div>
)}
</div>
</div>
</div>
);
},
);
TaskEmailItem.displayName = 'TaskEmailItem';