diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx deleted file mode 100644 index 72d41e35..00000000 --- a/components/email/__tests__/email-list-item.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { render, screen, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EmailListItem } from '../email-list-item'; -import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; -import { useEmailStore } from '@/stores/email-store'; -import type { Email } from '@/lib/jmap/types'; - -// Mock the drag hook -vi.mock('@/hooks/use-email-drag', () => ({ - useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), -})); - -// Mock identity badge -vi.mock('../email-identity-badge', () => ({ - EmailIdentityBadge: () => null, -})); - -// Mock auth store -vi.mock('@/stores/auth-store', () => ({ - useAuthStore: () => ({ identities: [] }), -})); - -const makeEmail = (overrides: Partial = {}): Email => ({ - id: 'email-1', - threadId: 'thread-1', - mailboxIds: { inbox: true }, - keywords: { $seen: true }, - size: 1000, - receivedAt: '2024-01-15T10:00:00Z', - from: [{ name: 'Alice', email: 'alice@example.com' }], - subject: 'Test Subject', - hasAttachment: false, - ...overrides, -}); - -describe('EmailListItem tag badge', () => { - beforeEach(() => { - useSettingsStore.setState({ - emailKeywords: [...DEFAULT_KEYWORDS], - showPreview: false, - mailLayout: 'split', - }); - useEmailStore.setState({ - selectedEmailIds: new Set(), - selectedMailbox: 'inbox', - }); - }); - - it('does not show tag badge when email has no label keyword', () => { - const email = makeEmail({ keywords: { $seen: true } }); - render(); - expect(screen.getByText('Test Subject')).toBeInTheDocument(); - // No keyword label should appear - DEFAULT_KEYWORDS.forEach((kw) => { - expect(screen.queryByText(kw.label)).not.toBeInTheDocument(); - }); - }); - - it('shows tag badge with label when email has $label: keyword', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); - render(); - expect(screen.getByText('Red')).toBeInTheDocument(); - }); - - it('shows tag badge for legacy $color: keyword', () => { - const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } }); - render(); - expect(screen.getByText('Blue')).toBeInTheDocument(); - }); - - it('shows a gray fallback badge when keyword id is not in settings', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }); - render(); - // Unknown tags fall back to the raw id as label with a gray dot - // (see email-list-item.tsx: keywordDefs fallback). - expect(screen.getByText('unknown-tag')).toBeInTheDocument(); - }); - - it('shows custom keyword label', () => { - useSettingsStore.setState({ - emailKeywords: [ - ...DEFAULT_KEYWORDS, - { id: 'work', label: 'Work', color: 'teal' }, - ], - }); - const email = makeEmail({ keywords: { $seen: true, '$label:work': true } }); - render(); - expect(screen.getByText('Work')).toBeInTheDocument(); - }); - - it('updates badge when keyword definition changes', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); - const { rerender } = render(); - expect(screen.getByText('Red')).toBeInTheDocument(); - - // Update label name - act(() => { - useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); - }); - rerender(); - expect(screen.getByText('Urgent')).toBeInTheDocument(); - expect(screen.queryByText('Red')).not.toBeInTheDocument(); - }); - - it('renders subject even without tag', () => { - const email = makeEmail({ subject: 'Hello World' }); - render(); - expect(screen.getByText('Hello World')).toBeInTheDocument(); - }); - - it('renders inline preview text in focused mail layout', () => { - useSettingsStore.setState({ - showPreview: true, - mailLayout: 'focus', - }); - const email = makeEmail({ preview: 'Inline preview content' }); - const { container } = render(); - - expect(screen.getByText('Test Subject')).toBeInTheDocument(); - expect(screen.getByText(/Inline preview content/)).toBeInTheDocument(); - expect(container.querySelector('p')).toBeNull(); - }); -}); - -describe('EmailListItem shift-range checkbox', () => { - beforeEach(() => { - useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); - }); - - it('shift-clicking the checkbox extends the selection from the anchor', () => { - const e1 = makeEmail({ id: 'e1', threadId: 't1' }); - const e2 = makeEmail({ id: 'e2', threadId: 't2' }); - const e3 = makeEmail({ id: 'e3', threadId: 't3' }); - // selection mode active (so the checkbox renders), anchor on e1 - useEmailStore.setState({ - emails: [e1, e2, e3], - selectedEmailIds: new Set(['e1']), - lastSelectedEmailId: 'e1', - selectedMailbox: 'inbox', - }); - - render(); - // the checkbox is the first button in the row (shown in selection mode) - const checkbox = screen.getAllByRole('button')[0]; - act(() => { - checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); - }); - - const sel = useEmailStore.getState().selectedEmailIds; - expect(sel.has('e1')).toBe(true); - expect(sel.has('e2')).toBe(true); // the in-between row got filled in - expect(sel.has('e3')).toBe(true); - }); -}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx new file mode 100644 index 00000000..9b4ac331 --- /dev/null +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: () => ({ identities: [] }), +})); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + ...overrides, +}); + +/** + * A one-message thread, built through the real grouping so the fixture cannot + * drift from what the list actually feeds this component. `ThreadListItem` + * delegates to `SingleEmailItem` at that size, which is what draws every + * single-message row in the app. + */ +function renderRow(email: Email) { + const [thread] = groupEmailsByThread([email]); + return render( + {}} + onEmailSelect={() => {}} + />, + ); +} + +describe('ThreadListItem tag badge', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + it('does not show a tag badge when the email has no label keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true } })); + + expect(screen.getByText('Test Subject')).toBeInTheDocument(); + DEFAULT_KEYWORDS.forEach((kw) => { + expect(screen.queryByText(kw.label)).not.toBeInTheDocument(); + }); + }); + + it('shows a tag badge for a $label: keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + + expect(screen.getByText('Red')).toBeInTheDocument(); + }); + + it('shows a tag badge for the legacy $color: keyword', () => { + renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } })); + + expect(screen.getByText('Blue')).toBeInTheDocument(); + }); + + it('falls back to the raw id when the tag is not in settings', () => { + // A keyword created by another client, or one whose definition was deleted. + renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } })); + + expect(screen.getByText('unknown-tag')).toBeInTheDocument(); + }); + + it('shows a custom tag label', () => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }], + }); + renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } })); + + expect(screen.getByText('Work')).toBeInTheDocument(); + }); + + it('follows a renamed tag definition', () => { + const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); + const { rerender } = renderRow(email); + expect(screen.getByText('Red')).toBeInTheDocument(); + + act(() => { + useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); + }); + const [thread] = groupEmailsByThread([email]); + rerender( + {}} + onEmailSelect={() => {}} + />, + ); + + expect(screen.getByText('Urgent')).toBeInTheDocument(); + expect(screen.queryByText('Red')).not.toBeInTheDocument(); + }); +}); + +describe('ThreadListItem row content', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + it('renders the subject without a tag', () => { + renderRow(makeEmail({ subject: 'Hello World' })); + + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('renders preview text inline in the focused layout', () => { + useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' }); + const { container } = renderRow(makeEmail({ preview: 'Inline preview content' })); + + expect(screen.getByText('Test Subject')).toBeInTheDocument(); + expect(screen.getByText(/Inline preview content/)).toBeInTheDocument(); + // Focused rows are one line: the preview shares the subject's element + // rather than getting a paragraph of its own. + expect(container.querySelector('p')).toBeNull(); + }); +}); + +describe('ThreadListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // Selection mode active so the checkbox renders, with the anchor on e1. + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + renderRow(e3); + const checkbox = screen.getAllByRole('button')[0]; + act(() => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + }); + + const selected = useEmailStore.getState().selectedEmailIds; + expect(selected.has('e1')).toBe(true); + expect(selected.has('e2')).toBe(true); // the row in between got filled in + expect(selected.has('e3')).toBe(true); + }); +}); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx deleted file mode 100644 index ac6178fb..00000000 --- a/components/email/email-list-item.tsx +++ /dev/null @@ -1,357 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { useCallback } from "react"; -import { formatDate, stripInvisibleLeading } from "@/lib/utils"; -import { Email } from "@/lib/jmap/types"; -import { cn } from "@/lib/utils"; -import { SelectableAvatar } from "@/components/email/selectable-avatar"; -import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; -import { useEmailStore } from "@/stores/email-store"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { useEmailDrag } from "@/hooks/use-email-drag"; -import { useLongPress } from "@/hooks/use-long-press"; -import { useUIStore } from "@/stores/ui-store"; -import { EmailIdentityBadge } from "./email-identity-badge"; -import { EmailHoverActions } from "./email-hover-actions"; -import { getEmailColorTags } from "@/lib/thread-utils"; -import { useKeywordFormat } from "@/hooks/use-keyword-format"; - -interface EmailListItemProps { - email: Email; - selected?: boolean; - onClick?: () => void; - onDoubleClick?: () => void; - onContextMenu?: (e: React.MouseEvent, email: Email) => void; - onToggleStar?: () => void; - onMarkAsRead?: (read: boolean) => void; - onDelete?: () => void; - onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; - onMarkAsSpam?: () => void; - onUndoSpam?: () => void; -} - -export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) { - const t = useTranslations('email_viewer'); - const tBatch = useTranslations('email_list.batch_actions'); - const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); - const showPreview = useSettingsStore((state) => state.showPreview); - const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); - const { identities } = useAuthStore(); - const isChecked = selectedEmailIds.has(email.id); - const isUnread = !email.keywords?.$seen; - const isStarred = email.keywords?.$flagged; - const isPinned = email.keywords?.['$pinned'] === true; - const isImportant = email.keywords?.["$important"]; - const isAnswered = email.keywords?.$answered; - const isForwarded = email.keywords?.$forwarded; - // 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 (spam ↔ not-spam) 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 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 hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); - const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; - - // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray - const colorTagIds = getEmailColorTags(email.keywords); - const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - // Use first tag for background coloring - const keywordDef = keywordDefs[0] ?? null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; - - // Drag and drop functionality - 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) { - // Shift-click extends the selection from the anchor to here, like - // shift-clicking the row (the checkbox stops propagation, so the - // row's shift handler never runs — replicate it here). - selectRangeEmails(email.id); - } else { - toggleEmailSelection(email.id); - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - onContextMenu?.(e, email); - }; - - return ( -
{ - 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?.(); - } - }} - onDoubleClick={(e) => { - 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 && ( - - )} - - {/* Unread indicator */} - {isUnread && ( -
- -
- )} - - {/* Avatar */} - {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} - selectLabel={tBatch('select')} - /> - )} - - {/* Content */} -
- {isFocusedMailLayout ? ( -
-
- - {sender?.name || sender?.email || 'Unknown'} - -
- - {email.subject || t('no_subject')} - - {inlinePreview && ( - {inlinePreview} - )} -
-
-
- {isPinned && } - {isStarred && } - {isImportant && } - {isAnswered && !isForwarded && } - {isForwarded && !isAnswered && } - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && } - {keywordDefs.map((kd) => ( - - ))} - - {formatDate(email.receivedAt)} - -
-
- ) : ( - <> - {/* First Line: Sender and Date */} -
-
- - {sender?.name || sender?.email || "Unknown"} - -
- {isPinned && ( - - )} - {isStarred && ( - - )} - {isImportant && ( - - Important - - )} - - {isAnswered && !isForwarded && ( - - )} - {isForwarded && !isAnswered && ( - - )} - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && ( - - )} -
-
-
- {keywordDefs.map((kd) => ( - - - {kd.label} - - ))} - - {formatDate(email.receivedAt)} - -
-
- - {/* Second Line: Subject */} -
- {email.subject || t('no_subject')} -
- - {/* Third Line: Preview (controlled by showPreview setting) */} - {showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {trimmedPreview || t('no_preview_available')} -

- )} - - )} -
-
- - {/* Hover Quick Actions */} - -
- ); -} \ No newline at end of file