mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: show plugin badges next to the subject in message-list rows
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user