"use client"; import React, { useCallback } from "react"; import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; import { useTranslations } from "next-intl"; /** * Small chip showing the originating folder of a message, rendered in the * aggregate "All …" views (All Mail / unified / cross-account) where rows come * from different folders. `email.sourceFolder` is stamped at fetch time. */ function SourceFolderTag({ name }: { name: string }) { return ( {name} ); } interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; selectedEmailId?: string; isLoading?: boolean; expandedEmails?: Email[]; onToggleExpand: () => void; onCollapseAllThreads?: () => void; onEmailSelect: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; onOpenConversation?: (thread: ThreadGroup) => void; onToggleStar?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } interface SingleEmailItemProps { email: Email; selected: boolean; onClick: () => void; onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; colorTag: string | null; onToggleStar?: () => void; onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; onSetColorTag?: (color: string | null) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). In aggregate role-views the selected mailbox is virtual → fall back // to the unified role so junk-contextual UI and avatar hiding work. const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; // Show the originating folder in the aggregate "All …" views. const showSourceFolder = isUnifiedView && !!email.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); const isMobile = useUIStore((state) => state.isMobile); // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const scheduledSendLabel = email.isScheduled && email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : null; // Resolve color tags using keyword definitions; unknown tags fall back to gray const tagIds = getEmailColorTags(email.keywords); const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedColorTag = !tintListRowsByTag ? null : (() => { if (colorTag) return colorTag; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; })(); const { dragHandlers, isDragging } = useEmailDrag({ email, sourceMailboxId: selectedMailbox, }); const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, email ); }, [onContextMenu, email]), isMobile ); const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }; const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); if (e.shiftKey) { selectRangeEmails(email.id); } else { toggleEmailSelection(email.id); } }; const handleContextMenu = (e: React.MouseEvent) => { onContextMenu?.(e, email); }; const handleClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); toggleEmailSelection(email.id); } else if (e.shiftKey) { e.preventDefault(); selectRangeEmails(email.id); } else { if (selectedEmailIds.size > 0) clearSelection(); onClick(); } }; return ( { if (e.ctrlKey || e.metaKey || e.shiftKey) return; if (!onDoubleClick) return; e.preventDefault(); onDoubleClick(); }} onContextMenu={handleContextMenu} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} > {/* Checkbox - only visible when in selection mode */} {selectedEmailIds.size > 0 && ( {isChecked ? ( ) : ( )} )} {isUnread && ( )} {density !== 'extra-compact' && ( toggleEmailSelection(email.id)} selectLabel={tBatch('select')} /> )} {isFocusedMailLayout ? ( {isUnifiedView && email.accountId && accountColor && ( )} {sender?.name || sender?.email || 'Unknown'} {email.subject || '(no subject)'} {inlinePreview && ( {inlinePreview} )} {isPinned && } {isStarred && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( <> > )} {email.hasAttachment && } {resolvedKeywordDefs.map((kd) => ( ))} {showSourceFolder && } {scheduledSendLabel ? ( {scheduledSendLabel} ) : ( {formatDate(email.receivedAt)} )} ) : ( <> {isUnifiedView && email.accountId && accountColor && ( )} {sender?.name || sender?.email || "Unknown"} {isPinned && ( )} {isStarred && ( )} {isAnswered && !isForwarded && ( )} {isForwarded && !isAnswered && ( )} {isAnswered && isForwarded && ( <> > )} {email.hasAttachment && ( )} {resolvedKeywordDefs.map((kd) => ( {kd.label} ))} {showSourceFolder && } {scheduledSendLabel ? ( {scheduledSendLabel} ) : ( {formatDate(email.receivedAt)} )} {email.subject || "(no subject)"} {showPreview && density !== 'extra-compact' && density !== 'compact' && ( {trimmedPreview || t('no_preview_available')} )} > )} {/* Hover Quick Actions */} {!email.isScheduled && ( )} ); } ); export const ThreadListItem = React.forwardRef( function ThreadListItem({ thread, isExpanded, selectedEmailId, isLoading = false, expandedEmails, onToggleExpand, onCollapseAllThreads, onEmailSelect, onEmailDoubleClick, onContextMenu, onOpenConversation, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam, }, ref) { const t = useTranslations('threads'); const tEmailViewer = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const showPreview = useSettingsStore((state) => state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt ? formatDateTime(latestEmail.scheduledSendAt, timeFormat) : null; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). Aggregate role-views use a virtual selected mailbox → fall back to // the unified role so junk-contextual UI and avatar hiding work. const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const displayNames = showRecipient ? Array.from(new Set( thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0])) )).slice(0, 4) : participantNames; const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0]; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({ email: latestEmail, sourceMailboxId: selectedMailbox, threadEmails: thread.emails, }); const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( { preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent, latestEmail ); }, [onContextMenu, latestEmail]), isMobile ); const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel }; const threadColor = getThreadColorTag(thread.emails); const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id)); if (emailCount === 1) { return ( onEmailSelect(latestEmail)} onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} colorTag={colorTag} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> ); } const emailsToShow = expandedEmails || thread.emails; // Toggle selection for all emails in this thread. const toggleThreadSelection = () => { const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds); thread.emails.forEach(em => { if (allSelected) { newSelection.delete(em.id); } else { newSelection.add(em.id); } }); useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id }); }; const handleThreadCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); if (e.shiftKey) { selectRangeEmails(latestEmail.id); return; } toggleThreadSelection(); }; const handleHeaderClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); // Ctrl+Click: toggle selection for all thread emails thread.emails.forEach(em => toggleEmailSelection(em.id)); return; } if (e.shiftKey) { e.preventDefault(); selectRangeEmails(latestEmail.id); return; } if (isMobile && onOpenConversation) { onOpenConversation(thread); return; } const target = e.target as HTMLElement; if (target.closest('[data-expand-toggle]')) { onToggleExpand(); } else { if (selectedEmailIds.size > 0) clearSelection(); if (!isExpanded) { onCollapseAllThreads?.(); onToggleExpand(); } onEmailSelect(latestEmail); } }; const handleContextMenu = (e: React.MouseEvent) => { onContextMenu?.(e, latestEmail); }; return ( { if (e.ctrlKey || e.metaKey || e.shiftKey) return; if (!onEmailDoubleClick) return; e.preventDefault(); onEmailDoubleClick(latestEmail); }} onContextMenu={handleContextMenu} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} > {/* Checkbox for thread selection - only visible when in selection mode */} {selectedEmailIds.size > 0 && ( {isChecked ? ( ) : ( )} )} {hasUnread && ( )} {density !== 'extra-compact' && ( {!isMobile && ( { e.stopPropagation(); onToggleExpand(); }} className={cn( "absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full", "transition-all duration-200", "hover:bg-muted/50 hover:scale-110", "active:scale-95", "text-muted-foreground hover:text-foreground", "bg-background border border-border" )} aria-expanded={isExpanded} aria-label={t('toggle_thread')} > {isLoading ? ( ) : isExpanded ? ( ) : ( )} )} )} {isFocusedMailLayout ? ( {isUnifiedView && latestEmail.accountId && threadAccountColor && ( )} {displayNames.join(', ')} {emailCount} {latestEmail.subject || '(no subject)'} {inlinePreview && ( {inlinePreview} )} {hasPinned && } {hasStarred && } {hasAnswered && !hasForwarded && } {hasForwarded && !hasAnswered && } {hasAnswered && hasForwarded && ( <> > )} {hasAttachment && } {keywordDef && ( )} {showSourceFolder && } {scheduledSendLabel ? ( {scheduledSendLabel} ) : ( {formatDate(latestEmail.receivedAt)} )} ) : ( <> {isUnifiedView && latestEmail.accountId && threadAccountColor && ( )} {displayNames.join(", ")} {emailCount} {hasPinned && ( )} {hasStarred && ( )} {hasAnswered && !hasForwarded && ( )} {hasForwarded && !hasAnswered && ( )} {hasAnswered && hasForwarded && ( <> > )} {hasAttachment && ( )} {keywordDef && ( {keywordDef.label} )} {showSourceFolder && } {scheduledSendLabel ? ( {scheduledSendLabel} ) : ( {formatDate(latestEmail.receivedAt)} )} {latestEmail.subject || "(no subject)"} {showPreview && density !== 'extra-compact' && density !== 'compact' && ( {trimmedPreview || tEmailViewer('no_preview_available')} )} > )} {/* Hover Quick Actions for thread header */} {!latestEmail.isScheduled && ( onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')} /> )} {isExpanded && !isMobile && ( {isLoading ? ( {t('loading')} ) : ( emailsToShow.map((email, index) => ( onEmailSelect(email)} onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined} onContextMenu={onContextMenu} /> )) )} )} ); } );
{trimmedPreview || t('no_preview_available')}
{trimmedPreview || tEmailViewer('no_preview_available')}