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';