feat: show plugin badges next to the subject in message-list rows

This commit is contained in:
Linus Rath
2026-10-14 03:30:00 +02:00
parent af53675191
commit c02aa4f3ed
3 changed files with 325 additions and 0 deletions
@@ -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> = {}): 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 (
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<string>(), 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<string, unknown> }];
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: '<img src=x onerror=alert(1)>' },
]);
const { container } = render(row([makeEmail()]));
expect(await screen.findByText('<img src=x onerror=alert(1)>')).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([]);
});
});
+161
View File
@@ -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<string, EmailListBadge[]>();
const inflight = new Map<string, Promise<EmailListBadge[]>>();
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<string>();
for (const item of raw) {
if (out.length >= MAX_ROW_BADGES) break;
if (!item || typeof item !== "object") continue;
const candidate = item as Record<string, unknown>;
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<EmailListBadge[]> {
const pending = inflight.get(key);
if (pending) return pending;
const promise = renderHooks.onEmailListItemRender.transform<EmailListBadge[]>([], { 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 (
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
{badges.map((badge) => (
<span
key={badge.key}
title={badge.title}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[8rem] truncate border",
!badge.color && "border-border bg-muted text-muted-foreground",
)}
style={badge.color ? {
color: badge.color,
borderColor: badge.color,
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
} : undefined}
>
{badge.label}
</span>
))}
</span>
);
}
+5
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
</span>
<PluginRowBadges email={email} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{/* Gmail runs the snippet on from the subject after a dash. */}
@@ -738,6 +740,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
</span>
<PluginRowBadges email={email} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -1182,6 +1185,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
</span>
<PluginRowBadges email={latestEmail} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{mvRow && '\u00a0-\u00a0'}
@@ -1330,6 +1334,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
</span>
<PluginRowBadges email={latestEmail} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (