Files
webmail/components/email/plugin-row-badges.tsx

175 lines
6.7 KiB
TypeScript

"use client";
import { useEffect, useState, useSyncExternalStore } 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 { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
import { PLUGIN_ICON_RE } from "@/lib/plugin-sandbox/actions";
import { cn } from "@/lib/utils";
import { iconForName } from "@/components/icons";
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, generation: 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.
// The generation moves on when a plugin asks for a refresh.
return `${generation}|${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;
const icon = cleanText(candidate.icon, 40);
if (icon && PLUGIN_ICON_RE.test(icon)) badge.icon = icon;
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 generation = useSyncExternalStore(subscribeRowBadges, rowBadgeGeneration, rowBadgeGeneration);
const key = listeners > 0 ? cacheKey(email, listeners, generation) : 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) => {
const Icon = iconForName(badge.icon);
return (
<span
key={badge.key}
title={badge.title}
data-plugin-row-badge={badge.key}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[8rem] truncate border",
Icon && "gap-0.5",
!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}
>
{Icon && <Icon className="w-3 h-3 flex-shrink-0" aria-hidden data-badge-icon={badge.icon} />}
{Icon ? <span className="truncate">{badge.label}</span> : badge.label}
</span>
);
})}
</span>
);
}