From 013ef7d5576541d86753eb99117d015b59a916ba Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 10:56:33 +0200 Subject: [PATCH] fix: remove unused code This code is nowhere used, so to prevent extra work during an upcoming refactor of tags, it is removed and some related tests are now actually made useful --- .../email/__tests__/email-list-item.test.tsx | 154 -------- .../email/__tests__/thread-list-item.test.tsx | 184 +++++++++ components/email/email-list-item.tsx | 357 ------------------ 3 files changed, 184 insertions(+), 511 deletions(-) delete mode 100644 components/email/__tests__/email-list-item.test.tsx create mode 100644 components/email/__tests__/thread-list-item.test.tsx delete mode 100644 components/email/email-list-item.tsx 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