diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx new file mode 100644 index 000000000..f4406bac2 --- /dev/null +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { Email } from '@/lib/jmap/types'; + +// onEmailListItemRender: plugins add small badges next to a row's subject. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +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, +}); + +function row(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return ( + {}} onEmailSelect={() => {}} /> + ); +} + +beforeEach(() => { + clearAllHooks(); + clearRowBadgeCache(); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); +}); + +describe('plugin badges in message-list rows', () => { + it('shows the badges a plugin returns, with the email as a plain summary', async () => { + const handler = vi.fn(async (badges: unknown[]) => [ + ...badges, + { key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' }, + ]); + renderHooks.onEmailListItemRender.register('test', handler); + + render(row([makeEmail()])); + + const badge = await screen.findByText('Encrypted'); + expect(badge).toHaveAttribute('title', 'Signed and encrypted'); + expect(badge.style.borderColor).not.toBe(''); + expect(handler).toHaveBeenCalledTimes(1); + const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record }]; + expect(initial).toEqual([]); + expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] }); + // A projection, not the store object. + expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); + }); + + it('shows badges on a conversation row and on the phone layout', async () => { + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]); + + render(row([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }), + ])); + + expect(await screen.findByText('Phone badge')).toBeInTheDocument(); + }); + + it('renders the label as text, never as HTML', async () => { + renderHooks.onEmailListItemRender.register('test', async () => [ + { key: 'x', label: '' }, + ]); + + const { container } = render(row([makeEmail()])); + + expect(await screen.findByText('')).toBeInTheDocument(); + expect(container.querySelector('img[src="x"]')).toBeNull(); + }); + + it('asks plugins once per message and keyword state, not on every render', async () => { + const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]); + renderHooks.onEmailListItemRender.register('test', handler); + const email = makeEmail(); + + const view = render(row([email])); + await screen.findByText('Badge'); + view.rerender(row([{ ...email }])); + view.unmount(); + render(row([{ ...email }])); + await screen.findByText('Badge'); + expect(handler).toHaveBeenCalledTimes(1); + + // Marking it unread is a new keyword state: ask again. + render(row([{ ...email, keywords: {} }])); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }); + + it('does nothing without a handler', async () => { + const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform'); + render(row([makeEmail()])); + await act(async () => {}); + expect(transform).not.toHaveBeenCalled(); + expect(screen.queryByTestId('plugin-row-badges')).toBeNull(); + transform.mockRestore(); + }); +}); + +describe('normalizeBadges', () => { + it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => { + const out = normalizeBadges([ + { key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' }, + { key: 'b', label: '' }, + 'nonsense', + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D', color: 'red; background: url(x)' }, + { key: 'e', label: 'E' }, + { key: 'f', label: 'F' }, + ]); + expect(out).toEqual([ + { key: 'a', label: 'A' }, + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D' }, + ]); + expect(normalizeBadges(undefined)).toEqual([]); + }); +}); diff --git a/components/email/plugin-row-badges.tsx b/components/email/plugin-row-badges.tsx new file mode 100644 index 000000000..03e28b3f2 --- /dev/null +++ b/components/email/plugin-row-badges.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Email } from "@/lib/jmap/types"; +import type { EmailListBadge } from "@/lib/plugin-types"; +import { renderHooks } from "@/lib/plugin-hooks"; +import { emailSummary } from "@/lib/plugin-projection"; +import { cn } from "@/lib/utils"; +import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; + +// Badges plugins add to a message-list row through onEmailListItemRender. +// +// The hook is async and would otherwise run for every row on every render, so +// the answer is kept per message and keyword state: scrolling a row back into +// view, or re-rendering the list, reuses it. A keyword change (read, flagged, +// a tag) asks again, since that is what most badges are about. + +/** At most this many plugin badges per row; the row has little room. */ +export const MAX_ROW_BADGES = 3; +const MAX_LABEL_LENGTH = 32; +const MAX_TITLE_LENGTH = 200; +const CACHE_LIMIT = 2000; + +const cache = new Map(); +const inflight = new Map>(); + +function cacheKey(email: Email, listeners: number): string { + const keywords = Object.keys(email.keywords ?? {}) + .filter((k) => email.keywords?.[k]) + .sort() + .join(","); + // The listener count is part of the key so a plugin being switched on or + // off doesn't leave the old answer behind. + return `${listeners}|${email.id}|${keywords}`; +} + +// A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS +// variable or a colour name. Anything else (url(), expressions, several +// declarations) is dropped and the badge keeps the theme colours. +const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i; + +// eslint-disable-next-line no-control-regex -- removing control characters is the point +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g; + +function cleanText(value: unknown, max: number): string { + return typeof value === "string" ? value.replace(CONTROL_CHARS, "").trim().slice(0, max) : ""; +} + +/** Keep only well-formed badges, as plain text, capped at MAX_ROW_BADGES. */ +export function normalizeBadges(raw: unknown): EmailListBadge[] { + if (!Array.isArray(raw)) return []; + const out: EmailListBadge[] = []; + const seen = new Set(); + for (const item of raw) { + if (out.length >= MAX_ROW_BADGES) break; + if (!item || typeof item !== "object") continue; + const candidate = item as Record; + const label = cleanText(candidate.label, MAX_LABEL_LENGTH); + if (!label) continue; + const key = cleanText(candidate.key, 100) || label; + if (seen.has(key)) continue; + seen.add(key); + const badge: EmailListBadge = { key, label }; + const color = cleanText(candidate.color, 60); + if (color && SAFE_COLOR.test(color)) badge.color = color; + const title = cleanText(candidate.title, MAX_TITLE_LENGTH); + if (title) badge.title = title; + out.push(badge); + } + return out; +} + +function remember(key: string, badges: EmailListBadge[]): void { + if (cache.size >= CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, badges); +} + +function resolveBadges(email: Email, key: string): Promise { + const pending = inflight.get(key); + if (pending) return pending; + const promise = renderHooks.onEmailListItemRender.transform([], { email: emailSummary(email) }) + .then(normalizeBadges, () => []) + .then((badges) => { + remember(key, badges); + return badges; + }) + .finally(() => inflight.delete(key)); + inflight.set(key, promise); + return promise; +} + +/** Forget every cached answer (tests). */ +export function clearRowBadgeCache(): void { + cache.clear(); + inflight.clear(); +} + +/** + * The plugin badges for a row: none at first, then whatever the plugins + * returned. Without any onEmailListItemRender handler this does nothing. + */ +export function usePluginRowBadges(email: Email): EmailListBadge[] { + const listeners = renderHooks.onEmailListItemRender.size; + const key = listeners > 0 ? cacheKey(email, listeners) : null; + const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({ + key, + badges: key ? cache.get(key) ?? [] : [], + })); + + useEffect(() => { + if (!key) return; + const cached = cache.get(key); + if (cached) { + setState((prev) => (prev.key === key && prev.badges === cached ? prev : { key, badges: cached })); + return; + } + let cancelled = false; + void resolveBadges(email, key).then((badges) => { + if (!cancelled) setState({ key, badges }); + }); + return () => { cancelled = true; }; + // `key` covers the parts of the email the answer depends on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + if (!key) return []; + // While a new answer is on its way, show the cached one for this key, if + // any, rather than the previous key's badges. + return state.key === key ? state.badges : cache.get(key) ?? []; +} + +/** Plugin badges next to a row's subject. Renders nothing when there are none. */ +export function PluginRowBadges({ email, className }: { email: Email; className?: string }) { + const badges = usePluginRowBadges(email); + if (badges.length === 0) return null; + return ( + + {badges.map((badge) => ( + + {badge.label} + + ))} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f40a91e07..48697c5b2 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -29,6 +29,7 @@ import type { SwipeAction } from "@/stores/settings-store"; import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; import { SearchSnippetText } from "./search-snippet-text"; +import { PluginRowBadges } from "./plugin-row-badges"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { useTranslations } from "next-intl"; @@ -596,6 +597,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {/* Gmail runs the snippet on from the subject after a dash. */} @@ -738,6 +740,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || "(no subject)")} + {showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -1182,6 +1185,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {mvRow && '\u00a0-\u00a0'} @@ -1330,6 +1334,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || "(no subject)")} + {showPreview && density !== 'extra-compact' && density !== 'compact' && (