mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
Merge branch 'feat/plugin-system-audit'
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { PluginPageApp } from "@/components/plugins/plugin-page-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
|
||||
// Pages plugins add with api.ui.setPages: /apps/<pluginId>/<pageId>[/<path>].
|
||||
// Anything after the page id reaches the plugin's `app-page` slot as `path`.
|
||||
// A catch-all rather than [pluginId]/[pageId] so the static Lite export can
|
||||
// prerender the bare route; plugins never run in Lite, so it only ever shows
|
||||
// the "not available" note there (lib/lite-static-params.ts).
|
||||
export const generateStaticParams = generateLiteSegmentParams;
|
||||
|
||||
function decodeOnce(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PluginPageRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <PluginPageApp linkSegments={(segments ?? []).map(decodeOnce)} />;
|
||||
}
|
||||
@@ -9,8 +9,11 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
|
||||
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
|
||||
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PluginAppEvents } from "@/components/providers/plugin-app-events";
|
||||
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
@@ -56,9 +59,12 @@ export default async function LocaleLayout({
|
||||
<ImpersonationReconciler />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<CommandPaletteHost />
|
||||
<PluginConnectDialog />
|
||||
<QuickRuleHost />
|
||||
<PluginConsentDialog />
|
||||
<PluginOAuthCallbackListener />
|
||||
<PluginAppEvents />
|
||||
<PWAInstallPrompt />
|
||||
<PushNotificationPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
|
||||
|
||||
interface ApprovalEntry {
|
||||
pluginId: string;
|
||||
bundleHash: string;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
manifest: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
httpOrigins?: string[];
|
||||
apiPostPaths?: string[];
|
||||
};
|
||||
requestedBy: string;
|
||||
requestedAt: string;
|
||||
decidedAt?: string;
|
||||
}
|
||||
|
||||
interface SourceView {
|
||||
entry: ApprovalEntry;
|
||||
code?: string;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<ApprovalEntry['status'], string> = {
|
||||
pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400',
|
||||
approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
|
||||
denied: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugins users installed themselves and asked to have approved. The admin
|
||||
* decides on the package the user uploaded: its manifest and code are read
|
||||
* on the server, and the approval is pinned to that exact package.
|
||||
*/
|
||||
export function PluginApprovalsPanel() {
|
||||
const [entries, setEntries] = useState<ApprovalEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<SourceView | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugin-approvals');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { entries: ApprovalEntry[] };
|
||||
setEntries([...data.entries].sort((a, b) =>
|
||||
Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(`Could not load approval requests (${(e as Error).message})`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
|
||||
const key = `${entry.pluginId}:${entry.bundleHash}`;
|
||||
setBusy(key);
|
||||
try {
|
||||
const res = decision === 'revoke'
|
||||
? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
|
||||
: await apiFetch('/api/admin/plugin-approvals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(`Could not save the decision (${(e as Error).message})`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function viewSource(entry: ApprovalEntry) {
|
||||
setSource({ entry });
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
setSource({ entry, code: data.code, warnings: data.warnings });
|
||||
} catch (e) {
|
||||
setSource({ entry, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const pending = entries?.filter((e) => e.status === 'pending').length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Approval Requests</h2>
|
||||
{pending > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400">{pending} waiting</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-4 py-2 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{entries === null ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground"><Loader2 className="inline w-4 h-4 animate-spin" /></div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No requests yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => {
|
||||
const key = `${entry.pluginId}:${entry.bundleHash}`;
|
||||
const perms = entry.manifest.permissions ?? [];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
|
||||
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${STATUS_STYLE[entry.status]}`}>{entry.status}</span>
|
||||
</div>
|
||||
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
|
||||
</div>
|
||||
{perms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{perms.map((p) => (
|
||||
<span key={p} className={`text-[11px] px-1.5 py-0.5 rounded font-mono ${HIGH_RISK_PERMISSIONS.has(p) ? 'bg-warning/15 text-foreground' : 'bg-muted text-muted-foreground'}`}>{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<button type="button" onClick={() => viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
|
||||
<Code className="w-3.5 h-3.5" /> View code
|
||||
</button>
|
||||
{entry.status !== 'approved' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'approved')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 disabled:opacity-50">
|
||||
<Check className="w-3.5 h-3.5" /> Approve
|
||||
</button>
|
||||
)}
|
||||
{entry.status !== 'denied' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'denied')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-foreground text-xs font-medium hover:bg-accent disabled:opacity-50">
|
||||
<X className="w-3.5 h-3.5" /> Deny
|
||||
</button>
|
||||
)}
|
||||
{entry.status !== 'pending' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'revoke')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-xs text-muted-foreground hover:text-destructive disabled:opacity-50">
|
||||
Forget decision
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
|
||||
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
{source.warnings && source.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
<pre className="min-h-0 flex-1 overflow-auto p-4 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-all">
|
||||
{source.error ?? source.code ?? 'Loading…'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { PluginConfigPanel } from './plugin-config-panel';
|
||||
import { PluginApprovalsPanel } from './plugin-approvals-panel';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
@@ -374,6 +375,8 @@ export function PluginsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pluginsUploadEnabled && <PluginApprovalsPanel />}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import './plugin-kit.css';
|
||||
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
// asset request from this layout is then cross-origin from the "null" origin
|
||||
// to the host origin. Fonts need CORS, so there are no font imports. The one
|
||||
// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves
|
||||
// it from /_next/static, which answers with Access-Control-Allow-Origin: *
|
||||
// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin
|
||||
// bundles and host API calls travel over the postMessage RPC bridge.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
@@ -19,6 +20,11 @@ export default function PluginSandboxLayout({ children }: { children: ReactNode
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{/* next dev puts its indicator into every document, so each plugin
|
||||
slot showed its own badge. Plugins have no use for it. */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<style>{'nextjs-portal{display:none!important}'}</style>
|
||||
)}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into
|
||||
every plugin iframe. Built from the same Tailwind classes as the app's own
|
||||
components, so a kit Button is the app's Button. Only the kit's sources
|
||||
are scanned, which keeps the file small.
|
||||
|
||||
No Tailwind preflight: plugins written before the kit style plain HTML
|
||||
elements and rely on the browser's defaults. The kit's elements get the
|
||||
same reset through the .bk class each kit component puts on its root. */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities) source(none);
|
||||
@import "../theme-inline.css";
|
||||
|
||||
@source "../../lib/plugin-sandbox/ui-kit";
|
||||
@source "../../components/ui/button.tsx";
|
||||
@source "../../components/ui/input.tsx";
|
||||
@source "../../components/files/file-icons.tsx";
|
||||
|
||||
|
||||
@layer base {
|
||||
/* Tailwind's preflight, limited to the kit's own elements. :where() keeps
|
||||
it at zero specificity, so every utility still wins. */
|
||||
:where(.bk, .bk *, .bk ::before, .bk ::after) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid var(--color-border);
|
||||
}
|
||||
:where(button.bk, input.bk, select.bk, textarea.bk, .bk button, .bk input, .bk select, .bk textarea) {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(button.bk, select.bk, [role="button"].bk, [role="switch"].bk, [role="checkbox"].bk, .bk button, .bk select, .bk [role="button"], .bk [role="tab"], .bk [role="switch"], .bk [role="checkbox"], .bk [role="option"]):not(:disabled, [aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:where(button.bk:disabled, select.bk:disabled, .bk[aria-disabled="true"], .bk button:disabled, .bk select:disabled, .bk [aria-disabled="true"]) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:where(.bk h1, .bk h2, .bk h3, .bk h4) {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
:where(a.bk, .bk a) {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
:where(.bk ol, .bk ul) {
|
||||
list-style: none;
|
||||
}
|
||||
:where(.bk img, .bk svg) {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
:where(.bk input::placeholder, .bk textarea::placeholder) {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
:where(.bk table) {
|
||||
border-collapse: collapse;
|
||||
text-indent: 0;
|
||||
}
|
||||
/* Same as globals.css: native option lists only follow these. */
|
||||
:where(.bk option, .bk optgroup) {
|
||||
background-color: var(--color-popover);
|
||||
color: var(--color-popover-foreground);
|
||||
}
|
||||
:where(.dark .bk select) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { openPackage, readManifest, readText } from '@/lib/extension-package';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
@@ -79,61 +78,28 @@ export async function GET(
|
||||
} else {
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
bundleSize = buffer.byteLength;
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
if (buffer.byteLength > maxSize) {
|
||||
bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
|
||||
} else {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
// Opening the preview must not inflate an arbitrary bundle in
|
||||
// full: every read goes through the capped package reader.
|
||||
const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin');
|
||||
try {
|
||||
manifest = await readManifest(pkg);
|
||||
} catch (err) {
|
||||
bundleError = err instanceof Error ? err.message : 'Invalid manifest.json in bundle';
|
||||
}
|
||||
|
||||
// Detect optional root directory inside the ZIP.
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
bundleError = 'Bundle missing manifest.json';
|
||||
} else {
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
bundleError = 'Invalid manifest.json in bundle';
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
if (type === 'theme') {
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (cssFile) {
|
||||
const css = await cssFile.async('string');
|
||||
sourcePreview = {
|
||||
name: 'theme.css',
|
||||
content: css.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: css,
|
||||
truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (jsFile) {
|
||||
const code = await jsFile.async('string');
|
||||
sourcePreview = {
|
||||
name: entrypoint,
|
||||
content: code.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: code,
|
||||
truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (manifest) {
|
||||
const name = type === 'theme'
|
||||
? 'theme.css'
|
||||
: (typeof manifest.entrypoint === 'string' && manifest.entrypoint) || 'index.js';
|
||||
const content = await readText(pkg, name);
|
||||
if (content !== null) {
|
||||
sourcePreview = {
|
||||
name,
|
||||
content: content.length > MAX_PREVIEW_SOURCE_LEN
|
||||
? content.slice(0, MAX_PREVIEW_SOURCE_LEN)
|
||||
: content,
|
||||
truncated: content.length > MAX_PREVIEW_SOURCE_LEN,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn(() => 'https://extensions.test'),
|
||||
},
|
||||
}));
|
||||
|
||||
const registry = new Map<string, Record<string, unknown>>();
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
savePlugin: vi.fn(async (p: Record<string, unknown>) => { registry.set(p.id as string, p); }),
|
||||
saveTheme: vi.fn(async () => {}),
|
||||
getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
|
||||
getTheme: vi.fn(async () => null),
|
||||
getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
|
||||
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
|
||||
}));
|
||||
vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/admin/csp-frame-origins')>()),
|
||||
invalidateFrameOriginsCache: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
|
||||
async function bundle(manifest: Record<string, unknown>, code = 'module.exports = {};'): Promise<ArrayBuffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify(manifest));
|
||||
zip.file('index.js', code);
|
||||
const buf = await zip.generateAsync({ type: 'uint8array' });
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function install(body: Record<string, unknown>) {
|
||||
return POST(new NextRequest('http://localhost/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
|
||||
const MANIFEST = {
|
||||
name: 'Pretty Dates', version: '1.0.0', author: 'x', description: 'd',
|
||||
type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
registry.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('POST /api/admin/marketplace', () => {
|
||||
it('installs a plugin under its directory slug', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' }))));
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(registry.has('pretty-dates')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a ZIP bomb from the directory', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' }));
|
||||
zip.file('index.js', new Uint8Array(30 * 1024 * 1024));
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit)));
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a bundle whose manifest id differs from the slug', async () => {
|
||||
// Otherwise a directory entry can overwrite another installed plugin,
|
||||
// inheriting its enabled and force-enabled state, while the admin UI
|
||||
// (which keys install status by slug) still shows "not installed".
|
||||
registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' }))));
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/does not match/);
|
||||
expect(registry.get('pgp-plugin')?.version).toBe('2.0.0');
|
||||
});
|
||||
});
|
||||
+112
-221
@@ -9,18 +9,18 @@ import {
|
||||
getTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
themeCSSResolver,
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS } from '@/lib/theme-loader';
|
||||
EXTENSION_ID_RE,
|
||||
ExtensionPackageError,
|
||||
readPluginPackage,
|
||||
readThemePackage,
|
||||
suspiciousCode,
|
||||
} from '@/lib/extension-package';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
@@ -123,6 +123,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// The slug becomes a file name and the registry key; it must be a plain id.
|
||||
if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Download the bundle from the directory
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||
@@ -138,92 +146,41 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const buffer = await bundleRes.arrayBuffer();
|
||||
const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
|
||||
|
||||
if (buffer.byteLength > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the ZIP
|
||||
let zip: JSZip;
|
||||
try {
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root directory
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||
// bundles are authored by a third-party publisher; without this an id
|
||||
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||
// plugins/themes dir via path.join.
|
||||
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'theme') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
|
||||
const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() });
|
||||
if (!read.valid || !read.manifest) {
|
||||
return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
let css = await cssFile.async('string');
|
||||
|
||||
// Sanitize unconditionally: the safety check is advisory, and the
|
||||
// sanitizer is also what drops rules outside :root and .dark.
|
||||
const warnings: string[] = [];
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
css = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
const manifest = read.manifest;
|
||||
// The slug is what the admin picked and what install status is keyed
|
||||
// on. A bundle that names another id would install over whatever
|
||||
// already uses that id, keeping its enabled and force-enabled state.
|
||||
if (manifest.id !== slug) {
|
||||
return NextResponse.json(
|
||||
{ error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const resolvedId = slug;
|
||||
const { css, skin, warnings } = read;
|
||||
|
||||
const existingTheme = await getTheme(resolvedId);
|
||||
const isUpdate = existingTheme !== null;
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
name: manifest.name || slug,
|
||||
// Prefer the directory-published version (what we requested) over
|
||||
// manifest.version. Publishers sometimes forget to bump the version
|
||||
// inside the bundle's manifest.json; trusting it would make the
|
||||
// update never appear to "stick" — the registry would keep showing
|
||||
// the older version even after a successful update.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||
version: version || manifest.version,
|
||||
author: manifest.author || 'Unknown',
|
||||
description: manifest.description || '',
|
||||
variants: manifest.variants,
|
||||
enabled: existingTheme?.enabled ?? true,
|
||||
...(existingTheme?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingTheme.forceEnabled }
|
||||
@@ -232,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await saveTheme(theme, css, skin);
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
|
||||
{
|
||||
@@ -246,148 +203,82 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
|
||||
} else {
|
||||
// Plugin installation
|
||||
// Read entrypoint JS
|
||||
const entrypoint = (manifest.entrypoint as string) || 'index.js';
|
||||
const jsFile = zip.file(root + entrypoint);
|
||||
if (!jsFile) {
|
||||
return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = await jsFile.async('string');
|
||||
|
||||
// Block plugins with dangerous JS patterns
|
||||
const DANGEROUS_JS_PATTERNS = [
|
||||
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||
{ pattern: /document\.write/g, label: 'document.write' },
|
||||
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||
];
|
||||
const dangerousFindings: string[] = [];
|
||||
for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) dangerousFindings.push(label);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (dangerousFindings.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknownPerms = permissions.filter(p => !validPerms.has(p));
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (unknownPerms.length > 0) {
|
||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
// Plugins may declare iframe origins they need for embedded content.
|
||||
// Anything that doesn't pass strict origin validation is silently
|
||||
// dropped - the plugin still installs, but those origins are not
|
||||
// added to the host CSP.
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||
? (manifest.frameOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedFrameOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
|
||||
? (manifest.httpOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedHttpOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||
? (manifest.apiPostPaths as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedApiPostPaths.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingPlugin = await getPlugin(resolvedId);
|
||||
const isUpdate = existingPlugin !== null;
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
// See theme branch: trust the directory-published version, not
|
||||
// manifest.version, so updates actually stick in the registry.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
type: (manifest.type as string) || 'hook',
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: existingPlugin?.enabled ?? true,
|
||||
...(existingPlugin?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingPlugin.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingPlugin?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
|
||||
{
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
slug,
|
||||
frameOrigins: declaredFrameOrigins,
|
||||
httpOrigins: declaredHttpOrigins,
|
||||
apiPostPaths: declaredApiPostPaths,
|
||||
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
|
||||
}
|
||||
|
||||
// Plugin installation
|
||||
let pkg;
|
||||
try {
|
||||
pkg = await readPluginPackage(buffer, {
|
||||
appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
expectedId: slug,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ExtensionPackageError) {
|
||||
// Same wording the admin UI already shows for an id mismatch.
|
||||
const message = err.message.replace(/Package id "([^"]*)" does not match "([^"]*)"/, 'Bundle id "$1" does not match the directory entry "$2"');
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { manifest, code } = pkg;
|
||||
|
||||
// Block plugins with dangerous JS patterns
|
||||
const suspicious = suspiciousCode(code);
|
||||
if (suspicious.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const existingPlugin = await getPlugin(slug);
|
||||
const isUpdate = existingPlugin !== null;
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: slug,
|
||||
name: manifest.name,
|
||||
// See theme branch: trust the directory-published version, not
|
||||
// manifest.version, so updates actually stick in the registry.
|
||||
version: version || manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
type: manifest.type,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: manifest.permissions,
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: existingPlugin?.enabled ?? true,
|
||||
...(existingPlugin?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingPlugin.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingPlugin?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
|
||||
...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
|
||||
...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}),
|
||||
...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}),
|
||||
...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
|
||||
...(manifest.locales ? { locales: manifest.locales } : {}),
|
||||
...(manifest.credentials ? { credentials: manifest.credentials } : {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
|
||||
{
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
slug,
|
||||
frameOrigins: plugin.frameOrigins ?? [],
|
||||
httpOrigins: plugin.httpOrigins ?? [],
|
||||
apiPostPaths: plugin.apiPostPaths ?? [],
|
||||
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate });
|
||||
} catch (error) {
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readApprovalBundle } from '@/lib/admin/plugin-approvals';
|
||||
import { readPluginPackage } from '@/lib/extension-package';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugin-approvals/source?pluginId=…&bundleHash=…
|
||||
*
|
||||
* The manifest and code of a plugin a user asked to have approved, read
|
||||
* from the ZIP they uploaded, so the admin decides on what will actually run.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId') ?? '';
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash') ?? '';
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(pluginId) || !/^[a-f0-9]{64}$/.test(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
const zip = await readApprovalBundle(pluginId, bundleHash);
|
||||
if (!zip) return NextResponse.json({ error: 'No package stored for this request' }, { status: 404 });
|
||||
const pkg = await readPluginPackage(zip);
|
||||
return NextResponse.json(
|
||||
{ manifest: pkg.manifest, code: pkg.code, warnings: pkg.warnings },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals source GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let signedIn = false;
|
||||
vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => signedIn) }));
|
||||
vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: vi.fn(async () => null), readDevBundle: vi.fn() }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPlugin: vi.fn(async () => ({ id: 'demo', enabled: true, bundleHash: 'h' })),
|
||||
getPluginBundle: vi.fn(async () => 'module.exports = {}'),
|
||||
getPluginSignature: vi.fn(async () => 'sig'),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: vi.fn(async () => 'sig') }));
|
||||
|
||||
import { GET } from '../route';
|
||||
|
||||
function get() {
|
||||
return GET(new NextRequest('http://localhost/api/admin/plugins/demo/bundle'), { params: Promise.resolve({ id: 'demo' }) });
|
||||
}
|
||||
|
||||
beforeEach(() => { signedIn = false; });
|
||||
|
||||
describe('GET plugin bundle', () => {
|
||||
it('does not hand plugin code to someone who is not signed in', async () => {
|
||||
expect((await get()).status).toBe(401);
|
||||
});
|
||||
|
||||
it('serves it to a signed-in user with its signature', async () => {
|
||||
signedIn = true;
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('x-bundle-signature')).toBe('sig');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
|
||||
|
||||
async function safeSign(code: string): Promise<string | null> {
|
||||
try { return await signBytes(code); } catch { return null; }
|
||||
@@ -10,13 +11,18 @@ async function safeSign(code: string): Promise<string | null> {
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
*
|
||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||
* Serves bundles to signed-in users so the client-side plugin loader can fetch them.
|
||||
* Only serves plugins that exist in the registry and are enabled.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
// Bundles are delivered to signed-in users only, like the plugin list
|
||||
// that names them (/api/plugins).
|
||||
if (!(await canSeeInstanceDetails(request))) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -65,7 +71,7 @@ export async function GET(
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
const signature = await getPluginSignature(id, code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let admin = true;
|
||||
vi.mock('@/lib/admin/session', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
return { requireAdminAuth: async () => (admin ? { ok: true } : { error: NextResponse.json({}, { status: 401 }) }) };
|
||||
});
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => ({ username: 'alice@example.org' }) }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPlugin: async () => ({ id: 'paperless', configSchema: { serverUrl: { type: 'string' }, apiToken: { type: 'secret' } } }),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: async () => null }));
|
||||
vi.mock('@/lib/admin/plugin-config', () => ({
|
||||
getPluginConfig: async () => ({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' }),
|
||||
setPluginConfig: vi.fn(), deletePluginConfigKey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from '../route';
|
||||
|
||||
function get(query = '') {
|
||||
return GET(new NextRequest(`http://localhost/api/admin/plugins/paperless/config${query}`), { params: Promise.resolve({ id: 'paperless' }) });
|
||||
}
|
||||
|
||||
beforeEach(() => { admin = true; });
|
||||
|
||||
describe('GET plugin config', () => {
|
||||
it('gives an admin every field', async () => {
|
||||
expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' });
|
||||
});
|
||||
|
||||
it('never gives secrets with ?public=1, even to an admin (what plugins use)', async () => {
|
||||
expect(await (await get('?public=1')).json()).toEqual({ serverUrl: 'https://paperless.example.org' });
|
||||
});
|
||||
|
||||
it('gives a user only the fields that aren\'t secrets', async () => {
|
||||
admin = false;
|
||||
expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org' });
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,12 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ?public=1: only the fields any signed-in user may see, even for an
|
||||
// admin. The plugin API uses it so a plugin never gets secrets just
|
||||
// because the user is also signed in to the admin dashboard.
|
||||
const publicOnly = request.nextUrl.searchParams.get('public') === '1';
|
||||
const adminAuth = await requireAdminAuth(request);
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
const isAdmin = !publicOnly && !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
|
||||
let policy: Record<string, unknown>;
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn(),
|
||||
getPolicy: vi.fn(() => policy),
|
||||
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
|
||||
},
|
||||
}));
|
||||
|
||||
const registry = new Map<string, Record<string, unknown>>();
|
||||
const removedConfig: string[] = [];
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
savePlugin: vi.fn(async (p: Record<string, unknown>) => { registry.set(p.id as string, p); }),
|
||||
getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
|
||||
getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
|
||||
deletePlugin: vi.fn(async (id: string) => registry.delete(id)),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-config', () => ({
|
||||
deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }),
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
|
||||
vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/admin/csp-frame-origins')>()),
|
||||
invalidateFrameOriginsCache: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DELETE, POST } from '../route';
|
||||
|
||||
const MANIFEST = {
|
||||
id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd',
|
||||
type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
|
||||
};
|
||||
|
||||
async function upload(files: Record<string, string | Uint8Array>) {
|
||||
const zip = new JSZip();
|
||||
for (const [n, d] of Object.entries(files)) zip.file(n, d);
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' }));
|
||||
return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registry.clear();
|
||||
removedConfig.length = 0;
|
||||
policy = { forceEnabledPlugins: [] };
|
||||
});
|
||||
|
||||
describe('POST /api/admin/plugins', () => {
|
||||
it('installs a valid plugin with its locales', async () => {
|
||||
const res = await upload({
|
||||
'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }),
|
||||
'index.js': 'module.exports = {}',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } });
|
||||
});
|
||||
|
||||
it('refuses permissions given as a string', async () => {
|
||||
const res = await upload({
|
||||
'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }),
|
||||
'index.js': '',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a ZIP bomb instead of inflating it', async () => {
|
||||
const res = await upload({
|
||||
'manifest.json': JSON.stringify(MANIFEST),
|
||||
'index.js': new Uint8Array(30 * 1024 * 1024),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/once unpacked/);
|
||||
});
|
||||
|
||||
it('refuses an invalid settings schema', async () => {
|
||||
const res = await upload({
|
||||
'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }),
|
||||
'index.js': '',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-uploading and deleting', () => {
|
||||
it('keeps a plugin the admin disabled disabled when a new version is uploaded', async () => {
|
||||
registry.set('demo-plugin', { id: 'demo-plugin', enabled: false, forceEnabled: true });
|
||||
|
||||
await upload({ 'manifest.json': JSON.stringify({ ...MANIFEST, version: '1.1.0' }), 'index.js': '' });
|
||||
|
||||
expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', enabled: false, forceEnabled: true });
|
||||
});
|
||||
|
||||
it('drops the config and the forced flag when a plugin is deleted', async () => {
|
||||
registry.set('demo-plugin', { id: 'demo-plugin' });
|
||||
policy = { forceEnabledPlugins: ['demo-plugin', 'other'] };
|
||||
|
||||
const res = await DELETE(new NextRequest('http://localhost/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id: 'demo-plugin' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(removedConfig).toEqual(['demo-plugin']);
|
||||
expect(policy.forceEnabledPlugins).toEqual(['other']);
|
||||
});
|
||||
});
|
||||
+44
-134
@@ -3,31 +3,17 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPlugin,
|
||||
getPluginRegistry,
|
||||
savePlugin,
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
// Server-side extraction using the same validation logic
|
||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
|
||||
const SUSPICIOUS_JS_PATTERNS = [
|
||||
{ pattern: /\beval\s*\(/g, label: 'eval()' },
|
||||
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
|
||||
{ pattern: /document\.cookie/g, label: 'document.cookie' },
|
||||
{ pattern: /document\.write/g, label: 'document.write' },
|
||||
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
|
||||
];
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
|
||||
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
|
||||
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
@@ -79,141 +65,64 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract and validate ZIP
|
||||
let zip: JSZip;
|
||||
let pkg;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
pkg = await readPluginPackage(await file.arrayBuffer(), {
|
||||
appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ExtensionPackageError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { manifest, code } = pkg;
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate manifest
|
||||
const errors: string[] = [];
|
||||
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
|
||||
|
||||
const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
|
||||
if (!validTypes.includes(manifest.type as string)) {
|
||||
errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||
}
|
||||
|
||||
if (manifest.permissions && Array.isArray(manifest.permissions)) {
|
||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
|
||||
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file extensions
|
||||
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir) continue;
|
||||
const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
|
||||
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
|
||||
errors.push(`Disallowed file type: ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read entrypoint code
|
||||
const entryFile = zip.file(root + (manifest.entrypoint as string));
|
||||
if (!entryFile) {
|
||||
return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
// Admin uploads reach every user, so the patterns a user upload only
|
||||
// warns about are refused here.
|
||||
const suspicious = suspiciousCode(code);
|
||||
if (suspicious.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
// A new upload of an installed plugin is an update: keep whether the
|
||||
// admin had it enabled or forced instead of switching it on for everyone.
|
||||
const existing = await getPlugin(manifest.id);
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
id: manifest.id as string,
|
||||
name: manifest.name as string,
|
||||
version: manifest.version as string,
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
type: manifest.type,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
permissions: manifest.permissions,
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: existing?.enabled ?? true,
|
||||
...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
|
||||
...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
|
||||
...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
|
||||
...(manifest.locales ? { locales: manifest.locales } : {}),
|
||||
...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}),
|
||||
...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}),
|
||||
...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
|
||||
...(manifest.credentials ? { credentials: manifest.credentials } : {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const declaredFrameOrigins = plugin.frameOrigins ?? [];
|
||||
const declaredHttpOrigins = plugin.httpOrigins ?? [];
|
||||
const declaredApiPostPaths = plugin.apiPostPaths ?? [];
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
return NextResponse.json({ plugin, warnings: pkg.warnings });
|
||||
} catch (error) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -291,6 +200,7 @@ export async function DELETE(request: NextRequest) {
|
||||
if (!removed) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
await cleanUpDeletedPlugin(id);
|
||||
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { serveThemeAsset } from '@/lib/admin/theme-asset';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||
* GET /api/admin/themes/[id]/css - Serve a theme's CSS to clients
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Validate ID format
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify theme exists and is enabled
|
||||
const registry = await getThemeRegistry();
|
||||
const theme = registry.themes.find(t => t.id === id);
|
||||
if (!theme) {
|
||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
}
|
||||
if (!theme.enabled) {
|
||||
return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const css = await getThemeCSS(id);
|
||||
if (!css) {
|
||||
return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(css, {
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
const { id } = await params;
|
||||
return serveThemeAsset(id, 'css');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { serveThemeAsset } from '@/lib/admin/theme-asset';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes/[id]/skin - Serve a theme's skin (Theme API v2 component CSS) to clients
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return serveThemeAsset(id, 'skin');
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
|
||||
let policy: Record<string, unknown>;
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn(),
|
||||
getPolicy: vi.fn(() => policy),
|
||||
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
|
||||
},
|
||||
}));
|
||||
const saved: Array<{ theme: Record<string, unknown>; css: string; skin: string | null }> = [];
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
deleteTheme: vi.fn(async () => true),
|
||||
themeCSSResolver: vi.fn(async () => () => null),
|
||||
saveTheme: vi.fn(async (theme: Record<string, unknown>, css: string, skin: string | null) => { saved.push({ theme, css, skin }); }),
|
||||
getTheme: vi.fn(async () => null),
|
||||
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
|
||||
}));
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { DELETE, POST } from '../route';
|
||||
|
||||
function del(id: string) {
|
||||
return DELETE(new NextRequest('http://localhost/api/admin/themes', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saved.length = 0;
|
||||
policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] };
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/themes', () => {
|
||||
it('stops forcing a theme once it is deleted', async () => {
|
||||
const res = await del('corp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(policy.forceEnabledThemes).toEqual(['other']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/themes', () => {
|
||||
it('refuses a theme.css that unpacks past the limit', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({ id: 'big', name: 'Big', version: '1.0.0', author: 'a', type: 'theme', variants: ['light'] }));
|
||||
zip.file('theme.css', new Uint8Array(30 * 1024 * 1024));
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bytes as BlobPart], 't.zip'));
|
||||
|
||||
const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/once unpacked/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/admin/themes with Theme API v2', () => {
|
||||
it('installs a tokens-only theme with a skin', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', type: 'theme', variants: ['light', 'dark'],
|
||||
apiVersion: 2, tokens: { light: { '--color-primary': '#0066cc' }, dark: { '--color-primary': '#66aaff' } },
|
||||
}));
|
||||
zip.file('skin.css', '.message-list { border-radius: 8px; }');
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bytes as BlobPart], 'ocean.zip'));
|
||||
|
||||
const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(saved[0].css).toContain('#0066cc');
|
||||
expect(saved[0].skin).toContain('border-radius');
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,16 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getTheme,
|
||||
getThemeRegistry,
|
||||
saveTheme,
|
||||
deleteTheme as removeTheme,
|
||||
themeCSSResolver,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS } from '@/lib/theme-loader';
|
||||
import { readThemePackage } from '@/lib/extension-package';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
@@ -47,98 +48,30 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_THEME_SIZE) {
|
||||
return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
|
||||
const read = await readThemePackage(await file.arrayBuffer(), {
|
||||
resolveExtends: await themeCSSResolver(),
|
||||
});
|
||||
if (!read.valid || !read.manifest) {
|
||||
return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
const { manifest, css, skin, warnings } = read;
|
||||
|
||||
// Extract and validate ZIP
|
||||
let zip: JSZip;
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find root
|
||||
const entries = Object.keys(zip.files);
|
||||
const topDirs = new Set(entries.map(e => e.split('/')[0]));
|
||||
let root = '';
|
||||
if (topDirs.size === 1) {
|
||||
const dir = [...topDirs][0];
|
||||
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
|
||||
root = dir + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(await manifestFile.async('string'));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate manifest
|
||||
const errors: string[] = [];
|
||||
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
|
||||
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
|
||||
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
|
||||
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
|
||||
|
||||
if (manifest.type !== 'theme') {
|
||||
errors.push(`Expected type "theme", got "${manifest.type}"`);
|
||||
}
|
||||
|
||||
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
|
||||
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
|
||||
}
|
||||
|
||||
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
|
||||
errors.push('Missing or empty "variants" array');
|
||||
} else {
|
||||
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
|
||||
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
|
||||
}
|
||||
|
||||
let css = await cssFile.async('string');
|
||||
|
||||
// Sanitize unconditionally: the safety check is advisory, and the
|
||||
// sanitizer is also what drops rules outside :root and .dark.
|
||||
const warnings: string[] = [];
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
css = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
|
||||
const existing = await getTheme(manifest.id);
|
||||
const now = new Date().toISOString();
|
||||
const theme: ServerTheme = {
|
||||
id: manifest.id as string,
|
||||
name: manifest.name as string,
|
||||
version: manifest.version as string,
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
variants: manifest.variants as string[],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description || '',
|
||||
variants: manifest.variants,
|
||||
enabled: existing?.enabled ?? true,
|
||||
...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
|
||||
installedAt: existing?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await saveTheme(theme, css, skin);
|
||||
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
|
||||
|
||||
return NextResponse.json({ theme, warnings });
|
||||
@@ -205,6 +138,7 @@ export async function DELETE(request: NextRequest) {
|
||||
if (!removed) {
|
||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
}
|
||||
await forgetExtensionInPolicy('theme', id);
|
||||
|
||||
await auditLog('theme.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_store: unknown, slot: number) => (slot === 0 ? { username: currentUser } : null),
|
||||
}));
|
||||
vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null }));
|
||||
|
||||
let currentUser = 'alice@example.org';
|
||||
const entries: Array<Record<string, unknown>> = [];
|
||||
const bundles = new Map<string, Uint8Array>();
|
||||
vi.mock('@/lib/admin/plugin-approvals', async (orig) => {
|
||||
const real = await orig<typeof import('@/lib/admin/plugin-approvals')>();
|
||||
return {
|
||||
...real,
|
||||
getApprovalStatus: vi.fn(async () => ({ status: 'not-requested' })),
|
||||
requestApproval: vi.fn(async (pluginId: string, bundleHash: string, manifest: unknown, requestedBy: string) => {
|
||||
const pending = entries.filter((e) => e.status === 'pending' && e.requestedBy === requestedBy).length;
|
||||
if (pending >= real.MAX_PENDING_PER_USER) throw new real.ApprovalLimitError('too many');
|
||||
const entry = { pluginId, bundleHash, manifest, requestedBy, status: 'pending', requestedAt: 'now' };
|
||||
entries.push(entry);
|
||||
return entry;
|
||||
}),
|
||||
saveApprovalBundle: vi.fn(async (id: string, hash: string, zip: Uint8Array) => { bundles.set(`${id}:${hash}`, zip); }),
|
||||
};
|
||||
});
|
||||
|
||||
import { POST } from '../route';
|
||||
import { packageHash, checkPluginManifest } from '@/lib/extension-package';
|
||||
|
||||
const MANIFEST = {
|
||||
id: 'my-plugin', name: 'Mine', version: '1.0.0', author: 'me', description: 'd',
|
||||
type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
|
||||
};
|
||||
|
||||
async function zipOf(manifest: Record<string, unknown>, code = 'module.exports = {}') {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify(manifest));
|
||||
zip.file('index.js', code);
|
||||
return zip.generateAsync({ type: 'uint8array' });
|
||||
}
|
||||
|
||||
async function submit(bytes: Uint8Array) {
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bytes as BlobPart], 'p.zip'));
|
||||
return POST(new NextRequest('http://localhost/api/plugin-approval-status', { method: 'POST', body: form }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
entries.length = 0;
|
||||
bundles.clear();
|
||||
currentUser = 'alice@example.org';
|
||||
});
|
||||
|
||||
describe('POST /api/plugin-approval-status', () => {
|
||||
it('pins the request to the hash of the code and manifest it read itself', async () => {
|
||||
const res = await submit(await zipOf(MANIFEST));
|
||||
const body = await res.json();
|
||||
|
||||
const expected = await packageHash(checkPluginManifest(MANIFEST).manifest!, 'module.exports = {}');
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.bundleHash).toBe(expected);
|
||||
expect(entries[0].manifest).toMatchObject({ name: 'Mine', permissions: ['email:read'] });
|
||||
expect(bundles.has(`my-plugin:${expected}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives the same code with wider permissions a different hash', async () => {
|
||||
const narrow = await (await submit(await zipOf(MANIFEST))).json();
|
||||
const wide = await (await submit(await zipOf({ ...MANIFEST, permissions: ['email:read', 'http:fetch'], httpOrigins: ['https://evil.example'] }))).json();
|
||||
|
||||
expect(wide.bundleHash).not.toBe(narrow.bundleHash);
|
||||
});
|
||||
|
||||
it('refuses the old form where the client named the hash and manifest', async () => {
|
||||
const res = await POST(new NextRequest('http://localhost/api/plugin-approval-status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pluginId: 'my-plugin', bundleHash: 'a'.repeat(64), manifest: { permissions: [] } }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('limits how many requests one user can have open', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect((await submit(await zipOf({ ...MANIFEST, id: `p-${i}x` }))).status).toBe(200);
|
||||
}
|
||||
const res = await submit(await zipOf({ ...MANIFEST, id: 'one-more' }));
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,14 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
|
||||
import {
|
||||
ApprovalLimitError,
|
||||
getApprovalStatus,
|
||||
requestApproval,
|
||||
saveApprovalBundle,
|
||||
type ApprovalEntry,
|
||||
} from '@/lib/admin/plugin-approvals';
|
||||
import { ExtensionPackageError, packageHash, readPluginPackage } from '@/lib/extension-package';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
|
||||
@@ -15,8 +22,9 @@ import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/ad
|
||||
* they want to enable. The client uses this BEFORE running `enablePlugin`
|
||||
* when the `requirePluginApproval` policy is set.
|
||||
*
|
||||
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
|
||||
* pending approval entry (or returns the existing one).
|
||||
* POST same path with the plugin ZIP as multipart `file` creates a pending
|
||||
* approval entry (or returns the existing one) for the package hash the
|
||||
* server computes, and keeps the ZIP for the admin to review.
|
||||
*/
|
||||
|
||||
async function resolveUsername(): Promise<string | null> {
|
||||
@@ -67,26 +75,55 @@ export async function POST(request: NextRequest) {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
// The request carries the package itself. The admin reviews the code
|
||||
// and manifest the server read from it, and the approval is pinned to
|
||||
// the hash the server computed, not to anything the client claims.
|
||||
let file: File | null = null;
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const value = form.get('file');
|
||||
file = value instanceof File ? value : null;
|
||||
} catch { /* not multipart */ }
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Send the plugin ZIP as "file"' }, { status: 400 });
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
const m = (b.manifest ?? {}) as Record<string, unknown>;
|
||||
const manifest: ApprovalEntry['manifest'] = {
|
||||
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
|
||||
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
|
||||
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
|
||||
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
|
||||
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
|
||||
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
let pkg;
|
||||
try {
|
||||
pkg = await readPluginPackage(bytes, { appVersion: process.env.NEXT_PUBLIC_APP_VERSION });
|
||||
} catch (err) {
|
||||
if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
throw err;
|
||||
}
|
||||
const { manifest, code } = pkg;
|
||||
const hash = await packageHash(manifest, code);
|
||||
|
||||
const snapshot: ApprovalEntry['manifest'] = {
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
permissions: manifest.permissions,
|
||||
httpOrigins: manifest.httpOrigins,
|
||||
apiPostPaths: manifest.apiPostPaths,
|
||||
};
|
||||
|
||||
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
|
||||
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
|
||||
let entry: ApprovalEntry;
|
||||
try {
|
||||
entry = await requestApproval(manifest.id, hash, snapshot, username);
|
||||
} catch (err) {
|
||||
if (err instanceof ApprovalLimitError) return NextResponse.json({ error: err.message }, { status: 429 });
|
||||
throw err;
|
||||
}
|
||||
if (entry.status === 'pending') await saveApprovalBundle(manifest.id, hash, bytes);
|
||||
return NextResponse.json({
|
||||
pluginId: manifest.id,
|
||||
bundleHash: hash,
|
||||
status: entry.status,
|
||||
requestedAt: entry.requestedAt,
|
||||
decidedAt: entry.decidedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
|
||||
import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
||||
|
||||
function parseCapture(raw: string | null): CaptureSpec | null | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (typeof v.secret !== 'string' || !FIELD_RE.test(v.secret)) return null;
|
||||
if (v.username !== undefined && (typeof v.username !== 'string' || !FIELD_RE.test(v.username))) return null;
|
||||
return { secret: v.secret, ...(typeof v.username === 'string' ? { username: v.username } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plugin-proxy?plugin=<id>&credential=<id>&url=<target>
|
||||
*
|
||||
* Forwards a plugin's request to `url` with the named credential added. The
|
||||
* target method goes in `X-Bulwark-Method` (default GET); the request body
|
||||
* and headers are passed through, minus cookies, auth and transport headers.
|
||||
* `X-Bulwark-Capture: {"secret":"appPassword","username":"loginName"}` stores
|
||||
* those JSON fields of the response as the credential instead of returning
|
||||
* them (Nextcloud Login Flow v2).
|
||||
*
|
||||
* Called by the host page on a plugin's behalf, never by the sandboxed
|
||||
* plugin itself (its iframe has an opaque origin and is refused here).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getPolicy().features.pluginsEnabled) {
|
||||
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const pluginId = params.get('plugin') ?? '';
|
||||
const credentialId = params.get('credential') ?? '';
|
||||
const url = params.get('url') ?? '';
|
||||
if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) {
|
||||
return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 });
|
||||
}
|
||||
const capture = parseCapture(request.headers.get('x-bulwark-capture'));
|
||||
if (capture === null) {
|
||||
return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await proxyRequest({
|
||||
user: { username: creds.username, serverUrl: creds.serverUrl },
|
||||
pluginId,
|
||||
credentialId,
|
||||
url,
|
||||
method: request.headers.get('x-bulwark-method') ?? 'GET',
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
capture,
|
||||
signal: request.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyError) {
|
||||
// Marks the proxy's own refusals, so the plugin can tell them from the
|
||||
// upstream server's errors.
|
||||
const headers: Record<string, string> = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code };
|
||||
const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter;
|
||||
if (retryAfter) headers['retry-after'] = String(retryAfter);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers });
|
||||
}
|
||||
return NextResponse.json({ error: 'Internal server error', code: 'internal' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let signedIn = true;
|
||||
let pluginsEnabled = true;
|
||||
let sameOrigin = true;
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: async () => (signedIn ? { username: 'alice@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
|
||||
}));
|
||||
vi.mock('@/lib/security/same-origin', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
return { rejectCrossOriginRequest: () => (sameOrigin ? null : NextResponse.json({}, { status: 403 })) };
|
||||
});
|
||||
const store = new Map<string, Record<string, unknown>>();
|
||||
vi.mock('@/lib/plugin-secrets/vault', () => ({
|
||||
VaultFullError: class extends Error {},
|
||||
putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record<string, unknown>) => {
|
||||
store.set(`${pluginId}/${credentialId}`, input);
|
||||
}),
|
||||
listUserCredentials: vi.fn(async () => []),
|
||||
deleteUserCredentials: vi.fn(async () => {}),
|
||||
}));
|
||||
const proxied: unknown[] = [];
|
||||
vi.mock('@/lib/plugin-secrets/proxy', async (orig) => {
|
||||
const real = await orig<typeof import('@/lib/plugin-secrets/proxy')>();
|
||||
return {
|
||||
...real,
|
||||
proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }),
|
||||
adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]),
|
||||
adminCredentialOrigins: vi.fn(async () => ['https://paperless.example.com']),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET as listCredentials, POST as saveCredential } from '../route';
|
||||
import { POST as proxy } from '../../plugin-proxy/route';
|
||||
|
||||
const VALID = {
|
||||
pluginId: 'nextcloud', credentialId: 'cloud', label: 'Nextcloud',
|
||||
origin: 'https://cloud.example.org/index.php', inject: { type: 'basic' }, username: 'alice', secret: 'app-pw',
|
||||
};
|
||||
|
||||
function save(body: unknown) {
|
||||
return saveCredential(new NextRequest('http://localhost/api/plugin-secrets', { method: 'POST', body: JSON.stringify(body) }));
|
||||
}
|
||||
|
||||
function proxyCall(query: string, headers: Record<string, string> = {}) {
|
||||
return proxy(new NextRequest(`http://localhost/api/plugin-proxy?${query}`, { method: 'POST', headers }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
signedIn = true;
|
||||
pluginsEnabled = true;
|
||||
sameOrigin = true;
|
||||
store.clear();
|
||||
proxied.length = 0;
|
||||
});
|
||||
|
||||
describe('GET /api/plugin-secrets', () => {
|
||||
it("names the servers of set-up admin credentials for one plugin, for the host page's link check", async () => {
|
||||
const res = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless'));
|
||||
expect(await res.json()).toMatchObject({ admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
|
||||
const all = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets'));
|
||||
expect((await all.json()).adminOrigins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugin-secrets', () => {
|
||||
it('stores a credential for the server origin only', async () => {
|
||||
const res = await save(VALID);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.get('nextcloud/cloud')).toMatchObject({ origin: 'https://cloud.example.org', secret: 'app-pw', username: 'alice' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a plain http server', { origin: 'http://cloud.example.org' }],
|
||||
['a header the proxy owns', { inject: { type: 'header', name: 'Cookie' } }],
|
||||
['a secret with a line break', { secret: 'a\r\nX-Evil: 1' }],
|
||||
['a bad plugin id', { pluginId: '../x' }],
|
||||
])('refuses %s', async (_name, change) => {
|
||||
const res = await save({ ...VALID, ...change });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses without a session, with plugins off, or from another site', async () => {
|
||||
signedIn = false;
|
||||
expect((await save(VALID)).status).toBe(401);
|
||||
signedIn = true;
|
||||
pluginsEnabled = false;
|
||||
expect((await save(VALID)).status).toBe(403);
|
||||
pluginsEnabled = true;
|
||||
sameOrigin = false;
|
||||
expect((await save(VALID)).status).toBe(403);
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugin-proxy', () => {
|
||||
it('passes the request on with the method and capture header', async () => {
|
||||
const res = await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Flogin%2Fv2%2Fpoll', {
|
||||
'x-bulwark-method': 'POST', 'x-bulwark-capture': '{"secret":"appPassword","username":"loginName"}',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(proxied[0]).toMatchObject({
|
||||
pluginId: 'nextcloud', credentialId: 'cloud', method: 'POST',
|
||||
url: 'https://cloud.example.org/login/v2/poll',
|
||||
capture: { secret: 'appPassword', username: 'loginName' },
|
||||
user: { username: 'alice@example.org' },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses without a session, from another site, or with a broken capture header', async () => {
|
||||
signedIn = false;
|
||||
expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(401);
|
||||
signedIn = true;
|
||||
sameOrigin = false;
|
||||
expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(403);
|
||||
sameOrigin = true;
|
||||
expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example', { 'x-bulwark-capture': '{"secret":"a.b"}' })).status).toBe(400);
|
||||
expect(proxied).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
|
||||
import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
|
||||
import {
|
||||
VaultFullError,
|
||||
deleteUserCredentials,
|
||||
listUserCredentials,
|
||||
putUserCredential,
|
||||
type VaultUser,
|
||||
} from '@/lib/plugin-secrets/vault';
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const MAX_SECRET_LENGTH = 4096;
|
||||
const NO_STORE = { 'cache-control': 'no-store' };
|
||||
|
||||
async function currentUser(request: NextRequest): Promise<VaultUser | NextResponse> {
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getPolicy().features.pluginsEnabled) {
|
||||
return NextResponse.json({ error: 'Plugins are turned off' }, { status: 403 });
|
||||
}
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
return { username: creds.username, serverUrl: creds.serverUrl };
|
||||
}
|
||||
|
||||
/** GET /api/plugin-secrets[?plugin=<id>] - the user's connected accounts, without secrets. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await currentUser(request);
|
||||
if (user instanceof NextResponse) return user;
|
||||
const pluginId = request.nextUrl.searchParams.get('plugin') ?? undefined;
|
||||
if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
|
||||
}
|
||||
const credentials = await listUserCredentials(user, pluginId);
|
||||
// For one plugin, also say which of its admin credentials are set up, and
|
||||
// on which servers: the host page checks the links a plugin shows against
|
||||
// them (the plugin itself only gets yes/no, see proxyAccounts).
|
||||
const [admin, adminOrigins] = pluginId
|
||||
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
|
||||
: [[], []];
|
||||
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plugin-secrets - connect an account for a plugin.
|
||||
* Body: { pluginId, credentialId, label, origin, inject, username?, secret? }
|
||||
*
|
||||
* Sent by the host's own "Connect" dialog, where the user typed the secret
|
||||
* and saw which plugin and which server it is for. Without `secret` it only
|
||||
* records the approved server, for a sign-in flow that fills the secret in
|
||||
* later through the proxy.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const user = await currentUser(request);
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
const { pluginId, credentialId, label, username, secret } = body;
|
||||
const origin = credentialOrigin(body.origin);
|
||||
const inject = checkInject(body.inject);
|
||||
if (typeof pluginId !== 'string' || !PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
||||
if (typeof credentialId !== 'string' || !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
||||
if (typeof label !== 'string' || !label.trim() || label.length > 80) return bad('Invalid label');
|
||||
if (!origin) return bad('The server address must start with https://');
|
||||
if (!inject) return bad('Invalid inject');
|
||||
if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username');
|
||||
if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret');
|
||||
|
||||
try {
|
||||
await putUserCredential(user, pluginId, credentialId, {
|
||||
origin, inject, label: label.trim(),
|
||||
...(typeof username === 'string' && username ? { username } : {}),
|
||||
...(typeof secret === 'string' ? { secret } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof VaultFullError) return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
throw error;
|
||||
}
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
}
|
||||
|
||||
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const user = await currentUser(request);
|
||||
if (user instanceof NextResponse) return user;
|
||||
const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
|
||||
const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
|
||||
if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
||||
if (credentialId !== undefined && !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
||||
await deleteUserCredentials(user, pluginId, credentialId);
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
}
|
||||
|
||||
function bad(error: string) {
|
||||
return NextResponse.json({ error }, { status: 400 });
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export async function GET(request: NextRequest) {
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Surface so clients can enforce api.http.post path allowlists.
|
||||
apiPostPaths: p.apiPostPaths,
|
||||
// Which credentials the plugin may use through the proxy; no secrets.
|
||||
credentials: p.credentials,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
@@ -82,6 +84,8 @@ export async function GET(request: NextRequest) {
|
||||
description: t.description,
|
||||
variants: t.variants,
|
||||
forceEnabled: t.forceEnabled || false,
|
||||
hasSkin: t.hasSkin || false,
|
||||
contentHash: t.contentHash,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
+1
-41
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@import "./theme-inline.css";
|
||||
|
||||
:root {
|
||||
font-size: var(--font-size-base);
|
||||
@@ -123,46 +123,6 @@
|
||||
--color-chart-5: #a78bfa;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-border: var(--color-border);
|
||||
--color-input: var(--color-input);
|
||||
--color-ring: var(--color-ring);
|
||||
--color-background: var(--color-background);
|
||||
--color-foreground: var(--color-foreground);
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-foreground: var(--color-primary-foreground);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-foreground: var(--color-secondary-foreground);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-muted-foreground: var(--color-muted-foreground);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-foreground: var(--color-accent-foreground);
|
||||
--color-destructive: var(--color-destructive);
|
||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||
--color-popover: var(--color-popover);
|
||||
--color-popover-foreground: var(--color-popover-foreground);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-sidebar-foreground: var(--color-sidebar-foreground);
|
||||
--color-sidebar-border: var(--color-sidebar-border);
|
||||
--color-sidebar-accent: var(--color-sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
|
||||
--color-card: var(--color-card);
|
||||
--color-card-foreground: var(--color-card-foreground);
|
||||
--color-success: var(--color-success);
|
||||
--color-success-foreground: var(--color-success-foreground);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-warning-foreground: var(--color-warning-foreground);
|
||||
--color-info: var(--color-info);
|
||||
--color-info-foreground: var(--color-info-foreground);
|
||||
--color-selection: var(--color-selection);
|
||||
--color-selection-foreground: var(--color-selection-foreground);
|
||||
--color-unread: var(--color-unread);
|
||||
--color-chart-1: var(--color-chart-1);
|
||||
--color-chart-2: var(--color-chart-2);
|
||||
--color-chart-3: var(--color-chart-3);
|
||||
--color-chart-4: var(--color-chart-4);
|
||||
--color-chart-5: var(--color-chart-5);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Shared by the app (globals.css) and the plugin UI kit (sandbox
|
||||
plugin-kit.css): the dark variant and the mapping from the theme tokens to
|
||||
Tailwind colours, so both generate the same utilities. */
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-border: var(--color-border);
|
||||
--color-input: var(--color-input);
|
||||
--color-ring: var(--color-ring);
|
||||
--color-background: var(--color-background);
|
||||
--color-foreground: var(--color-foreground);
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-foreground: var(--color-primary-foreground);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-foreground: var(--color-secondary-foreground);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-muted-foreground: var(--color-muted-foreground);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-foreground: var(--color-accent-foreground);
|
||||
--color-destructive: var(--color-destructive);
|
||||
--color-destructive-foreground: var(--color-destructive-foreground);
|
||||
--color-popover: var(--color-popover);
|
||||
--color-popover-foreground: var(--color-popover-foreground);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-sidebar-foreground: var(--color-sidebar-foreground);
|
||||
--color-sidebar-border: var(--color-sidebar-border);
|
||||
--color-sidebar-accent: var(--color-sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
|
||||
--color-card: var(--color-card);
|
||||
--color-card-foreground: var(--color-card-foreground);
|
||||
--color-success: var(--color-success);
|
||||
--color-success-foreground: var(--color-success-foreground);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-warning-foreground: var(--color-warning-foreground);
|
||||
--color-info: var(--color-info);
|
||||
--color-info-foreground: var(--color-info-foreground);
|
||||
--color-selection: var(--color-selection);
|
||||
--color-selection-foreground: var(--color-selection-foreground);
|
||||
--color-unread: var(--color-unread);
|
||||
--color-chart-1: var(--color-chart-1);
|
||||
--color-chart-2: var(--color-chart-2);
|
||||
--color-chart-3: var(--color-chart-3);
|
||||
--color-chart-4: var(--color-chart-4);
|
||||
--color-chart-5: var(--color-chart-5);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const pro = vi.hoisted(() => ({ active: false }));
|
||||
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => pro.active }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
|
||||
|
||||
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
|
||||
const { resetActions, setPluginActions } = await import('@/lib/plugin-sandbox/actions');
|
||||
|
||||
describe('keyboard shortcuts help', () => {
|
||||
it('lists Ctrl+K as the command palette outside the Pro shell', () => {
|
||||
pro.active = false;
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('command_palette.open')).toBeInTheDocument();
|
||||
expect(screen.queryByText('shortcuts.global.global_search')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists Ctrl+K as global search in the Pro shell', () => {
|
||||
pro.active = true;
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument();
|
||||
expect(screen.queryByText('command_palette.open')).toBeNull();
|
||||
});
|
||||
|
||||
it("lists a plugin's composer button shortcut under the composer, with the plugin's name", () => {
|
||||
act(() => setPluginActions('assistant', 'Writing assistant', 'composer-toolbar-actions', [
|
||||
{ id: 'ask', label: 'Assistant', shortcut: 'Ctrl+Shift+Y' },
|
||||
{ id: 'quiet', label: 'No key' },
|
||||
]));
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No key/)).toBeNull();
|
||||
act(() => resetActions());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { appLifecycleHooks, clearAllHooks, routerHooks } from '@/lib/plugin-hooks';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
let pathname = '/';
|
||||
vi.mock('next/navigation', () => ({ usePathname: () => pathname }));
|
||||
|
||||
import { PluginAppEvents, PLUGIN_INTERVAL_MS } from '../providers/plugin-app-events';
|
||||
|
||||
function listen<T extends { register: (id: string, fn: (...a: unknown[]) => unknown) => unknown }>(bus: T) {
|
||||
const fn = vi.fn();
|
||||
bus.register('test', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
pathname = '/';
|
||||
usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useAuthStore.setState({ isAuthenticated: false });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('app events for plugins', () => {
|
||||
// These used to live in the mail view and stopped on every other page.
|
||||
it('reports the network and tab state on any page', () => {
|
||||
const online = listen(appLifecycleHooks.onOnline);
|
||||
const visibility = listen(appLifecycleHooks.onVisibilityChange);
|
||||
render(<PluginAppEvents />);
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('online')); });
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')); });
|
||||
|
||||
expect(online).toHaveBeenCalledTimes(1);
|
||||
expect(visibility).toHaveBeenCalledWith({ visible: true });
|
||||
});
|
||||
|
||||
it('reports unhandled errors', () => {
|
||||
const errors = listen(appLifecycleHooks.onAppError);
|
||||
render(<PluginAppEvents />);
|
||||
|
||||
act(() => { window.dispatchEvent(new ErrorEvent('error', { message: 'boom' })); });
|
||||
|
||||
expect(errors).toHaveBeenCalledWith({ message: 'boom' });
|
||||
});
|
||||
|
||||
it('ticks once a minute', () => {
|
||||
vi.useFakeTimers();
|
||||
const tick = listen(appLifecycleHooks.onInterval);
|
||||
render(<PluginAppEvents />);
|
||||
|
||||
act(() => { vi.advanceTimersByTime(PLUGIN_INTERVAL_MS * 2 + 10); });
|
||||
|
||||
expect(tick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('says the app is ready once, after sign-in and plugin start', () => {
|
||||
const ready = listen(appLifecycleHooks.onAppReady);
|
||||
const { rerender } = render(<PluginAppEvents />);
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true });
|
||||
usePluginStore.setState({ initialized: true });
|
||||
});
|
||||
rerender(<PluginAppEvents />);
|
||||
act(() => { usePluginStore.setState({ initialized: false }); });
|
||||
act(() => { usePluginStore.setState({ initialized: true }); });
|
||||
|
||||
expect(ready).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts the plugins once the user is signed in, on any page', () => {
|
||||
render(<PluginAppEvents />);
|
||||
expect(usePluginStore.getState().initializePlugins).not.toHaveBeenCalled();
|
||||
|
||||
act(() => { useAuthStore.setState({ isAuthenticated: true }); });
|
||||
|
||||
expect(usePluginStore.getState().initializePlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports navigation between pages', () => {
|
||||
const navigate = listen(routerHooks.onNavigate);
|
||||
const { rerender } = render(<PluginAppEvents />);
|
||||
|
||||
pathname = '/calendar';
|
||||
rerender(<PluginAppEvents />);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith({ path: '/calendar', from: '/' });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { authHooks } from "@/lib/plugin-hooks";
|
||||
import {
|
||||
PLUGIN_OAUTH_EVENT,
|
||||
PLUGIN_OAUTH_STORAGE_KEY,
|
||||
createOAuthState,
|
||||
} from "@/lib/plugin-oauth";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -25,6 +26,8 @@ function emitStorageEvent(newValue: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
let STATE = "";
|
||||
|
||||
describe("PluginOAuthCallbackListener", () => {
|
||||
let handler: (payload: unknown) => Promise<void>;
|
||||
let disposable: { dispose: () => void };
|
||||
@@ -35,6 +38,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
mockUsePathname.mockReturnValue("/en/mail");
|
||||
handler = vi.fn<(payload: unknown) => Promise<void>>().mockResolvedValue(undefined);
|
||||
disposable = authHooks.onOAuthCallback.register("test-plugin", handler);
|
||||
STATE = createOAuthState("test-plugin");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -43,14 +47,14 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
it("delivers a success payload via the storage event", () => {
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
|
||||
const payload = { code: "abc", state: STATE, receivedAt: 1 };
|
||||
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
|
||||
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: "abc", state: "xyz" }),
|
||||
expect.objectContaining({ code: "abc", state: STATE }),
|
||||
);
|
||||
// Best-effort cleanup after delivery
|
||||
expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull();
|
||||
@@ -59,14 +63,14 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
it("delivers an error payload (denial) with state", () => {
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
const payload = { state: "xyz", error: "access_denied", receivedAt: 1 };
|
||||
const payload = { state: STATE, error: "access_denied", receivedAt: 1 };
|
||||
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
|
||||
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: "xyz", error: "access_denied" }),
|
||||
expect.objectContaining({ state: STATE, error: "access_denied" }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -74,7 +78,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
it("does nothing on the callback route", () => {
|
||||
mockUsePathname.mockReturnValue("/en/plugins/oauth/callback");
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
|
||||
const payload = { code: "abc", state: STATE, receivedAt: 1 };
|
||||
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
|
||||
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
@@ -95,7 +99,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
emitStorageEvent("not json");
|
||||
emitStorageEvent(JSON.stringify({ foo: "bar" }));
|
||||
emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" }));
|
||||
emitStorageEvent(JSON.stringify({ code: 123, state: STATE }));
|
||||
emitStorageEvent(null);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
@@ -119,7 +123,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
try {
|
||||
vi.setSystemTime(0);
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
|
||||
const payload = { code: "abc", state: STATE, receivedAt: 1 };
|
||||
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
|
||||
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
@@ -127,10 +131,10 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
// Outside the dedup window the same payload is delivered again
|
||||
// A state is used once: replaying it later delivers nothing either.
|
||||
vi.setSystemTime(61_000);
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
unmount();
|
||||
} finally {
|
||||
@@ -140,7 +144,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
it("delivers same-tab custom events", () => {
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
|
||||
const payload = { code: "abc", state: STATE, receivedAt: 1 };
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
@@ -150,7 +154,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: "abc", state: "xyz" }),
|
||||
expect.objectContaining({ code: "abc", state: STATE }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -159,7 +163,7 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
unmount();
|
||||
|
||||
const payload = { code: "abc", state: "xyz", receivedAt: 1 };
|
||||
const payload = { code: "abc", state: STATE, receivedAt: 1 };
|
||||
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
|
||||
emitStorageEvent(JSON.stringify(payload));
|
||||
act(() => {
|
||||
@@ -170,4 +174,29 @@ describe("PluginOAuthCallbackListener", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers the code only to the plugin that started the flow", () => {
|
||||
// Every plugin with http:fetch used to receive every code.
|
||||
const other = vi.fn<(payload: unknown) => Promise<void>>().mockResolvedValue(undefined);
|
||||
const otherDisposable = authHooks.onOAuthCallback.register("other-plugin", other);
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
|
||||
emitStorageEvent(JSON.stringify({ code: "abc", state: STATE, receivedAt: 1 }));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
otherDisposable.dispose();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("drops a callback whose state no plugin created", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
|
||||
emitStorageEvent(JSON.stringify({ code: "abc", state: "made-up", receivedAt: 1 }));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import { CalendarWeekView } from '../calendar-week-view';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
import { MiniCalendar } from '../mini-calendar';
|
||||
import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces in the calendar: a section in an event's details and in
|
||||
// its form, and the labels and dots plugins put on days.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const calendars = [
|
||||
{ id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
const existing = {
|
||||
id: 'e1',
|
||||
uid: 'uid-1',
|
||||
title: 'Planning',
|
||||
start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetDayLabels();
|
||||
});
|
||||
|
||||
describe('calendar-event-detail', () => {
|
||||
it('shows the event in the detail popover', () => {
|
||||
offerSlots('p1', ['calendar-event-detail']);
|
||||
render(
|
||||
<EventDetailPopover
|
||||
event={existing}
|
||||
calendar={calendars[0]}
|
||||
anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSaveNote={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
|
||||
expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
|
||||
});
|
||||
|
||||
it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => {
|
||||
let resize: (() => void) | null = null;
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(cb: () => void) { resize = cb; }
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
});
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
|
||||
let height = 200;
|
||||
const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.getAttribute('role') === 'dialog' ? height : 0;
|
||||
});
|
||||
try {
|
||||
offerSlots('p1', ['calendar-event-detail']);
|
||||
// An event at 14:00 in the week view of a 1280x800 window.
|
||||
render(
|
||||
<EventDetailPopover
|
||||
event={existing}
|
||||
calendar={calendars[0]}
|
||||
anchorRect={new DOMRect(600, 520, 120, 60)}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSaveNote={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const popover = screen.getByRole('dialog');
|
||||
expect(popover.style.top).toBe('520px');
|
||||
|
||||
height = 420;
|
||||
act(() => resize?.());
|
||||
expect(popover.style.top).toBe(`${800 - 420 - 12}px`);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the event in the modal view, and not in the form', () => {
|
||||
offerSlots('p1', ['calendar-event-detail']);
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
|
||||
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when no plugin offers it', () => {
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar-event-form', () => {
|
||||
it('hands the plugin the form state of an edited event and follows the fields', async () => {
|
||||
offerSlots('p1', ['calendar-event-form']);
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
const frame = screen.getByTestId('plugin-frame-calendar-event-form');
|
||||
expect(framePropsOf(frame).event).toMatchObject({
|
||||
id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
|
||||
await waitFor(() => {
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a new event an empty id', () => {
|
||||
offerSlots('p1', ['calendar-event-form']);
|
||||
render(
|
||||
<EventModal
|
||||
calendars={calendars}
|
||||
defaultDate={new Date('2026-10-12T10:00:00')}
|
||||
defaultEndDate={new Date('2026-10-12T11:30:00')}
|
||||
defaultCalendarId="cal-1"
|
||||
onSave={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
|
||||
id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when no plugin offers it', () => {
|
||||
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar-day-header and mini-calendar-marks', () => {
|
||||
const week = {
|
||||
focus: { date: new Date(2026, 8, 9), nonce: 0 },
|
||||
windowKey: 'week:2026-09-07',
|
||||
rangeStart: new Date(2026, 8, 7),
|
||||
rangeEnd: new Date(2026, 8, 13),
|
||||
selectedDate: new Date(2026, 8, 9),
|
||||
events: [],
|
||||
calendars,
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
onCreateAtTime: vi.fn(),
|
||||
};
|
||||
|
||||
function headerOf(dayKey: string): HTMLElement {
|
||||
const header = document.querySelector<HTMLElement>(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
|
||||
if (!header) throw new Error(`no header for ${dayKey}`);
|
||||
return header;
|
||||
}
|
||||
|
||||
it('puts a label under the day name in the week view', () => {
|
||||
render(<CalendarWeekView {...week} />);
|
||||
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
|
||||
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
|
||||
|
||||
const label = headerOf('2026-09-09').querySelector<HTMLElement>('[data-plugin-surface="calendar-day-header"]');
|
||||
expect(label).toHaveTextContent('Feast day');
|
||||
expect(label?.title).toBe('A feast (Holidays)');
|
||||
expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts a label under the day name in the day view', () => {
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
|
||||
render(<CalendarDayView {...week} windowKey="day:2026-09-09" rangeStart={new Date(2026, 8, 9)} rangeEnd={new Date(2026, 8, 9)} />);
|
||||
|
||||
expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
|
||||
});
|
||||
|
||||
it('shows the label in the month cell header, as a dot on phones', () => {
|
||||
act(() => setDayLabels('hol', 'Holidays', [
|
||||
{ date: '2026-09-09', text: 'Feast day' },
|
||||
{ date: '2026-09-10', dot: true, title: 'Dot only' },
|
||||
]));
|
||||
const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
|
||||
const { unmount } = render(<CalendarMonthView {...month} />);
|
||||
|
||||
const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toHaveTextContent('Feast day');
|
||||
expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
|
||||
unmount();
|
||||
|
||||
render(<CalendarMonthView {...month} isMobile />);
|
||||
const dots = [...document.querySelectorAll<HTMLElement>('[data-plugin-surface="calendar-day-header"]')];
|
||||
expect(dots).toHaveLength(2);
|
||||
for (const dot of dots) expect(dot).toHaveAttribute('data-compact');
|
||||
expect(dots[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
|
||||
expect(dots[0].title).toBe('Feast day (Holidays)');
|
||||
expect(dots[1].title).toBe('Dot only (Holidays)');
|
||||
});
|
||||
|
||||
it('marks days with a dot in the small month calendar', () => {
|
||||
render(
|
||||
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
|
||||
);
|
||||
expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
|
||||
|
||||
act(() => setDayLabels('hol', 'Holidays', [
|
||||
{ date: '2026-09-09', dot: true, title: 'A feast' },
|
||||
{ date: '2026-09-10', text: 'Text only' },
|
||||
]));
|
||||
|
||||
const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
|
||||
expect(dots).toHaveLength(1);
|
||||
expect(dots[0].closest('button')).toHaveTextContent('9');
|
||||
expect(dots[0].closest('button')).toHaveTextContent('A feast');
|
||||
});
|
||||
|
||||
it('keeps the dot visible on today, whose filled circle has white text', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 8, 9, 12));
|
||||
try {
|
||||
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', dot: true, title: 'A feast' }]));
|
||||
render(
|
||||
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
|
||||
);
|
||||
const dot = document.querySelector<HTMLElement>('[data-plugin-surface="mini-calendar-marks"]')!;
|
||||
expect(dot.closest('button')).toHaveAttribute('aria-current', 'date');
|
||||
// Its own colour, not the button's text colour, which is white on today.
|
||||
expect(dot.className).not.toMatch(/bg-current/);
|
||||
expect(dot.className).toMatch(/ring-background/);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { calendarFormHooks, calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Opening an event for reading fires onCalendarEventOpen once; opening the
|
||||
// form (a new event, or Edit on an open one) fires onCalendarEventFormOpen.
|
||||
|
||||
const calendars = [
|
||||
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
|
||||
] as unknown as Calendar[];
|
||||
|
||||
const existing = {
|
||||
id: 'e1',
|
||||
uid: 'uid-1',
|
||||
title: 'Planning',
|
||||
start: '2026-10-12T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
describe('EventModal open hooks', () => {
|
||||
it('reports an existing event opened for reading, and the form when Edit is pressed', async () => {
|
||||
const opened = vi.fn();
|
||||
const formOpened = vi.fn();
|
||||
calendarHooks.onCalendarEventOpen.register('t', opened);
|
||||
calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened);
|
||||
|
||||
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(opened).toHaveBeenCalledTimes(1));
|
||||
expect(opened.mock.calls[0][0]).toMatchObject({ id: 'e1', title: 'Planning', calendarId: 'cal-1' });
|
||||
expect(formOpened).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1));
|
||||
expect(formOpened.mock.calls[0][0]).toMatchObject({ title: 'Planning', calendarId: 'cal-1', uid: 'uid-1', isAllDay: false });
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the form for a new event, and no event open', async () => {
|
||||
const opened = vi.fn();
|
||||
const formOpened = vi.fn();
|
||||
calendarHooks.onCalendarEventOpen.register('t', opened);
|
||||
calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened);
|
||||
|
||||
render(
|
||||
<EventModal
|
||||
calendars={calendars}
|
||||
defaultDate={new Date('2026-10-12T10:00:00')}
|
||||
defaultCalendarId="cal-1"
|
||||
onSave={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1));
|
||||
expect(formOpened.mock.calls[0][0]).toMatchObject({ calendarId: 'cal-1', uid: undefined, attendees: [] });
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { TaskModal } from '../task-modal';
|
||||
import { TaskToolbar } from '../task-toolbar';
|
||||
import { filterTasksForList } from '../task-list-view';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces for tasks: a section in a task's details and buttons in
|
||||
// the task list toolbar acting on the tasks the list shows.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[];
|
||||
|
||||
function makeTask(id: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
|
||||
return {
|
||||
id,
|
||||
'@type': 'Task',
|
||||
uid: `uid-${id}`,
|
||||
title: `Task ${id}`,
|
||||
description: '',
|
||||
due: null,
|
||||
showWithoutTime: true,
|
||||
priority: 0,
|
||||
progress: 'needs-action',
|
||||
calendarIds: { 'cal-1': true },
|
||||
alerts: null,
|
||||
...overrides,
|
||||
} as unknown as CalendarTask;
|
||||
}
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('task-detail-section', () => {
|
||||
it('shows an open task to the plugin', () => {
|
||||
offerSlots('p1', ['task-detail-section']);
|
||||
render(<TaskModal task={makeTask('t1', { priority: 5 })} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-task-detail-section');
|
||||
expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false });
|
||||
});
|
||||
|
||||
it('is not shown for a new task or without a plugin', () => {
|
||||
offerSlots('p1', ['task-detail-section']);
|
||||
const { unmount } = render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
|
||||
unmount();
|
||||
|
||||
clearSlotOffers();
|
||||
render(<TaskModal task={makeTask('t1')} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-list-actions', () => {
|
||||
const toolbar = {
|
||||
filter: 'all' as const,
|
||||
showCompleted: false,
|
||||
onFilterChange: vi.fn(),
|
||||
onShowCompletedChange: vi.fn(),
|
||||
onCreateTask: vi.fn(),
|
||||
};
|
||||
|
||||
it('puts plugin buttons after the create button and sends the listed task ids', async () => {
|
||||
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
|
||||
render(<TaskToolbar {...toolbar} taskIds={['t2', 't1']} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Plan my day' });
|
||||
expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] });
|
||||
});
|
||||
|
||||
it('draws icon-only buttons when compact', () => {
|
||||
act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
|
||||
render(<TaskToolbar {...toolbar} taskIds={[]} compact />);
|
||||
expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
|
||||
});
|
||||
|
||||
it('renders nothing without a plugin', () => {
|
||||
render(<TaskToolbar {...toolbar} taskIds={['t1']} />);
|
||||
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the ids the task list shows, in its order', () => {
|
||||
const tasks = [
|
||||
makeTask('done', { progress: 'completed' }),
|
||||
makeTask('later', { due: '2030-01-02' }),
|
||||
makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
|
||||
makeTask('soon', { due: '2030-01-01' }),
|
||||
];
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
|
||||
expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView } from "@/components/calendar/task-list-view";
|
||||
import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
@@ -97,6 +97,8 @@ import { findMeetingLink } from "@/lib/event-links";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -707,6 +709,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setDetailPinned(true);
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
// A click opens the event's card; the hover preview does not count as
|
||||
// opening it. The editor reports its own opens (event-modal).
|
||||
void calendarHooks.onCalendarEventOpen.emit(eventToView(event));
|
||||
}, [isMobile, closeDetail, openEditModal]);
|
||||
|
||||
const {
|
||||
@@ -1206,7 +1211,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
delete (newEventData as Record<string, unknown>).uid;
|
||||
delete (newEventData as Record<string, unknown>).recurrenceId;
|
||||
try {
|
||||
await createEvent(client, newEventData, sendScheduling);
|
||||
// null when the save failed or a plugin stopped it: either way
|
||||
// the truncated master has to be restored.
|
||||
const created = await createEvent(client, newEventData, sendScheduling);
|
||||
if (!created) throw new Error("The new series was not created");
|
||||
} catch (createError) {
|
||||
debug.error("Failed to create new series, rolling back master truncation:", createError);
|
||||
try {
|
||||
@@ -1480,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
|
||||
// The tasks the task list shows; plugin buttons in its toolbar act on them.
|
||||
const listedTaskIds = useMemo(
|
||||
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
|
||||
[taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
|
||||
);
|
||||
|
||||
// Tasks on the month, week and day grids follow the calendar filter like
|
||||
// the events do (#1107).
|
||||
const calendarTasks = useMemo(() => {
|
||||
@@ -1671,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onFilterChange={taskStore.setFilter}
|
||||
onShowCompletedChange={taskStore.setShowCompleted}
|
||||
onCreateTask={openCreateTaskModal}
|
||||
taskIds={listedTaskIds}
|
||||
compact={isMobile}
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
|
||||
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarDayViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -334,6 +335,7 @@ export function CalendarDayView({
|
||||
>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
|
||||
</div>
|
||||
|
||||
{hasAllDayArea && (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -379,6 +380,8 @@ export function CalendarMonthView({
|
||||
>
|
||||
{formatDayNumber(day)}
|
||||
</button>
|
||||
{/* Phones have no room for the text: a dot stands in for it. */}
|
||||
<PluginDayLabel surface="calendar-day-header" date={key} compact={isMobile} className={isMobile ? undefined : "min-w-0 flex-1"} />
|
||||
</div>
|
||||
{isMobile && !showChips ? (
|
||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
|
||||
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { readableTextOn } from "@/lib/color-transform";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -436,6 +437,7 @@ export function CalendarWeekView({
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
<PluginDayLabel surface="calendar-day-header" date={dayKey(day)} className="w-full px-1 text-center" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
event: CalendarEvent;
|
||||
@@ -52,13 +54,18 @@ const POPOVER_GAP = 8;
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const MAX_HEIGHT = 480;
|
||||
|
||||
/** As tall as MAX_HEIGHT, but never taller than the window. */
|
||||
function maxPopoverHeight(): number {
|
||||
return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN));
|
||||
}
|
||||
|
||||
function computePosition(
|
||||
anchorRect: DOMRect,
|
||||
popoverHeight: number
|
||||
): { top: number; left: number } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
|
||||
const clampedHeight = Math.min(popoverHeight, maxPopoverHeight());
|
||||
|
||||
const clampTop = (top: number) =>
|
||||
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
|
||||
@@ -145,6 +152,7 @@ export function EventDetailPopover({
|
||||
// Invitations (Teams above all) leave virtualLocations empty and bury the
|
||||
// join URL in the description; surface it as the meeting link anyway.
|
||||
const meeting = useMemo(() => findMeetingLink(event), [event]);
|
||||
const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
|
||||
const virtualLocation = meeting?.uri ?? null;
|
||||
|
||||
// A URL opens as a link, a location that only names the service joins the
|
||||
@@ -223,6 +231,19 @@ export function EventDetailPopover({
|
||||
if (!ready) requestAnimationFrame(() => setReady(true));
|
||||
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
|
||||
|
||||
// Content that grows after the popover was placed (a plugin section reports
|
||||
// its height a moment later) moves it again, so it stays in the window.
|
||||
useEffect(() => {
|
||||
const node = popoverRef.current;
|
||||
if (!node || isMobile || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const next = computePosition(anchorRect, node.offsetHeight);
|
||||
setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next));
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRect, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -299,14 +320,14 @@ export function EventDetailPopover({
|
||||
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
|
||||
isMobile
|
||||
? "inset-0 rounded-none flex flex-col"
|
||||
: "rounded-lg"
|
||||
: "rounded-lg flex flex-col"
|
||||
)}
|
||||
style={isMobile ? {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
} : {
|
||||
width: POPOVER_WIDTH,
|
||||
maxHeight: MAX_HEIGHT,
|
||||
maxHeight: maxPopoverHeight(),
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
opacity: ready ? 1 : 0,
|
||||
@@ -410,7 +431,7 @@ export function EventDetailPopover({
|
||||
{/* Content */}
|
||||
<div className={cn(
|
||||
"px-4 py-2 space-y-2.5 overflow-y-auto",
|
||||
isMobile ? "flex-1" : ""
|
||||
isMobile ? "flex-1" : "min-h-0"
|
||||
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
|
||||
{/* Date & Time */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -580,6 +601,8 @@ export function EventDetailPopover({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="calendar-event-detail" className="w-full" extraProps={pluginProps} />
|
||||
</div>
|
||||
|
||||
{/* Quick Note */}
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
calendarHooks,
|
||||
type CalendarEventFormSavePatch,
|
||||
} from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -418,6 +419,82 @@ export function EventModal({
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]);
|
||||
|
||||
// Plugins hear about an existing event opened for reading once per event,
|
||||
// and about the form each time it opens (new event, or view -> edit).
|
||||
const reportedOpenRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!event || mode !== "view" || reportedOpenRef.current === event.id) return;
|
||||
reportedOpenRef.current = event.id;
|
||||
void calendarHooks.onCalendarEventOpen.emit(eventToView(event));
|
||||
}, [event, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || calendarFormHooks.onCalendarEventFormOpen.size === 0) return;
|
||||
void calendarFormHooks.onCalendarEventFormOpen.emit({
|
||||
title,
|
||||
description,
|
||||
start: allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`,
|
||||
end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
|
||||
isAllDay: allDay,
|
||||
location,
|
||||
virtualLocation,
|
||||
calendarId,
|
||||
uid: event?.uid,
|
||||
attendees: attendees.map((a) => a.email),
|
||||
});
|
||||
// Only the opening counts; the fields are read as they are at that moment.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
// calendar-event-detail: the event as plugins see it, in the read views.
|
||||
const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
|
||||
|
||||
// calendar-event-form: the form's current state in the same shape. Typing
|
||||
// would hand the plugin iframe new props on every key, so the view settles
|
||||
// for half a second first.
|
||||
const formEventView = useMemo<CalendarEventView>(() => {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
|
||||
const start = new Date(startStr);
|
||||
let duration = event?.duration ?? "PT1H";
|
||||
if (!isNaN(start.getTime())) {
|
||||
if (allDay) {
|
||||
const inclusiveEnd = new Date(`${endDate}T00:00:00`);
|
||||
if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
|
||||
} else {
|
||||
const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
|
||||
if (!isNaN(end.getTime())) duration = buildDuration(start, end);
|
||||
}
|
||||
}
|
||||
const base = event ? eventToView(event) : null;
|
||||
return {
|
||||
id: event?.id ?? "",
|
||||
uid: event?.uid ?? "",
|
||||
calendarId,
|
||||
title,
|
||||
description,
|
||||
start: startStr,
|
||||
duration,
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
utcStart: null,
|
||||
utcEnd: null,
|
||||
isAllDay: allDay,
|
||||
location,
|
||||
status: base?.status ?? "confirmed",
|
||||
participants: attendees.map((a) => {
|
||||
const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
|
||||
return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
|
||||
}),
|
||||
isRecurring: recurrence !== "none",
|
||||
keywords: base?.keywords ?? [],
|
||||
};
|
||||
}, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
|
||||
const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formEventView]);
|
||||
const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -795,6 +872,8 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -972,6 +1051,8 @@ export function EventModal({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1319,6 +1400,8 @@ export function EventModal({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
|
||||
@@ -199,6 +200,7 @@ export function MiniCalendar({
|
||||
{hasEvent && !selected && !today && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
|
||||
|
||||
const NONE: RegisteredDayLabel[] = [];
|
||||
const EMPTY_MAP = new Map<string, RegisteredDayLabel[]>();
|
||||
|
||||
/** The labels plugins put on one day (api.ui.setDayLabels). */
|
||||
export function useDayLabels(date: string): RegisteredDayLabel[] {
|
||||
const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
|
||||
return byDate.get(date) ?? NONE;
|
||||
}
|
||||
|
||||
function tooltip(labels: RegisteredDayLabel[]): string {
|
||||
return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
|
||||
}
|
||||
|
||||
interface PluginDayLabelProps {
|
||||
/**
|
||||
* calendar-day-header: the label text under a day's name (day, week and
|
||||
* month views). mini-calendar-marks: a dot on the day in the small month
|
||||
* calendar; the parent must be positioned.
|
||||
*/
|
||||
surface: "calendar-day-header" | "mini-calendar-marks";
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
className?: string;
|
||||
/**
|
||||
* calendar-day-header only: no room for text (the phone month view), so a
|
||||
* dot stands for the day's labels; their text is its tooltip and is read
|
||||
* out by screen readers.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** What plugins put on a calendar day. Renders nothing without labels. */
|
||||
export function PluginDayLabel({ surface, date, className, compact }: PluginDayLabelProps) {
|
||||
const labels = useDayLabels(date);
|
||||
|
||||
if (surface === "mini-calendar-marks") {
|
||||
const marks = labels.filter((l) => l.dot);
|
||||
if (marks.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-plugin-surface="mini-calendar-marks"
|
||||
title={tooltip(marks)}
|
||||
className={cn("pointer-events-none absolute top-0 end-0 h-1.5 w-1.5 rounded-full bg-muted-foreground ring-1 ring-background", className)}
|
||||
/>
|
||||
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
const shown = labels.filter((l) => l.text || l.dot);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
data-plugin-surface="calendar-day-header"
|
||||
data-compact=""
|
||||
title={tooltip(shown)}
|
||||
className={cn("inline-flex flex-shrink-0", className)}
|
||||
>
|
||||
<span aria-hidden="true" className="block h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
<span className="sr-only">{shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const withText = labels.filter((l) => l.text);
|
||||
if (withText.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-plugin-surface="calendar-day-header"
|
||||
title={tooltip(withText)}
|
||||
className={cn("max-w-full truncate text-[10px] leading-tight text-muted-foreground", className)}
|
||||
>
|
||||
{withText.map((l) => l.text).join(" · ")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
|
||||
};
|
||||
}
|
||||
|
||||
/** The tasks the list shows, in its order: filtered by calendar and status. */
|
||||
export function filterTasksForList(
|
||||
tasks: CalendarTask[],
|
||||
selectedCalendarIds: string[],
|
||||
filter: TaskViewFilter,
|
||||
showCompleted: boolean,
|
||||
): CalendarTask[] {
|
||||
let result = tasks.filter(task => {
|
||||
const calIds = Object.keys(task.calendarIds);
|
||||
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||
});
|
||||
|
||||
if (!showCompleted) {
|
||||
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case "pending":
|
||||
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||
break;
|
||||
case "completed":
|
||||
result = result.filter(task => task.progress === "completed");
|
||||
break;
|
||||
case "overdue":
|
||||
result = result.filter(task => {
|
||||
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||
result.sort((a, b) => {
|
||||
// Completed tasks at the bottom
|
||||
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||
|
||||
// Tasks with due dates before those without
|
||||
if (a.due && !b.due) return -1;
|
||||
if (!a.due && b.due) return 1;
|
||||
if (a.due && b.due) {
|
||||
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
|
||||
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||
const aPri = a.priority || 10;
|
||||
const bPri = b.priority || 10;
|
||||
return aPri - bPri;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function TaskListView({
|
||||
tasks,
|
||||
calendars,
|
||||
@@ -77,53 +131,10 @@ export function TaskListView({
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks.filter(task => {
|
||||
const calIds = Object.keys(task.calendarIds);
|
||||
return calIds.some(id => selectedCalendarIds.includes(id));
|
||||
});
|
||||
|
||||
if (!showCompleted) {
|
||||
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case "pending":
|
||||
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
|
||||
break;
|
||||
case "completed":
|
||||
result = result.filter(task => task.progress === "completed");
|
||||
break;
|
||||
case "overdue":
|
||||
result = result.filter(task => {
|
||||
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
|
||||
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then by due date (no due date last), then by priority
|
||||
result.sort((a, b) => {
|
||||
// Completed tasks at the bottom
|
||||
if (a.progress === "completed" && b.progress !== "completed") return 1;
|
||||
if (a.progress !== "completed" && b.progress === "completed") return -1;
|
||||
|
||||
// Tasks with due dates before those without
|
||||
if (a.due && !b.due) return -1;
|
||||
if (!a.due && b.due) return 1;
|
||||
if (a.due && b.due) {
|
||||
const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
|
||||
// Higher priority first (lower number = higher priority, but 0 = no priority goes last)
|
||||
const aPri = a.priority || 10;
|
||||
const bPri = b.priority || 10;
|
||||
return aPri - bPri;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [tasks, selectedCalendarIds, filter, showCompleted]);
|
||||
const filteredTasks = useMemo(
|
||||
() => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
|
||||
[tasks, selectedCalendarIds, filter, showCompleted],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,6 +8,8 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { taskToView } from "@/lib/plugin-projection";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
@@ -48,6 +50,8 @@ export function TaskModal({
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!task;
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
// task-detail-section: the saved task, for an existing task only.
|
||||
const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
|
||||
|
||||
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
@@ -302,6 +306,8 @@ export function TaskModal({
|
||||
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{pluginTaskProps && <PluginSlot name="task-detail-section" className="w-full" extraProps={pluginTaskProps} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
|
||||
interface TaskToolbarProps {
|
||||
@@ -12,8 +14,14 @@ interface TaskToolbarProps {
|
||||
onFilterChange: (filter: TaskViewFilter) => void;
|
||||
onShowCompletedChange: (show: boolean) => void;
|
||||
onCreateTask: () => void;
|
||||
/** The tasks the list shows, in order: what plugin buttons here act on. */
|
||||
taskIds?: string[];
|
||||
/** Icon-only plugin buttons (phones). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const NO_IDS: string[] = [];
|
||||
|
||||
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||
|
||||
export function TaskToolbar({
|
||||
@@ -22,8 +30,11 @@ export function TaskToolbar({
|
||||
onFilterChange,
|
||||
onShowCompletedChange,
|
||||
onCreateTask,
|
||||
taskIds = NO_IDS,
|
||||
compact,
|
||||
}: TaskToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const pluginContext = useMemo(() => ({ taskIds }), [taskIds]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||
@@ -60,6 +71,8 @@ export function TaskToolbar({
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t("tasks.create")}
|
||||
</Button>
|
||||
|
||||
<PluginActions surface="task-list-actions" context={pluginContext} compact={compact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}(${Object.values(params).join(',')})` : key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => nav.pathname,
|
||||
Link: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
vi.mock('@/components/keyboard-shortcuts-modal', () => ({
|
||||
KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div>shortcuts help</div> : null),
|
||||
}));
|
||||
|
||||
const { CommandPaletteHost } = await import('../command-palette');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useThemeStore } = await import('@/stores/theme-store');
|
||||
const appCommands = await import('@/lib/app-commands');
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const pressCtrlK = (target: Element = document.body) =>
|
||||
act(() => { fireEvent.keyDown(target, { key: 'k', code: 'KeyK', ctrlKey: true }); });
|
||||
|
||||
beforeEach(() => {
|
||||
nav.pathname = '/';
|
||||
nav.push.mockClear();
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useAuthStore.setState({ isAuthenticated: true, client: null } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
appCommands.parkAppCommand(null as never);
|
||||
});
|
||||
|
||||
describe('command palette', () => {
|
||||
it('opens on Ctrl+K with the search field focused, and closes on Escape', () => {
|
||||
render(<CommandPaletteHost />);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
pressCtrlK();
|
||||
expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
|
||||
const input = screen.getByRole('combobox');
|
||||
expect(input).toHaveFocus();
|
||||
expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id);
|
||||
|
||||
act(() => { fireEvent.keyDown(input, { key: 'Escape' }); });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens from a menu entry (phones)', () => {
|
||||
render(<CommandPaletteHost />);
|
||||
act(() => appCommands.openCommandPalette());
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves Ctrl+K alone while typing in a field', () => {
|
||||
render(<><input aria-label="other field" /><CommandPaletteHost /></>);
|
||||
pressCtrlK(screen.getByLabelText('other field'));
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves Ctrl+K to global search in the Pro shell, and does nothing signed out', () => {
|
||||
nav.pathname = '/pro';
|
||||
const { unmount } = render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
unmount();
|
||||
|
||||
nav.pathname = '/';
|
||||
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the app commands and no plugin group without plugin entries', () => {
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
|
||||
const labels = screen.getAllByRole('option').map((o) => o.textContent);
|
||||
expect(labels).toContain('go_to(mail)');
|
||||
expect(labels).toContain('go_to(settings)');
|
||||
expect(labels).toContain('compose');
|
||||
expect(labels).toContain('shortcuts');
|
||||
expect(screen.queryByText('group_plugins')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
|
||||
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
|
||||
expect(screen.getByText('group_plugins')).toBeInTheDocument();
|
||||
const option = screen.getByRole('option', { name: /Create issue/ });
|
||||
expect(option).toHaveTextContent('Jira');
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
act(() => { fireEvent.change(input, { target: { value: 'issue' } }); });
|
||||
expect(screen.getAllByRole('option')).toHaveLength(1);
|
||||
expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'true');
|
||||
expect(input).toHaveAttribute('aria-activedescendant', screen.getByRole('option').id);
|
||||
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
|
||||
expect(dispatch).toHaveBeenCalledWith('jira', 'command-palette', 'new-issue', {});
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds plugin entries by plugin name too', () => {
|
||||
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.change(screen.getByRole('combobox'), { target: { value: 'jir' } }); });
|
||||
expect(screen.getAllByRole('option').map((o) => o.getAttribute('data-command-id'))).toEqual(['plugin:jira:new-issue']);
|
||||
});
|
||||
|
||||
it('moves with the arrow keys and wraps around', () => {
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
const input = screen.getByRole('combobox');
|
||||
const options = () => screen.getAllByRole('option');
|
||||
|
||||
expect(options()[0]).toHaveAttribute('aria-selected', 'true');
|
||||
act(() => { fireEvent.keyDown(input, { key: 'ArrowDown' }); });
|
||||
expect(options()[1]).toHaveAttribute('aria-selected', 'true');
|
||||
act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
|
||||
act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
|
||||
expect(options()[options().length - 1]).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('navigates with a go-to command', () => {
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.click(screen.getByRole('option', { name: 'go_to(settings)' })); });
|
||||
expect(nav.push).toHaveBeenCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('hands compose to the mail page, or opens mail first and parks it', () => {
|
||||
const taken = vi.fn((e: Event) => e.preventDefault());
|
||||
window.addEventListener('bulwark:app-command', taken);
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
|
||||
expect(taken).toHaveBeenCalled();
|
||||
expect(nav.push).not.toHaveBeenCalled();
|
||||
window.removeEventListener('bulwark:app-command', taken);
|
||||
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
|
||||
expect(nav.push).toHaveBeenCalledWith('/');
|
||||
expect(appCommands.peekParkedAppCommand()).toBe('compose');
|
||||
});
|
||||
|
||||
it('switches the theme and shows the keyboard shortcuts', () => {
|
||||
useThemeStore.setState({ resolvedTheme: 'light' } as never);
|
||||
const setTheme = vi.fn();
|
||||
useThemeStore.setState({ setTheme } as never);
|
||||
render(<CommandPaletteHost />);
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.click(screen.getByRole('option', { name: 'theme_dark' })); });
|
||||
expect(setTheme).toHaveBeenCalledWith('dark');
|
||||
|
||||
pressCtrlK();
|
||||
act(() => { fireEvent.click(screen.getByRole('option', { name: 'shortcuts' })); });
|
||||
expect(screen.getByText('shortcuts help')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
BookUser,
|
||||
Calendar,
|
||||
HardDrive,
|
||||
Keyboard,
|
||||
Mail,
|
||||
Moon,
|
||||
PenLine,
|
||||
Puzzle,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
iconForName,
|
||||
} from "@/components/icons";
|
||||
import type { AppIcon } from "@/components/icons";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PaletteCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: AppIcon;
|
||||
/** Shown after the label: the plugin that added the entry. */
|
||||
source?: string;
|
||||
group: "navigation" | "actions" | "plugins";
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/** The action surface plugins declare palette entries on (api.ui.setActions). */
|
||||
const surface = "command-palette";
|
||||
|
||||
/** Surfaces where the palette does not take Ctrl+K: the Pro shell uses it for global search. */
|
||||
const isProShell = (pathname: string) => pathname === "/pro" || pathname.startsWith("/pro/");
|
||||
|
||||
/**
|
||||
* The command palette (Ctrl+K / ⌘K): a searchable list of the app's own
|
||||
* commands and of the entries plugins declare for `command-palette`.
|
||||
* Mounted once in the locale layout; it only listens while signed in.
|
||||
*/
|
||||
export function CommandPaletteHost() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const enabled = isAuthenticated && !isProShell(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;
|
||||
// event.code: the K key on any keyboard layout, Latin or not.
|
||||
if (event.code !== "KeyK" && event.key.toLowerCase() !== "k") return;
|
||||
if (isEditableEventTarget(event)) return;
|
||||
event.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) setOpen(false);
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
|
||||
<KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
|
||||
const t = useTranslations("command_palette");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const listId = useId();
|
||||
const optionId = (index: number) => `${listId}-option-${index}`;
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
|
||||
const isFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled);
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const pluginActions = usePluginActions(surface);
|
||||
|
||||
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
|
||||
|
||||
const commands = useMemo<PaletteCommand[]>(() => {
|
||||
const go = (href: string) => () => router.push(href);
|
||||
// Compose and search belong to the mail page: run there, or open mail first.
|
||||
const onMail = (command: AppCommand) => () => {
|
||||
if (requestAppCommand(command)) return;
|
||||
parkAppCommand(command);
|
||||
router.push("/");
|
||||
};
|
||||
const list: PaletteCommand[] = [
|
||||
{ id: "go-mail", group: "navigation", icon: Mail, label: t("go_to", { name: tSidebar("mail") }), run: go("/") },
|
||||
];
|
||||
if (supportsCalendar && isFeatureEnabled("calendarEnabled")) {
|
||||
list.push({ id: "go-calendar", group: "navigation", icon: Calendar, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
|
||||
}
|
||||
if ((client?.supportsContacts?.() ?? false) && isFeatureEnabled("contactsEnabled")) {
|
||||
list.push({ id: "go-contacts", group: "navigation", icon: BookUser, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
|
||||
}
|
||||
if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
|
||||
list.push({ id: "go-files", group: "navigation", icon: HardDrive, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
|
||||
}
|
||||
list.push(
|
||||
{ id: "go-settings", group: "navigation", icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
|
||||
{ id: "compose", group: "actions", icon: PenLine, label: t("compose"), run: onMail("compose") },
|
||||
{ id: "search", group: "actions", icon: Search, label: t("search"), run: onMail("search") },
|
||||
{
|
||||
id: "theme",
|
||||
group: "actions",
|
||||
icon: resolvedTheme === "dark" ? Sun : Moon,
|
||||
label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
|
||||
run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
|
||||
},
|
||||
{ id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts },
|
||||
);
|
||||
for (const action of pluginActions) {
|
||||
list.push({
|
||||
id: `plugin:${action.pluginId}:${action.id}`,
|
||||
group: "plugins",
|
||||
icon: iconForName(action.icon) ?? Puzzle,
|
||||
label: action.label,
|
||||
source: action.pluginName,
|
||||
run: () => { void runAction(action, surface, {}); },
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [client, supportsCalendar, isFeatureEnabled, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = needle
|
||||
? commands.filter((c) => c.label.toLowerCase().includes(needle) || (c.source?.toLowerCase().includes(needle) ?? false))
|
||||
: commands;
|
||||
const activeIndex = visible.length === 0 ? -1 : Math.min(active, visible.length - 1);
|
||||
|
||||
// Close when the page changes underneath (a "go to" command ran).
|
||||
const startPath = useRef(pathname);
|
||||
useEffect(() => {
|
||||
if (pathname !== startPath.current) onClose();
|
||||
}, [pathname, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
const node = listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
|
||||
node?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const runCommand = useCallback((command: PaletteCommand) => {
|
||||
onClose();
|
||||
command.run();
|
||||
}, [onClose]);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (visible.length) setActive((activeIndex + 1) % visible.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (visible.length) setActive((activeIndex - 1 + visible.length) % visible.length);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setActive(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setActive(Math.max(0, visible.length - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) runCommand(visible[activeIndex]);
|
||||
} else if ((event.ctrlKey || event.metaKey) && (event.code === "KeyK" || event.key.toLowerCase() === "k")) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const groupLabel = (group: PaletteCommand["group"]) =>
|
||||
group === "navigation" ? t("group_navigation") : group === "actions" ? t("group_actions") : t("group_plugins");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] max-sm:p-0 max-sm:pt-0"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title")}
|
||||
className={cn(
|
||||
"w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl",
|
||||
"max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3">
|
||||
<Search className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
|
||||
aria-label={t("title")}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActive(0); }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t("placeholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Esc</kbd>
|
||||
</div>
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t("title")}
|
||||
className="max-h-[60vh] max-sm:max-h-none max-sm:flex-1 overflow-y-auto py-1"
|
||||
>
|
||||
{visible.length === 0 && (
|
||||
<li role="presentation" className="px-4 py-6 text-center text-sm text-muted-foreground">{t("no_results")}</li>
|
||||
)}
|
||||
{visible.map((command, index) => {
|
||||
const Icon = command.icon;
|
||||
const showGroup = index === 0 || visible[index - 1].group !== command.group;
|
||||
return (
|
||||
<li key={command.id} role="presentation">
|
||||
{showGroup && (
|
||||
<div aria-hidden="true" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{groupLabel(command.group)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
data-index={index}
|
||||
data-command-id={command.id}
|
||||
onMouseMove={() => { if (index !== activeIndex) setActive(index); }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => runCommand(command)}
|
||||
className={cn(
|
||||
"mx-1 flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm max-sm:min-h-[44px]",
|
||||
index === activeIndex ? "bg-muted text-foreground" : "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate">{command.label}</span>
|
||||
{command.source && (
|
||||
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
@@ -65,3 +66,23 @@ describe('ContactDetail', () => {
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactDetail plugin hooks', () => {
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
it('tells plugins once when a contact is opened, again for another one', async () => {
|
||||
const opened = vi.fn();
|
||||
contactHooks.onContactOpen.register('test', opened);
|
||||
|
||||
const { rerender } = render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
// Same contact with changed data: still the same open contact.
|
||||
rerender(<ContactDetail contact={{ ...contact, notes: {} }} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
rerender(<ContactDetail contact={{ ...contact, id: '2' }} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
rerender(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(opened).toHaveBeenCalledTimes(2);
|
||||
expect(opened.mock.calls[0][0]).toMatchObject({ id: '1', fullName: 'Alice Smith', emails: ['alice@example.com'] });
|
||||
expect(opened.mock.calls[1][0]).toMatchObject({ id: '2' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import { ContactList } from '../contact-list';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// Plugin surfaces in contacts: a section in a contact's details and buttons
|
||||
// in the list's selection toolbar.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: '1',
|
||||
uid: 'uid-alice',
|
||||
addressBookIds: { ab1: true },
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
notes: { n0: { note: 'VIP customer' } },
|
||||
};
|
||||
const bob: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: { ab1: true },
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
};
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearSlotOffers();
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('contact-detail-section', () => {
|
||||
it('shows the contact to the plugin in its details', () => {
|
||||
offerSlots('p1', ['contact-detail-section']);
|
||||
render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
const frame = screen.getByTestId('plugin-frame-contact-detail-section');
|
||||
expect(framePropsOf(frame).contact).toMatchObject({
|
||||
id: '1', uid: 'uid-alice', addressBookId: 'ab1', firstName: 'Alice', lastName: 'Smith', emails: ['alice@example.com'],
|
||||
});
|
||||
// Below the contact's own fields.
|
||||
expect(screen.getByText('VIP customer').compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing without a plugin or without a contact', () => {
|
||||
const { unmount } = render(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
unmount();
|
||||
|
||||
offerSlots('p1', ['contact-detail-section']);
|
||||
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact-list-actions', () => {
|
||||
const props = {
|
||||
contacts: [alice, bob],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectRangeContacts: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
onEditContact: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
onAddContactToGroup: vi.fn(),
|
||||
};
|
||||
|
||||
it('puts plugin buttons in the selection toolbar and sends the selected ids', async () => {
|
||||
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
|
||||
render(<ContactList {...props} selectedContactIds={new Set(['1', '2'])} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Sync to CRM' });
|
||||
// Next to the app's own bulk buttons.
|
||||
expect(button.closest('div.flex-wrap')?.parentElement).toContainElement(screen.getByText('bulk.delete'));
|
||||
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'contact-list-actions', 'sync', { contactIds: ['1', '2'] });
|
||||
});
|
||||
|
||||
it('shows no plugin buttons without a selection or without a plugin', () => {
|
||||
act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
|
||||
const { unmount } = render(<ContactList {...props} selectedContactIds={new Set()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
|
||||
unmount();
|
||||
|
||||
resetActions();
|
||||
render(<ContactList {...props} selectedContactIds={new Set(['1'])} />);
|
||||
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -16,6 +16,8 @@ import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { contactHooks } from "@/lib/plugin-hooks";
|
||||
import { contactToView } from "@/lib/plugin-projection";
|
||||
|
||||
type MoreItem =
|
||||
| {
|
||||
@@ -123,6 +125,17 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
|
||||
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
|
||||
|
||||
// Tell plugins once per contact shown here, not on every edit of that card.
|
||||
const latestContact = useRef(contact);
|
||||
useEffect(() => { latestContact.current = contact; });
|
||||
const openedId = contact?.id;
|
||||
useEffect(() => {
|
||||
const opened = latestContact.current;
|
||||
if (!openedId || !opened) return;
|
||||
void contactHooks.onContactOpen.emit(contactToView(opened));
|
||||
}, [openedId]);
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
@@ -497,6 +510,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{pluginContactProps && (
|
||||
<PluginSlot name="contact-detail-section" className="w-full py-6" extraProps={pluginContactProps} />
|
||||
)}
|
||||
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{(contact.created || contact.updated) && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
@@ -268,6 +269,7 @@ export function ContactList({
|
||||
}, [sorted, locale, sortByLastName]);
|
||||
|
||||
const hasSelection = selectedContactIds.size > 0;
|
||||
const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
|
||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||
|
||||
return (
|
||||
@@ -521,6 +523,7 @@ export function ContactList({
|
||||
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.delete")}
|
||||
</Button>
|
||||
<PluginActions surface="contact-list-actions" context={pluginContext} />
|
||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -347,7 +347,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||
} else {
|
||||
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
await updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
}
|
||||
}
|
||||
const msg = contactIds.length === 1
|
||||
@@ -427,11 +427,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, contactId);
|
||||
} else {
|
||||
deleteLocalContact(contactId);
|
||||
}
|
||||
const deleted = supportsSync && client
|
||||
? await deleteContact(client, contactId)
|
||||
: await deleteLocalContact(contactId);
|
||||
// A plugin stopped it and has told the user why.
|
||||
if (!deleted) return;
|
||||
toast.success(t("toast.deleted"));
|
||||
if (selectedContactId === contactId) setView("list");
|
||||
} catch (error) {
|
||||
@@ -468,33 +468,27 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _uid; void _created; void _updated;
|
||||
const data: Partial<ContactCard> = JSON.parse(JSON.stringify(rest));
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
const created = supportsSync && client
|
||||
? await createContact(client, data)
|
||||
: await addLocalContact({
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
});
|
||||
if (created) toast.success(t("toast.created"));
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
const created = supportsSync && client
|
||||
? await createContact(client, data)
|
||||
: await addLocalContact({
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
});
|
||||
// A plugin stopped it: keep the form open with what was typed.
|
||||
if (!created) return;
|
||||
toast.success(t("toast.created"));
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setCreatePrefill(undefined);
|
||||
if (returnToEmail) {
|
||||
@@ -508,13 +502,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, selectedContact.id, data);
|
||||
toast.success(t("toast.updated"));
|
||||
} else {
|
||||
updateLocalContact(selectedContact.id, data);
|
||||
toast.success(t("toast.updated"));
|
||||
}
|
||||
const updated = supportsSync && client
|
||||
? await updateContact(client, selectedContact.id, data)
|
||||
: await updateLocalContact(selectedContact.id, data);
|
||||
if (!updated) return;
|
||||
toast.success(t("toast.updated"));
|
||||
setView("detail");
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
|
||||
@@ -140,6 +140,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -147,7 +155,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -151,6 +151,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -158,7 +166,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
|
||||
@@ -133,13 +133,21 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { intercept: async () => true },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -134,6 +134,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -141,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -132,6 +132,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -139,7 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -144,7 +152,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -150,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -136,6 +136,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -143,7 +151,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { clearAllHooks, identityHooks, templateHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
const TEMPLATE = vi.hoisted(() => ({
|
||||
id: 'tpl-1',
|
||||
name: 'Greeting',
|
||||
category: '',
|
||||
subject: 'Hello',
|
||||
body: 'Dear reader',
|
||||
isHTML: false,
|
||||
isFavorite: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}));
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
'data-testid': 'rich-text-editor',
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (!event.defaultPrevented) editorKeyDown();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
// The picker hands the composer a template, as choosing one does.
|
||||
vi.mock('@/components/templates/template-picker', () => ({
|
||||
TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record<string, string>) => void }) =>
|
||||
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
|
||||
}));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
sanitizeSignatureHtmlForDisplay: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
escapeHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/template-utils')>()),
|
||||
substitutePlaceholders: (s: string) => s,
|
||||
}));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DATA = {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', mayDelete: false };
|
||||
const WORK = { id: 'id-work', email: 'work@example.com', name: 'Work', mayDelete: true, htmlSignature: '<b>W</b>' };
|
||||
|
||||
describe('composer plugin hooks for identities and templates', () => {
|
||||
beforeEach(() => {
|
||||
useIdentityStore.setState({ identities: [ME, WORK] });
|
||||
});
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
it('tells plugins which From identity the user picked', () => {
|
||||
const selected = vi.fn();
|
||||
identityHooks.onIdentitySelect.register('test', selected);
|
||||
render(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('composer-from'), { target: { value: 'id-work' } });
|
||||
|
||||
expect(selected).toHaveBeenCalledTimes(1);
|
||||
expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-work', email: 'work@example.com', htmlSignature: '<b>W</b>' }));
|
||||
});
|
||||
|
||||
it('inserts the template as a plugin rendered it and reports it applied', async () => {
|
||||
const render_ = vi.fn((value: { subject: string; html: string; text: string }, ctx: { templateId: string }) => {
|
||||
expect(ctx).toEqual({ templateId: 'tpl-1' });
|
||||
return { ...value, subject: `[Plugin] ${value.subject}` };
|
||||
});
|
||||
const applied = vi.fn();
|
||||
templateHooks.onTemplateRender.register('test', render_);
|
||||
templateHooks.onTemplateApply.register('test', applied);
|
||||
render(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} />);
|
||||
|
||||
act(() => { fireEvent.keyDown(window, { key: 't' }); });
|
||||
fireEvent.click(await screen.findByTestId('pick-template'));
|
||||
|
||||
await waitFor(() => expect(applied).toHaveBeenCalledTimes(1));
|
||||
expect(render_).toHaveBeenCalledWith(
|
||||
{ subject: 'Hello', html: '<p>Dear reader</p>', text: 'Dear reader' },
|
||||
{ templateId: 'tpl-1' },
|
||||
);
|
||||
expect(applied).toHaveBeenCalledWith(expect.objectContaining({ id: 'tpl-1', name: 'Greeting' }));
|
||||
expect(screen.getByDisplayValue('[Plugin] Hello')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -139,6 +139,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -146,7 +154,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -15,6 +15,14 @@ vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
// callback the host passed through extraProps - exactly what a real plugin
|
||||
// does after it has uploaded a picked file to JMAP itself (api.jmap.uploadBlob).
|
||||
// Every other slot renders nothing, matching the rest of the test suite.
|
||||
// The composer only renders its plugin row when a plugin offers a slot.
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
|
||||
usePluginSlotOffers: (slot: string) => (slot === 'composer-attachment-source' ? [{ pluginId: 'p', order: 100 }] : []),
|
||||
}));
|
||||
const attachState = vi.hoisted(() => ({
|
||||
next: { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 } as Record<string, unknown>,
|
||||
allow: true,
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record<string, unknown> }) => {
|
||||
if (name !== 'composer-attachment-source') return null;
|
||||
@@ -26,8 +34,7 @@ vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
{
|
||||
type: 'button',
|
||||
'data-testid': 'fake-cloud-storage-plugin',
|
||||
onClick: () =>
|
||||
onAttach?.({ blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 }),
|
||||
onClick: () => onAttach?.(attachState.next as never),
|
||||
},
|
||||
'Attach from cloud storage',
|
||||
);
|
||||
@@ -155,6 +162,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -162,9 +177,10 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => attachState.allow },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -208,6 +224,29 @@ describe('composer-attachment-source plugin slot', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
vi.clearAllMocks();
|
||||
attachState.next = { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 };
|
||||
attachState.allow = true;
|
||||
});
|
||||
|
||||
it('ignores something from a plugin that is not an attachment', async () => {
|
||||
attachState.next = { blobId: 'not a blob id', name: 'evil.jpg', type: 'image/jpeg', size: 1 };
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText('evil.jpg')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the same attachment checks as a local file', async () => {
|
||||
// A plugin's onBeforeAttachmentUpload guard used to be skipped here.
|
||||
attachState.allow = false;
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText('vacation.jpg')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a plugin-supplied blob as an attachment chip when onAttach is called', async () => {
|
||||
@@ -231,7 +270,7 @@ describe('composer-attachment-source plugin slot', () => {
|
||||
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
|
||||
expect(screen.getByText('vacation.jpg')).toBeInTheDocument();
|
||||
expect(await screen.findByText('vacation.jpg')).toBeInTheDocument();
|
||||
|
||||
// Fake timers only from here on - the autosave debounce below needs
|
||||
// vi.advanceTimersByTimeAsync, but findBy*'s own internal polling (not
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { undoDepth } from '@tiptap/pm/history';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { buildSignatureBlock } from '../signature-block';
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
|
||||
import { composerHandle } from '@/lib/plugin-sandbox/composer-registry';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
// The real composer around the real editor, worked on by a plugin through
|
||||
// the handle it registers for api.composer: what the plugin reads as the
|
||||
// message, and that each change is one step in the editor's history that
|
||||
// leaves the signature, the quoted reply and inline images alone.
|
||||
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
|
||||
// ─── Composer dependency mocks (as in composer-close-guard.test.tsx) ──────────
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
recipientMentionsEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/email-sanitization')>()),
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizePluginBodyHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SIGNATURE = buildSignatureBlock('<p>Alice Example<br>ACME Ltd.</p>');
|
||||
const QUOTE = buildQuotedHtmlBlock('<p>Can you send the photo?</p>');
|
||||
const BODY =
|
||||
'<p>Hello Bob,</p><p>here it is <img src="cid:photo1" data-cid="photo1" alt="photo"></p><p></p>'
|
||||
+ SIGNATURE
|
||||
+ '<p>On Monday, Bob wrote:</p>'
|
||||
+ QUOTE;
|
||||
|
||||
const DRAFT = {
|
||||
to: 'Bob Builder <bob@example.com>',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Re: Photo',
|
||||
body: BODY,
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const PLUGIN = 'assistant';
|
||||
|
||||
/** The real composer; its id comes from a click on a plugin's toolbar button, as for a plugin. */
|
||||
async function renderComposer(body = BODY) {
|
||||
const clicks: Record<string, unknown>[] = [];
|
||||
setActionDispatcher(async (_plugin, _surface, _id, context) => { clicks.push(context); });
|
||||
setPluginActions(PLUGIN, 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Ctrl+Shift+Y' }]);
|
||||
render(<EmailComposer initialData={{ ...DRAFT, body }} mode="reply" onClose={vi.fn()} />);
|
||||
const dom = await waitFor(() => {
|
||||
const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
|
||||
expect(el?.editor).toBeTruthy();
|
||||
return el!;
|
||||
});
|
||||
const editor = dom.editor!;
|
||||
await waitFor(() => expect(editor.getHTML()).toContain(body.slice(3, 12)));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
await waitFor(() => expect(clicks).toHaveLength(1));
|
||||
const id = clicks[0].composerId as string;
|
||||
expect(typeof id).toBe('string');
|
||||
return { editor, id, handle: composerHandle(id)!, clicks };
|
||||
}
|
||||
|
||||
const html = (editor: Editor) => serializeEditorContent(editor);
|
||||
|
||||
describe('plugins editing the message being written', () => {
|
||||
const range = Range.prototype as unknown as Record<string, unknown>;
|
||||
const doc = document as unknown as Record<string, unknown>;
|
||||
beforeAll(() => {
|
||||
range.getClientRects = () => [];
|
||||
range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
|
||||
doc.elementFromPoint = () => null;
|
||||
});
|
||||
afterAll(() => {
|
||||
delete range.getClientRects;
|
||||
delete range.getBoundingClientRect;
|
||||
delete doc.elementFromPoint;
|
||||
});
|
||||
afterEach(() => {
|
||||
resetActions();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
it('reads only the user\'s own text: no signature, no quote header, no quote', async () => {
|
||||
const { handle } = await renderComposer();
|
||||
const body = handle.readBody();
|
||||
expect(body.text).toBe('Hello Bob,\nhere it is');
|
||||
expect(body.html).toContain('cid:photo1');
|
||||
expect(body.html).not.toContain('ACME');
|
||||
expect(body.html).not.toContain('Bob wrote');
|
||||
expect(body.html).not.toContain('Can you send');
|
||||
});
|
||||
|
||||
it('replaces the own text in place, at once, and leaves signature, quote and inline image', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
let ok = false;
|
||||
act(() => { ok = handle.replaceBody('<p>Hi Bob, attached.</p>', 'Hi Bob, attached.'); });
|
||||
expect(ok).toBe(true);
|
||||
// Applied to the editor at once, so a plugin reads its own change back.
|
||||
expect(handle.readBody().text).toBe('Hi Bob, attached.');
|
||||
|
||||
const after = html(editor);
|
||||
expect(after).not.toContain('Hello Bob');
|
||||
expect(after.indexOf('Hi Bob, attached.')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
expect(after).toContain('ACME Ltd.');
|
||||
expect(after).toContain('On Monday, Bob wrote:');
|
||||
expect(after).toContain('Can you send the photo?');
|
||||
expect(after).toContain('data-cid="photo1"');
|
||||
// The blank line between the text and the signature stays.
|
||||
expect(after.indexOf('data-cid="photo1"')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("leaves a reply's quote header (On ... wrote:) with the quote when there is no signature above it", async () => {
|
||||
const { editor, handle } = await renderComposer('<p>Thanks, that works.</p><p>On Monday, Bob wrote:</p>' + QUOTE);
|
||||
expect(handle.readBody().text).toBe('Thanks, that works.');
|
||||
act(() => { handle.replaceBody('<p>Thank you, that works for me.</p>', 'Thank you, that works for me.'); });
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Thank you, that works for me.');
|
||||
expect(after).toContain('On Monday, Bob wrote:');
|
||||
expect(after.indexOf('Thank you')).toBeLessThan(after.indexOf('On Monday'));
|
||||
});
|
||||
|
||||
it('is one step in the editor history: one Ctrl+Z brings the old text back', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
const depth = undoDepth(editor.state);
|
||||
act(() => { handle.replaceBody('<p>Hi Bob, attached.</p>', 'Hi Bob, attached.'); });
|
||||
expect(undoDepth(editor.state)).toBe(depth + 1);
|
||||
act(() => { editor.commands.undo(); });
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('replaces a selection in one step and keeps an image inside it', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
// "Hello Bob," up to and including the image.
|
||||
let imageEnd = 0;
|
||||
editor.state.doc.descendants((node, pos) => { if (node.type.name === 'image') imageEnd = pos + node.nodeSize; });
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: imageEnd }); });
|
||||
expect(handle.readSelection().text).toContain('Hello Bob,');
|
||||
|
||||
const depth = undoDepth(editor.state);
|
||||
act(() => { handle.replaceSelection('<p>Dear Bob,</p>', 'Dear Bob,', { below: false }); });
|
||||
expect(undoDepth(editor.state)).toBe(depth + 1);
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Dear Bob,');
|
||||
expect(after).not.toContain('Hello Bob');
|
||||
expect(after).toContain('data-cid="photo1"');
|
||||
expect(after).toContain('ACME Ltd.');
|
||||
|
||||
act(() => { editor.commands.undo(); });
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('refuses to replace a selection that holds the signature', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
const before = html(editor);
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size }); });
|
||||
let ok = true;
|
||||
act(() => { ok = handle.replaceSelection('<p>x</p>', 'x', { below: false }); });
|
||||
expect(ok).toBe(false);
|
||||
expect(html(editor)).toBe(before);
|
||||
});
|
||||
|
||||
it('adds below the selection without touching it', async () => {
|
||||
const { editor, handle } = await renderComposer();
|
||||
act(() => { editor.commands.setTextSelection({ from: 1, to: 6 }); });
|
||||
act(() => { handle.replaceSelection('<p>Suggested: Hi Bob</p>', 'Suggested: Hi Bob', { below: true }); });
|
||||
const after = html(editor);
|
||||
expect(after).toContain('Hello Bob,');
|
||||
expect(after.indexOf('Hello Bob,')).toBeLessThan(after.indexOf('Suggested: Hi Bob'));
|
||||
expect(after.indexOf('Suggested: Hi Bob')).toBeLessThan(after.indexOf('data-signature-block-node'));
|
||||
});
|
||||
|
||||
it('runs a plugin button\'s shortcut while typing in the composer', async () => {
|
||||
const { editor, clicks } = await renderComposer();
|
||||
fireEvent.keyDown(editor.view.dom, { key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
|
||||
await waitFor(() => expect(clicks).toHaveLength(2));
|
||||
expect(clicks[1].composerId).toBe(clicks[0].composerId);
|
||||
});
|
||||
|
||||
it('attaches a file the plugin uploaded, and forgets the composer when it closes', async () => {
|
||||
const { handle, id } = await renderComposer();
|
||||
let ok = false;
|
||||
await act(async () => { ok = await handle.attach({ blobId: 'B1', name: 'report.pdf', type: 'application/pdf', size: 2048 }); });
|
||||
expect(ok).toBe(true);
|
||||
expect(await screen.findByText('report.pdf')).toBeTruthy();
|
||||
cleanup();
|
||||
expect(composerHandle(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React, { useState } from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
import { resetComposerSessions } from '@/lib/plugin-composer-hooks';
|
||||
|
||||
// mail-compose hooks fired by the composer itself: onComposerOpen and
|
||||
// onComposerClose once per session (not per remount), onDraftAutoSave after a
|
||||
// save the server took, and the onBeforeCompose prefill the host hands in.
|
||||
// The hook module is the real one here.
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
'data-testid': 'rich-text-editor',
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (!event.defaultPrevented) editorKeyDown();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
const updateSetting = vi.fn();
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: (...args: unknown[]) => updateSetting(...args),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const flushCloseTimer = () => act(() => new Promise((r) => setTimeout(r, 10)));
|
||||
|
||||
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
|
||||
|
||||
function Host({ onSend, sessionKey = 's1' }: { onSend?: () => Promise<void>; sessionKey?: string }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (!open) return <p>closed</p>;
|
||||
return (
|
||||
<EmailComposer
|
||||
sessionKey={sessionKey}
|
||||
requestCloseRef={requestCloseRef}
|
||||
initialData={DRAFT}
|
||||
onClose={() => setOpen(false)}
|
||||
onSend={async () => {
|
||||
setOpen(false);
|
||||
await onSend?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
resetComposerSessions();
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
describe('onComposerOpen / onComposerClose', () => {
|
||||
it('reports the open with the mode and the original message', () => {
|
||||
const opened = vi.fn();
|
||||
emailHooks.onComposerOpen.register('test', opened);
|
||||
render(
|
||||
<EmailComposer
|
||||
mode="reply"
|
||||
sessionKey="r1"
|
||||
sourceEmailId="orig-1"
|
||||
replyTo={{ from: [{ email: 'alice@example.com' }], subject: 'Plans' }}
|
||||
/>,
|
||||
);
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
expect(opened).toHaveBeenCalledWith({ mode: 'reply', inReplyToId: 'orig-1', originalSubject: 'Plans' });
|
||||
});
|
||||
|
||||
it('treats a remount with the same session as the same composer', async () => {
|
||||
const opened = vi.fn();
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerOpen.register('test', opened);
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
|
||||
const { rerender } = render(<div key="a"><EmailComposer sessionKey="same" initialData={DRAFT} /></div>);
|
||||
// Moved to another parent: React mounts a new composer.
|
||||
rerender(<section key="b"><EmailComposer sessionKey="same" initialData={DRAFT} /></section>);
|
||||
await flushCloseTimer();
|
||||
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a close without a send', async () => {
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
render(<Host />);
|
||||
|
||||
// Nothing typed: closes without asking.
|
||||
act(() => requestCloseRef.current!());
|
||||
await screen.findByText('closed');
|
||||
await flushCloseTimer();
|
||||
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith({ draftId: null, sent: false });
|
||||
});
|
||||
|
||||
it('reports a close after a send as sent, even though the host closes mid-send', async () => {
|
||||
const closed = vi.fn();
|
||||
emailHooks.onComposerClose.register('test', closed);
|
||||
render(<Host onSend={() => new Promise((r) => setTimeout(r, 20))} />);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await screen.findByText('closed');
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith(expect.objectContaining({ sent: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDraftAutoSave', () => {
|
||||
it('fires after the server took the draft, not when the save fails', async () => {
|
||||
const saved = vi.fn();
|
||||
emailHooks.onDraftAutoSave.register('test', saved);
|
||||
const createDraft = vi.fn().mockRejectedValueOnce(new Error('refused')).mockResolvedValue('draft-7');
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
await act(async () => {});
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch on Friday?' } });
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
expect(saved).toHaveBeenCalledWith({ draftId: 'draft-7', subject: 'Lunch on Friday?' });
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('onBeforeCompose prefill', () => {
|
||||
it('fills the fields of a new message', () => {
|
||||
render(<EmailComposer prefill={{ to: ['carol@example.com'], subject: 'From a plugin' }} />);
|
||||
expect(screen.getByTestId('composer-subject')).toHaveValue('From a plugin');
|
||||
expect(screen.getByText('carol@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('goes over what the host handed in', () => {
|
||||
render(<EmailComposer initialData={DRAFT} prefill={{ subject: 'Changed' }} />);
|
||||
expect(screen.getByTestId('composer-subject')).toHaveValue('Changed');
|
||||
// Fields the plugin left alone stay.
|
||||
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onBeforeDraftAutoSave', () => {
|
||||
function withClient(createDraft: ReturnType<typeof vi.fn>) {
|
||||
useAuthStore.setState({
|
||||
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
|
||||
});
|
||||
}
|
||||
|
||||
it('lets a plugin skip a save while typing', async () => {
|
||||
const before = vi.fn().mockReturnValue(false);
|
||||
emailHooks.onBeforeDraftAutoSave.register('test', before);
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-1');
|
||||
withClient(createDraft);
|
||||
try {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
await waitFor(() => expect(before).toHaveBeenCalled(), { timeout: 4000 });
|
||||
await act(async () => {});
|
||||
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('saves the draft a plugin returns', async () => {
|
||||
emailHooks.onBeforeDraftAutoSave.register('test', (draft: { subject: string }) => ({ ...draft, subject: `[x] ${draft.subject}` }));
|
||||
const createDraft = vi.fn().mockResolvedValue('draft-1');
|
||||
withClient(createDraft);
|
||||
try {
|
||||
render(<EmailComposer initialData={DRAFT} />);
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
|
||||
expect(createDraft.mock.calls[0][1]).toBe('[x] Lunch?');
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -141,6 +141,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -148,7 +156,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: hoisted.takeOver },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { checkActions, resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
|
||||
|
||||
// Plugin surfaces of the composer: frames above and below the message text
|
||||
// (fed the draft, at most every 2 seconds), and entries in the Send and
|
||||
// Attach menus.
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyHasSignature: () => false,
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const draftOf = (el: HTMLElement) => slotProps(el).draft as { subject: string; to: string[]; htmlBody: string; identityId: string };
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('composer-above-body and composer-below-body', () => {
|
||||
it('put the frames around the message text with the draft', () => {
|
||||
offerSlots('p1', ['composer-above-body', 'composer-below-body']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const above = screen.getByTestId('slot-composer-above-body');
|
||||
const below = screen.getByTestId('slot-composer-below-body');
|
||||
const editor = screen.getByTestId('rich-text-editor');
|
||||
expect(follows(screen.getByTestId('composer-subject'), above)).toBe(true);
|
||||
expect(follows(above, editor)).toBe(true);
|
||||
expect(follows(editor, below)).toBe(true);
|
||||
expect(draftOf(above)).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'], htmlBody: '<p>Hello there</p>', identityId: 'id-me' });
|
||||
expect(draftOf(below).subject).toBe('Lunch');
|
||||
});
|
||||
|
||||
it('send a changed draft only after 2 seconds without changes', () => {
|
||||
offerSlots('p1', ['composer-above-body']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
vi.useFakeTimers();
|
||||
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch at 12?' } });
|
||||
act(() => { vi.advanceTimersByTime(1900); });
|
||||
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch');
|
||||
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch at 12?');
|
||||
});
|
||||
|
||||
it('let a plugin read the selection and write over it', () => {
|
||||
useSettingsStore.setState({ plainTextMode: true });
|
||||
try {
|
||||
offerSlots('p1', ['composer-above-body']);
|
||||
const { container } = render(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
const props = lastSlotProps('composer-above-body') as { insertText: (t: string) => boolean; getSelectedText: () => string };
|
||||
|
||||
textarea.setSelectionRange(0, 5);
|
||||
expect(props.getSelectedText()).toBe('Hello');
|
||||
act(() => { expect(props.insertText('Hi')).toBe(true); });
|
||||
|
||||
expect(textarea.value).toBe('Hi there');
|
||||
expect(props.insertText('x'.repeat(100_001))).toBe(false);
|
||||
} finally {
|
||||
useSettingsStore.setState({ plainTextMode: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('let a cloud picker put share links into the text', () => {
|
||||
offerSlots('p1', ['composer-attachment-source']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const props = lastSlotProps('composer-attachment-source');
|
||||
expect(typeof props.onAttach).toBe('function');
|
||||
expect(typeof props.insertText).toBe('function');
|
||||
});
|
||||
|
||||
it('put the attachment source in the plugin button row, where the catalogue says it is', () => {
|
||||
offerSlots('p1', ['composer-attachment-source']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-composer-attachment-source');
|
||||
expect(frame.closest('[data-composer-plugin-row]')).not.toBeNull();
|
||||
expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin row/);
|
||||
});
|
||||
|
||||
it('draw nothing without a plugin', () => {
|
||||
const { container } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-slot="composer-below-body"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-send-menu', () => {
|
||||
it('turns Send into a split button for plugin entries and sends the draft on click', async () => {
|
||||
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
// The phone and the desktop Send button are both in the DOM.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'more_actions' })[0]);
|
||||
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getAllByRole('menuitem', { name: 'Send with Later' })[0]); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [pluginId, surface, actionId, context] = dispatch.mock.calls[0];
|
||||
expect([pluginId, surface, actionId]).toEqual(['p', 'composer-send-menu', 'later']);
|
||||
expect((context as { draft: { subject: string } }).draft).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'] });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Send with Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the entries under Schedule send when the account has it', () => {
|
||||
useAuthStore.setState({
|
||||
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
|
||||
});
|
||||
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
|
||||
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
|
||||
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
|
||||
expect(follows(schedule, plugin)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a plain Send button without plugin entries or scheduled send', () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'schedule_send' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-options-menu and composer-toolbar-actions', () => {
|
||||
it('lists plugin options in Options, ticked ones as check items counted in the badge', async () => {
|
||||
act(() => setPluginActions('smime', 'S/MIME', 'composer-options-menu', [
|
||||
{ id: 'sign', label: 'Sign', checked: true, description: 'With your certificate' },
|
||||
{ id: 'encrypt', label: 'Encrypt', checked: false },
|
||||
{ id: 'zones', label: 'Insert time in other zones' },
|
||||
]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const options = screen.getByTestId('composer-options-menu');
|
||||
expect(options).toHaveTextContent('1');
|
||||
fireEvent.click(options);
|
||||
const sign = screen.getByRole('menuitemcheckbox', { name: /Sign/ });
|
||||
expect(sign).toHaveAttribute('aria-checked', 'true');
|
||||
expect(sign).toHaveTextContent('With your certificate');
|
||||
expect(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'encrypt', { composerId: expect.any(String) });
|
||||
// A check item leaves the menu open, so several options can be set.
|
||||
expect(screen.getByRole('menuitemcheckbox', { name: /Sign/ })).toBeInTheDocument();
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Insert time in other zones' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'zones', { composerId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('draws plugin buttons in the bottom bar like Attach, with the shortcut in the tooltip', async () => {
|
||||
act(() => setPluginActions('ai', 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Alt+A' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
|
||||
expect(button.className).toContain('h-9');
|
||||
// In the bar only when it is wide enough next to the app's own buttons...
|
||||
expect(button.className).toMatch(/(^|\s)hidden(\s|$)/);
|
||||
expect(button.className).toContain('@[44rem]/composer-bar:inline-flex');
|
||||
let bar: HTMLElement | null = button;
|
||||
while (bar && !bar.classList.contains('@container/composer-bar')) bar = bar.parentElement;
|
||||
expect(bar).not.toBeNull();
|
||||
// Not in the plugin frame row above the bar.
|
||||
expect(button.closest('[data-composer-plugin-row]')).toBeNull();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
|
||||
|
||||
// ...and otherwise in the Options menu, which is shown only while narrow.
|
||||
dispatch.mockClear();
|
||||
fireEvent.click(screen.getByTestId('composer-options-menu'));
|
||||
const entry = screen.getByRole('menuitem', { name: 'Assistant' });
|
||||
expect(entry.closest('[data-narrow-only]')!.className).toContain('@[44rem]/composer-bar:hidden');
|
||||
await act(async () => { fireEvent.click(entry); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('refuses odd values for checked and description', () => {
|
||||
expect(() => setPluginActions('p', 'P', 'composer-options-menu', checkActions([{ id: 'a', label: 'A', checked: 'yes' }]))).toThrow(/checked must be true or false/);
|
||||
expect(() => checkActions([{ id: 'a', label: 'A', description: 'x'.repeat(121) }])).toThrow(/description/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-attach-menu', () => {
|
||||
it('turns Attach into a menu for plugin entries', async () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('composer-attach-menu'));
|
||||
expect(screen.getByRole('menuitem', { name: 'attach_from_device' })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'From the cloud' })); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', { composerId: expect.any(String) });
|
||||
expect(screen.queryByRole('menuitem', { name: 'From the cloud' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the plain Attach button without plugin entries', () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-sidebar and composer-sidebar-right', () => {
|
||||
function stubWidth(width: number) {
|
||||
class FixedWidthObserver {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe(el: Element) {
|
||||
if ((el as HTMLElement).dataset.testid !== 'email-composer') return;
|
||||
this.cb([{ target: el, contentRect: { width, height: 700 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FixedWidthObserver);
|
||||
}
|
||||
const panel = (side: 'left' | 'right') => document.querySelector<HTMLElement>(`[data-composer-sidebar="${side}"]`)!;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
try {
|
||||
localStorage.removeItem('composerPluginSidebarCollapsed:left');
|
||||
localStorage.removeItem('composerPluginSidebarCollapsed:right');
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('give both panels a fixed width beside a wide composer', () => {
|
||||
stubWidth(1400);
|
||||
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
for (const side of ['left', 'right'] as const) {
|
||||
expect(panel(side).dataset.composerSidebarMode).toBe('docked');
|
||||
expect(panel(side).style.width).toBe('260px');
|
||||
}
|
||||
expect(panel('left')).toContainElement(screen.getByTestId('slot-composer-sidebar'));
|
||||
expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right'));
|
||||
});
|
||||
|
||||
it('fold the right panel, then both, when the composer would get too narrow', () => {
|
||||
stubWidth(900);
|
||||
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
|
||||
const { unmount } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(panel('left').dataset.composerSidebarMode).toBe('docked');
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
|
||||
expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
|
||||
unmount();
|
||||
|
||||
// The reading pane of a 1280 px window: an open panel would leave the
|
||||
// composer too narrow for its bottom bar.
|
||||
stubWidth(792);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(panel('left').dataset.composerSidebarMode).toBe('folded');
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
|
||||
});
|
||||
|
||||
it('open a folded panel over the composer when there is no room beside it', () => {
|
||||
stubWidth(600);
|
||||
offerSlots('p1', ['composer-sidebar-right']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
|
||||
expect(panel('right').className).toContain('absolute');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -145,6 +145,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -152,7 +160,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -150,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -133,13 +133,21 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -144,6 +144,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -151,7 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
|
||||
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
|
||||
// Plugin surfaces of the message list: frames above the list and in the empty
|
||||
// list, buttons on each row (hover, and the row menu), and buttons in the
|
||||
// toolbar shown while messages are selected.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null }));
|
||||
vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
|
||||
vi.mock('@/stores/auth-store', async () => {
|
||||
const { create } = await import('zustand');
|
||||
return {
|
||||
useAuthStore: create(() => ({
|
||||
client: {}, identities: [], username: null, activeAccountId: 'acct-1',
|
||||
getAllConnectedClients: () => new Map(), getClientForAccount: () => null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { getAccountById: () => undefined, accounts: [] };
|
||||
return {
|
||||
useAccountStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const { EmailList } = await import('../email-list');
|
||||
const { MvListToolbar } = await import('../mv-list-toolbar');
|
||||
const { ThreadListItem } = await import('../thread-list-item');
|
||||
const { EmailContextMenu } = await import('../email-context-menu');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useUIStore } = await import('@/stores/ui-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { groupEmailsByThread } = await import('@/lib/thread-utils');
|
||||
|
||||
const mail = (id: string): Email => ({
|
||||
id,
|
||||
threadId: `thread-${id}`,
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2026-09-30T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: `Mail ${id}`,
|
||||
hasAttachment: false,
|
||||
});
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const initialEmailState = useEmailStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useEmailStore.setState(initialEmailState, true);
|
||||
useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set<string>() });
|
||||
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
|
||||
useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('message-list-header', () => {
|
||||
it('shows the plugin frame above the list with the folder', () => {
|
||||
offerSlots('p1', ['message-list-header']);
|
||||
render(<EmailList emails={[mail('a')]} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-message-list-header');
|
||||
expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' });
|
||||
// Above the scrolling list, not inside it.
|
||||
expect(frame.closest('[data-tour="email-list"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
const { container } = render(<EmailList emails={[mail('a')]} />);
|
||||
expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-list-empty', () => {
|
||||
it('shows the frame under the "no messages" text of an empty list', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
render(<EmailList emails={[]} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-message-list-empty');
|
||||
expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false });
|
||||
const text = screen.getByText('no_emails_description');
|
||||
expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says when the empty list is a search', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
useEmailStore.setState({ searchQuery: 'invoice' });
|
||||
render(<EmailList emails={[]} />);
|
||||
|
||||
expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true);
|
||||
});
|
||||
|
||||
it('is not there when the list has messages, or without a plugin', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
const { unmount } = render(<EmailList emails={[mail('a')]} />);
|
||||
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
|
||||
unmount();
|
||||
clearOffers();
|
||||
render(<EmailList emails={[]} />);
|
||||
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-bulk-actions', () => {
|
||||
it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => {
|
||||
const emails = [mail('a'), mail('b'), mail('c')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<EmailList emails={emails} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Make tasks' });
|
||||
// In the toolbar that holds the app's own batch buttons.
|
||||
const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!;
|
||||
expect(toolbar).toContainElement(button);
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] });
|
||||
});
|
||||
|
||||
it('has no plugin buttons without a selection or without a plugin', () => {
|
||||
const emails = [mail('a')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) });
|
||||
const { unmount } = render(<EmailList emails={emails} />);
|
||||
expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull();
|
||||
unmount();
|
||||
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>() });
|
||||
render(<EmailList emails={emails} />);
|
||||
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
|
||||
});
|
||||
|
||||
it('puts them behind one "more" button when the list is narrow', async () => {
|
||||
// The list next to an open folder sidebar, or on a phone.
|
||||
class NarrowObserver {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe(el: Element) {
|
||||
this.cb([{ target: el, contentRect: { width: 384, height: 48 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NarrowObserver);
|
||||
try {
|
||||
const emails = [mail('a'), mail('b')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'b']) });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [
|
||||
{ id: 'make', label: 'Make tasks' },
|
||||
{ id: 'more', label: 'Remind me' },
|
||||
]));
|
||||
render(<EmailList emails={emails} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
|
||||
// "Clear selection" keeps its name for screen readers but drops the text.
|
||||
const clear = screen.getByRole('button', { name: 'batch_actions.clear_selection' });
|
||||
expect(clear).not.toHaveTextContent('batch_actions.clear_selection');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const item = await screen.findByRole('menuitem', { name: 'Make tasks' });
|
||||
expect(screen.getByRole('menuitem', { name: 'Remind me' })).toBeInTheDocument();
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'b'] });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('adds them to the Mountain View toolbar too', async () => {
|
||||
const emails = [mail('a'), mail('b')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] });
|
||||
});
|
||||
|
||||
it('leaves the Mountain View toolbar alone without a selection', () => {
|
||||
useEmailStore.setState({ emails: [mail('a')] });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-row-actions', () => {
|
||||
const row = (email: Email) => {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return <ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />;
|
||||
};
|
||||
|
||||
it('adds the plugin buttons after the row\'s own hover buttons', async () => {
|
||||
act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }]));
|
||||
const onSelect = vi.fn();
|
||||
const [thread] = groupEmailsByThread([mail('a')]);
|
||||
render(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={onSelect} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Later' });
|
||||
const star = screen.getByTestId('hover-action-star');
|
||||
expect(star.parentElement).toContainElement(button);
|
||||
expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
|
||||
// The click is the plugin's, not a row click that opens the message.
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows them even when the user turned off every quick action', () => {
|
||||
useSettingsStore.setState({ hoverActions: [] } as never);
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
render(row(mail('a')));
|
||||
expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }]));
|
||||
render(row(mail('a')));
|
||||
expect(screen.queryByRole('button', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
render(row(mail('a')));
|
||||
expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull();
|
||||
});
|
||||
|
||||
const menu = (props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) => render(
|
||||
<EmailContextMenu
|
||||
email={mail('a')}
|
||||
position={{ x: 0, y: 0 }}
|
||||
isOpen
|
||||
onClose={props.onClose ?? (() => {})}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('puts them in the row menu, which is what a long press opens on phones', async () => {
|
||||
act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
const onClose = vi.fn();
|
||||
menu({ onClose });
|
||||
|
||||
const item = screen.getByRole('menuitem', { name: 'Later' });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => {
|
||||
const { unmount } = menu();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
unmount();
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
menu({ isMultiSelect: true, selectedCount: 3 });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the menu entries to the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }]));
|
||||
menu();
|
||||
expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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 { refreshRowBadges, resetRowBadgeRefresh } from '@/lib/plugin-sandbox/row-badges';
|
||||
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();
|
||||
resetRowBadgeRefresh();
|
||||
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('asks the plugin again when it requests a refresh, at most once a second', async () => {
|
||||
let label = 'Open';
|
||||
const handler = vi.fn(async () => [{ key: 'task', label }]);
|
||||
renderHooks.onEmailListItemRender.register('test', handler);
|
||||
render(row([makeEmail()]));
|
||||
await screen.findByText('Open');
|
||||
|
||||
label = 'Done';
|
||||
act(() => { expect(refreshRowBadges(10_000)).toBe(true); });
|
||||
await screen.findByText('Done');
|
||||
expect(refreshRowBadges(10_500)).toBe(false);
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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,427 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
|
||||
// Plugin surfaces of the open message: frames above the subject, under the
|
||||
// sender, between new text and quote, above a conversation; buttons in the
|
||||
// reader toolbar (More panel on phones); entries in an attachment's menu.
|
||||
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
|
||||
useMediaQuery: () => false,
|
||||
useIsMobile: () => device.isMobile,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
const { ReaderAboveQuoteSlot, QUOTE_ANCHOR_ATTR } = await import('../reader-plugin-surfaces');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
function mail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
...overrides,
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
function textMail(text: string, overrides: Partial<Email> = {}): Email {
|
||||
return mail({
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { '1': { value: text, isTruncated: false } },
|
||||
...overrides,
|
||||
} as Partial<Email>);
|
||||
}
|
||||
|
||||
const REPLY = 'Sounds good, see you then.\n\nOn Monday Bob wrote:\n> Shall we meet at ten?\n> Bob';
|
||||
|
||||
function viewer(email: Email, props: Partial<React.ComponentProps<typeof EmailViewer>> = {}) {
|
||||
return render(
|
||||
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...props} />,
|
||||
);
|
||||
}
|
||||
|
||||
const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
device.isMobile = false;
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('email-detail-sidebar', () => {
|
||||
// Reports a fixed width for whatever it observes, like a 584 px reading pane
|
||||
// (three panes on a laptop).
|
||||
function stubWidth(width: number) {
|
||||
class FixedWidthObserver {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe(el: Element) {
|
||||
this.cb([{ target: el, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FixedWidthObserver);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
try { localStorage.removeItem('emailDetailSidebarCollapsed'); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('starts collapsed when the message opens into a pane too narrow for it', () => {
|
||||
stubWidth(584);
|
||||
offerSlots('p1', ['email-detail-sidebar']);
|
||||
// The reader mounts empty first, then a message opens.
|
||||
const { rerender } = render(
|
||||
<EmailViewer email={null} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />,
|
||||
);
|
||||
rerender(<EmailViewer email={textMail('Hi')} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
|
||||
stubWidth(584);
|
||||
offerSlots('p1', ['email-detail-sidebar']);
|
||||
viewer(textMail('Hi'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
|
||||
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
|
||||
expect(panel.className).toContain('absolute');
|
||||
});
|
||||
|
||||
it('docks next to the message when there is room', () => {
|
||||
stubWidth(1000);
|
||||
offerSlots('p1', ['email-detail-sidebar']);
|
||||
viewer(textMail('Hi'));
|
||||
|
||||
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
|
||||
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('docked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-header and reader-below-subject', () => {
|
||||
it('put the frames above the subject and under the sender, with the message', () => {
|
||||
offerSlots('p1', ['reader-header', 'reader-below-subject']);
|
||||
const { container } = viewer(textMail('Hi'));
|
||||
|
||||
const header = screen.getByTestId('slot-reader-header');
|
||||
const below = screen.getByTestId('slot-reader-below-subject');
|
||||
const subject = screen.getByRole('heading', { name: 'Hello there' });
|
||||
const body = container.querySelector('.email-content-wrapper')!;
|
||||
expect(follows(header, subject)).toBe(true);
|
||||
expect(follows(subject, below)).toBe(true);
|
||||
expect(follows(below, body)).toBe(true);
|
||||
expect((slotProps(header).email as { id: string }).id).toBe('e1');
|
||||
expect((slotProps(below).email as { subject: string }).subject).toBe('Hello there');
|
||||
});
|
||||
|
||||
it('draw nothing without a plugin', () => {
|
||||
const { container } = viewer(textMail('Hi'));
|
||||
expect(container.querySelector('[data-plugin-slot="reader-header"]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-slot="reader-below-subject"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('email-footer', () => {
|
||||
it('hands the frame the id of the open message', () => {
|
||||
offerSlots('p1', ['email-footer']);
|
||||
viewer(textMail('Hi'));
|
||||
expect(slotProps(screen.getByTestId('slot-email-footer'))).toEqual({ emailId: 'e1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-above-quote', () => {
|
||||
it('sits between the new text of a plain-text reply and its folded quote', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const { container } = viewer(textMail(REPLY));
|
||||
|
||||
const frame = screen.getByTestId('slot-reader-above-quote');
|
||||
const quote = container.querySelector('.email-content-text details')!;
|
||||
expect(quote).not.toBeNull();
|
||||
expect(follows(frame, quote)).toBe(true);
|
||||
expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true);
|
||||
expect((slotProps(frame).email as { id: string }).id).toBe('e1');
|
||||
// The frame renders inside the anchor, so the anchor must grow with it
|
||||
// instead of letting the frame spill over the quote toggle and the footer.
|
||||
const anchor = container.querySelector<HTMLElement>(`[${QUOTE_ANCHOR_ATTR}]`)!;
|
||||
expect(anchor.contains(frame)).toBe(true);
|
||||
expect(anchor.style.height).toBe('');
|
||||
});
|
||||
|
||||
it('is not mounted for a message without a quote, or without a plugin', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const { unmount } = viewer(textMail('Just some text.'));
|
||||
expect(screen.queryByTestId('slot-reader-above-quote')).toBeNull();
|
||||
unmount();
|
||||
clearOffers();
|
||||
const { container } = viewer(textMail(REPLY));
|
||||
expect(container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
|
||||
});
|
||||
|
||||
it('lays the frame over an HTML body and makes room for it before the quote toggle', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const iframe = document.createElement('iframe');
|
||||
host.appendChild(iframe);
|
||||
const doc = iframe.contentDocument!;
|
||||
doc.body.innerHTML = '<p>New text</p><button data-quote-toggle>...</button><blockquote type="cite">Old</blockquote>';
|
||||
|
||||
const { unmount } = render(
|
||||
<ReaderAboveQuoteSlot root={doc.body} frame={iframe} extraProps={{ email: { id: 'e1' } }} />,
|
||||
{ container: host.appendChild(document.createElement('div')) },
|
||||
);
|
||||
|
||||
const anchor = doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!;
|
||||
expect(anchor.nextElementSibling).toBe(doc.querySelector('[data-quote-toggle]'));
|
||||
const overlay = document.querySelector('[data-plugin-quote-overlay]')!;
|
||||
expect(within(overlay as HTMLElement).getByTestId('slot-reader-above-quote')).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('shows in a conversation message on phones too', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const email = textMail(REPLY, { keywords: {} } as Partial<Email>);
|
||||
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId('slot-reader-above-quote')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('thread-summary', () => {
|
||||
const conversation = [
|
||||
textMail('First', { id: 'm1', receivedAt: '2026-01-01T00:00:00Z' } as Partial<Email>),
|
||||
textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
|
||||
];
|
||||
|
||||
it('sits above the messages of the phone conversation view', () => {
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-thread-summary');
|
||||
expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] });
|
||||
expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('sits above a Mountain View conversation', () => {
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 });
|
||||
viewer(conversation[1], { mvConversation: conversation });
|
||||
|
||||
expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] });
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin, and nothing for a single message', () => {
|
||||
const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
|
||||
const { unmount } = render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
|
||||
expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
|
||||
unmount();
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
viewer(conversation[1]);
|
||||
expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader surfaces in the phone conversation view', () => {
|
||||
const conversation = [
|
||||
textMail('Second', { id: 'm2', subject: 'Re: Plan', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
|
||||
textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial<Email>),
|
||||
];
|
||||
const thread = { threadKey: 't1', latestEmail: conversation[0], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
|
||||
|
||||
it('mounts the single reader\'s frames on the unfolded message only', () => {
|
||||
offerSlots('p1', ['reader-header', 'reader-below-subject', 'email-banner', 'email-footer']);
|
||||
render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
|
||||
|
||||
// The latest message is unfolded, the older one (read) is folded.
|
||||
for (const slot of ['reader-header', 'reader-below-subject', 'email-banner'] as const) {
|
||||
const frames = screen.getAllByTestId(`slot-${slot}`);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect((slotProps(frames[0]).email as { id: string }).id).toBe('m2');
|
||||
}
|
||||
const footer = screen.getAllByTestId('slot-email-footer');
|
||||
expect(footer).toHaveLength(1);
|
||||
expect(slotProps(footer[0])).toEqual({ emailId: 'm2' });
|
||||
// Header on top, the body between banner and footer.
|
||||
expect(follows(screen.getByTestId('slot-reader-header'), screen.getByTestId('slot-reader-below-subject'))).toBe(true);
|
||||
expect(follows(screen.getByTestId('slot-email-banner'), screen.getByText('Second'))).toBe(true);
|
||||
expect(follows(screen.getByText('Second'), footer[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('puts plugin reader actions behind a more button on the unfolded message', async () => {
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['inbox'] }]));
|
||||
const { unmount } = render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} mailboxRole="inbox" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const item = await screen.findByRole('menuitem', { name: 'Translate' });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'm2' });
|
||||
unmount();
|
||||
|
||||
// Kept to the folder roles the plugin asked for.
|
||||
render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} mailboxRole="junk" />);
|
||||
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-actions', () => {
|
||||
it('adds the plugin buttons to the reader toolbar', async () => {
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
viewer(textMail('Hola'));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Translate' });
|
||||
expect(button.closest('[data-plugin-actions="reader-actions"]')).not.toBeNull();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('keeps to the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['junk'] }]));
|
||||
viewer(textMail('Hola'));
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('puts them in the More panel on phones', async () => {
|
||||
device.isMobile = true;
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
viewer(textMail('Hola'));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
|
||||
const item = screen.getByRole('menuitem', { name: 'Translate', hidden: true });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
const { container } = viewer(textMail('Hola'));
|
||||
expect(container.querySelector('[data-plugin-actions="reader-actions"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment-actions', () => {
|
||||
const withAttachment = () => textMail('See attached', {
|
||||
hasAttachment: true,
|
||||
attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }],
|
||||
} as Partial<Email>);
|
||||
|
||||
it('puts the frames next to the chip, not in its hover overlay', () => {
|
||||
offerSlots('p1', ['attachment-actions']);
|
||||
viewer(withAttachment());
|
||||
|
||||
// Desktop chip and phone chip are both in the DOM.
|
||||
const frames = screen.getAllByTestId('slot-attachment-actions');
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
for (const frame of frames) {
|
||||
// Nothing between the frame and the page may hide it until hover.
|
||||
for (let el: HTMLElement | null = frame; el; el = el.parentElement) {
|
||||
expect(el.className).not.toMatch(/\bopacity-0\b/);
|
||||
}
|
||||
expect(frame.closest('[data-attachment-name]')).toBeNull();
|
||||
expect(slotProps(frame)).toEqual({
|
||||
attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the invisible hover overlay from taking taps', () => {
|
||||
viewer(withAttachment());
|
||||
const overlays = [...document.querySelectorAll<HTMLElement>('[data-attachment-name="invoice.pdf"] div')]
|
||||
.filter((d) => /\bopacity-0\b/.test(d.className));
|
||||
expect(overlays.length).toBeGreaterThan(0);
|
||||
for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/);
|
||||
});
|
||||
|
||||
it("shows the overlay's buttons to keyboard users who tab into them, not only on hover", () => {
|
||||
viewer(withAttachment());
|
||||
const overlays = [...document.querySelectorAll<HTMLElement>('[data-attachment-name="invoice.pdf"] div')]
|
||||
.filter((d) => /\bopacity-0\b/.test(d.className));
|
||||
expect(overlays.length).toBeGreaterThan(0);
|
||||
for (const overlay of overlays) {
|
||||
expect(overlay.className).toMatch(/\bgroup-focus-within:opacity-100\b/);
|
||||
expect(overlay.className).toMatch(/\bgroup-focus-within:pointer-events-auto\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment-menu', () => {
|
||||
const withAttachments = () => textMail('See attached', {
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' },
|
||||
{ partId: '3', blobId: 'blob-png', name: 'photo.png', type: 'image/png', size: 99, disposition: 'attachment' },
|
||||
],
|
||||
} as Partial<Email>);
|
||||
|
||||
it('opens the plugin entries from a menu button on the attachment and sends the attachment', async () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud', mimeTypes: ['application/pdf'] }]));
|
||||
viewer(withAttachments());
|
||||
|
||||
// Only the PDF gets the menu (desktop chip and phone chip are both in the DOM).
|
||||
const buttons = document.querySelectorAll('[data-attachment-plugin-menu]');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
for (const b of buttons) {
|
||||
expect(b.closest('[data-attachment-name]')?.getAttribute('data-attachment-name')).toBe('invoice.pdf');
|
||||
}
|
||||
fireEvent.click(buttons[0]);
|
||||
const item = await screen.findByRole('menuitem', { name: 'Save to cloud' });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'attachment-menu', 'save', {
|
||||
emailId: 'e1', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the menu to attachments in a conversation message', () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud' }]));
|
||||
const email = withAttachments();
|
||||
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
|
||||
|
||||
expect(document.querySelectorAll('[data-attachment-plugin-menu]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('draws no menu button without a plugin entry', () => {
|
||||
viewer(withAttachments());
|
||||
expect(document.querySelector('[data-attachment-plugin-menu]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -130,6 +130,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -129,6 +129,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -158,6 +158,14 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// With a plugin panel open at 280 px next to a 300 px message (three panes
|
||||
// on a laptop), the subject broke after every few letters. The panel now
|
||||
// starts collapsed when it would leave the message under 480 px.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-testid': `slot-${name}` }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
|
||||
usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'notes', order: 100 }] : []),
|
||||
}));
|
||||
|
||||
let reportWidth = 0;
|
||||
const observers: Array<(entries: Array<{ contentRect: { width: number } }>) => void> = [];
|
||||
class FakeResizeObserver {
|
||||
constructor(private cb: (entries: Array<{ contentRect: { width: number } }>) => void) { observers.push(cb); }
|
||||
observe() { this.cb([{ contentRect: { width: reportWidth } }]); }
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
|
||||
preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
} as unknown as Email;
|
||||
|
||||
function renderViewer() {
|
||||
return render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
observers.length = 0;
|
||||
});
|
||||
|
||||
describe('plugin detail panel and narrow messages', () => {
|
||||
it('starts collapsed when it would leave the message too narrow', () => {
|
||||
reportWidth = 640;
|
||||
renderViewer();
|
||||
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens anyway when the user asks', () => {
|
||||
reportWidth = 640;
|
||||
renderViewer();
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
|
||||
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is open when there is room', () => {
|
||||
reportWidth = 1100;
|
||||
renderViewer();
|
||||
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The "more details" panel defined its plugin slot component inside render,
|
||||
// so every re-render of the viewer was a new component type: React unmounted
|
||||
// and remounted the plugin's iframe each time (and re-ran its shouldShow).
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mounts: Record<string, number> = {};
|
||||
const propsSeen: Record<string, unknown[]> = {};
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: unknown }) => {
|
||||
(propsSeen[name] ??= []).push(extraProps);
|
||||
useEffect(() => {
|
||||
mounts[name] = (mounts[name] ?? 0) + 1;
|
||||
// Runs once per mount on purpose: that is what the test counts.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return React.createElement('div', { 'data-slot': name });
|
||||
},
|
||||
}));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
|
||||
usePluginSlotOffers: (slot: string) =>
|
||||
slot === 'email-details-section' || slot === 'email-banner' ? [{ pluginId: 'p', order: 100 }] : [],
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
} as unknown as Email;
|
||||
|
||||
describe('plugin slots in the message viewer', () => {
|
||||
it('keep their iframes and props across re-renders', () => {
|
||||
const view = (extra: Record<string, unknown>) => (
|
||||
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
|
||||
);
|
||||
const { rerender } = render(view({ isLoading: false }));
|
||||
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
|
||||
const detailMounts = mounts['email-details-section'];
|
||||
const bannerProps = propsSeen['email-banner'].at(-1);
|
||||
expect(detailMounts).toBeGreaterThan(0);
|
||||
|
||||
rerender(view({ isLoading: false, onReply: () => {} }));
|
||||
rerender(view({ isLoading: false, onReply: () => {}, onForward: () => {} }));
|
||||
|
||||
expect(mounts['email-details-section']).toBe(detailMounts);
|
||||
expect(propsSeen['email-banner'].at(-1)).toBe(bannerProps);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// mail-read hooks fired by the reading pane: onEmailContentRender once the
|
||||
// body is on screen and onThreadExpand for the conversation cards. (The link
|
||||
// hook runs inside the body iframe, which jsdom doesn't load; its decision is
|
||||
// covered in lib/__tests__/plugin-hook-result.test.ts.)
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const textEmail = (id = 'e1') => ({
|
||||
id, blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Hello there' } },
|
||||
}) as unknown as Email;
|
||||
|
||||
const viewer = (email: Email, extra: Record<string, unknown> = {}) => (
|
||||
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
|
||||
);
|
||||
|
||||
beforeEach(() => clearAllHooks());
|
||||
|
||||
describe('onEmailContentRender', () => {
|
||||
it('fires once the body is on screen, once per message', async () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onEmailContentRender.register('test', handler);
|
||||
|
||||
const { rerender } = render(viewer(textEmail(), { isLoading: true }));
|
||||
await act(async () => {});
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
rerender(viewer(textEmail(), { isLoading: false }));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
|
||||
expect(handler).toHaveBeenCalledWith({ emailId: 'e1', hasHtml: false, hasExternalContent: false });
|
||||
|
||||
rerender(viewer(textEmail(), { isLoading: false, onReply: () => {} }));
|
||||
await act(async () => {});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(viewer(textEmail('e2'), { isLoading: false }));
|
||||
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
||||
expect(handler).toHaveBeenLastCalledWith({ emailId: 'e2', hasHtml: false, hasExternalContent: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onThreadExpand in the conversation view', () => {
|
||||
it('reports a card being unfolded and folded', () => {
|
||||
const handler = vi.fn();
|
||||
emailHooks.onThreadExpand.register('test', handler);
|
||||
const older = { ...textEmail('old'), keywords: { $seen: true }, receivedAt: '2025-12-01T00:00:00Z' } as Email;
|
||||
const newer = textEmail('new');
|
||||
|
||||
render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
|
||||
emails={[newer, older]}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The newest card starts unfolded, the older read one folded.
|
||||
const olderHeader = screen.getAllByText('Alice').at(-1)!;
|
||||
fireEvent.click(olderHeader);
|
||||
expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true });
|
||||
fireEvent.click(screen.getAllByText('Alice').at(-1)!);
|
||||
expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false });
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a
|
||||
// fixed width and a toggle to fold it to a thin strip. When the composer is
|
||||
// too narrow for an open panel next to it, the panel folds by itself (right
|
||||
// first); opening it anyway lays it over the composer instead of squeezing
|
||||
// the message into a column nobody can write in.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Width of an open panel. */
|
||||
export const COMPOSER_SIDEBAR_WIDTH = 260;
|
||||
/** Width of a folded panel (the strip with its toggle). */
|
||||
export const COMPOSER_SIDEBAR_STRIP = 37;
|
||||
/**
|
||||
* Narrowest the composer itself may get next to open panels: below this its
|
||||
* bottom bar (Attach, Templates, Options, Discard, Send) runs into itself.
|
||||
*/
|
||||
export const MIN_COMPOSER_WIDTH = 560;
|
||||
|
||||
export type ComposerSidebarSide = "left" | "right";
|
||||
/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */
|
||||
export type ComposerSidebarMode = "docked" | "folded" | "overlay";
|
||||
|
||||
interface SideState {
|
||||
present: boolean;
|
||||
/** Folded by the user. */
|
||||
collapsed: boolean;
|
||||
/** Opened by the user while there was no room for it. */
|
||||
overlayOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How each panel is shown in a composer `width` px wide (0 = not measured
|
||||
* yet: everything the user left open is shown open). The left panel gets
|
||||
* room first, since the right one is folded first.
|
||||
*/
|
||||
export function composerSidebarModes(
|
||||
width: number,
|
||||
left: SideState,
|
||||
right: SideState,
|
||||
): Record<ComposerSidebarSide, ComposerSidebarMode | null> {
|
||||
const present = (left.present ? 1 : 0) + (right.present ? 1 : 0);
|
||||
let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity;
|
||||
const place = (side: SideState): ComposerSidebarMode | null => {
|
||||
if (!side.present) return null;
|
||||
if (side.collapsed) return "folded";
|
||||
const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP;
|
||||
if (extra <= room) {
|
||||
room -= extra;
|
||||
return "docked";
|
||||
}
|
||||
return side.overlayOpen ? "overlay" : "folded";
|
||||
};
|
||||
const l = place(left);
|
||||
return { left: l, right: place(right) };
|
||||
}
|
||||
|
||||
const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
|
||||
|
||||
function readCollapsed(side: ComposerSidebarSide): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Layout state of both panels for a composer whose root element is `root`. */
|
||||
export function useComposerSidebars(root: HTMLElement | null) {
|
||||
const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
|
||||
const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
|
||||
const [width, setWidth] = useState(0);
|
||||
const [collapsed, setCollapsed] = useState<Record<ComposerSidebarSide, boolean>>(() => ({
|
||||
left: readCollapsed("left"),
|
||||
right: readCollapsed("right"),
|
||||
}));
|
||||
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ left: false, right: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width));
|
||||
});
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, [root, hasLeft, hasRight]);
|
||||
|
||||
const modes = composerSidebarModes(
|
||||
width,
|
||||
{ present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left },
|
||||
{ present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right },
|
||||
);
|
||||
|
||||
const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
|
||||
setCollapsed((prev) => ({ ...prev, [side]: value }));
|
||||
try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
|
||||
};
|
||||
|
||||
/** Open a folded panel: beside the composer when it fits, over it otherwise. */
|
||||
const open = (side: ComposerSidebarSide) => {
|
||||
const next = { ...collapsed, [side]: false };
|
||||
const fits = composerSidebarModes(
|
||||
width,
|
||||
{ present: hasLeft, collapsed: next.left },
|
||||
{ present: hasRight, collapsed: next.right },
|
||||
)[side] === "docked";
|
||||
setSideCollapsed(side, false);
|
||||
setOverlayOpen((prev) => ({ ...prev, [side]: !fits }));
|
||||
};
|
||||
const close = (side: ComposerSidebarSide) => {
|
||||
setSideCollapsed(side, true);
|
||||
setOverlayOpen((prev) => ({ ...prev, [side]: false }));
|
||||
};
|
||||
|
||||
return { modes, open, close };
|
||||
}
|
||||
|
||||
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
|
||||
export function ComposerPluginSidebar({
|
||||
side,
|
||||
mode,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
side: ComposerSidebarSide;
|
||||
mode: ComposerSidebarMode | null;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
if (!mode) return null;
|
||||
const border = side === "left" ? "border-e" : "border-s";
|
||||
const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
|
||||
const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose;
|
||||
|
||||
if (mode === "folded") {
|
||||
return (
|
||||
<div
|
||||
className={cn("hidden md:flex flex-col items-center shrink-0 h-full border-border bg-background", border)}
|
||||
style={{ width: COMPOSER_SIDEBAR_STRIP }}
|
||||
data-composer-sidebar={side}
|
||||
data-composer-sidebar-mode="folded"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="m-1 grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={t("plugin_panel_show")}
|
||||
aria-label={t("plugin_panel_show")}
|
||||
>
|
||||
<OpenIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
|
||||
border,
|
||||
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
|
||||
)}
|
||||
style={{ width: COMPOSER_SIDEBAR_WIDTH }}
|
||||
data-composer-sidebar={side}
|
||||
data-composer-sidebar-mode={mode}
|
||||
>
|
||||
<div className={cn("flex items-center px-1 py-1 border-b border-border shrink-0", side === "left" ? "justify-end" : "justify-start")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={t("plugin_panel_hide")}
|
||||
aria-label={t("plugin_panel_hide")}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{side === "left"
|
||||
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
|
||||
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronDown, type AppIcon } from "@/components/icons";
|
||||
import { Check, ChevronDown, iconForName, Puzzle, type AppIcon } from "@/components/icons";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposerToolbarMenuProps {
|
||||
@@ -138,3 +140,111 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginItemsProps {
|
||||
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
|
||||
surface: string;
|
||||
/** What the click is about; a function is read at click time. */
|
||||
context: Record<string, unknown> | (() => Record<string, unknown>);
|
||||
/** Called before the plugin gets the click, e.g. to close the menu. */
|
||||
onSelect?: () => void;
|
||||
/** Draw a separator before the entries (when the menu has its own above). */
|
||||
separator?: boolean;
|
||||
/** Only while the composer bar is narrower than 44rem (see ComposerPluginToolbarButtons). */
|
||||
onlyWhenNarrow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons plugins declared for the composer's bottom bar
|
||||
* (composer-toolbar-actions), drawn like Attach and Templates. An action's
|
||||
* shortcut is shown in its tooltip; the composer binds it.
|
||||
*
|
||||
* The bar is a container (`@container/composer-bar`). Below 44rem there is
|
||||
* no room next to the app's own buttons, Discard and Send, so the buttons
|
||||
* are hidden and the same actions are listed in the Options menu instead
|
||||
* (ComposerPluginMenuItems with `onlyWhenNarrow`).
|
||||
*/
|
||||
export function ComposerPluginToolbarButtons({ surface, context }: { surface: "composer-toolbar-actions"; context: () => Record<string, unknown> }) {
|
||||
const actions = usePluginActions(surface);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label;
|
||||
return (
|
||||
<Button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn("hidden @[44rem]/composer-bar:inline-flex h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")}
|
||||
title={tip}
|
||||
aria-label={action.label}
|
||||
aria-keyshortcuts={action.shortcut}
|
||||
data-plugin-action={`${action.pluginId}:${action.id}`}
|
||||
onClick={() => { void runAction(action, surface, context()); }}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm">{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shows its children only while the composer bar is narrower than 44rem. */
|
||||
function NarrowOnly({ active, children }: { active: boolean; children: React.ReactNode }) {
|
||||
if (!active) return <>{children}</>;
|
||||
return <div className="@[44rem]/composer-bar:hidden" data-narrow-only="">{children}</div>;
|
||||
}
|
||||
|
||||
/** Entries plugins declared for a composer menu, drawn like the menu's own. */
|
||||
export function ComposerPluginMenuItems({ surface, context, onSelect, separator, onlyWhenNarrow }: PluginItemsProps) {
|
||||
const actions = usePluginActions(surface);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<NarrowOnly active={!!onlyWhenNarrow}>
|
||||
{separator && <div role="separator" className="my-1 h-px bg-border" />}
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
if (action.checked !== undefined) {
|
||||
// An option like the menu's own (plain text, read receipt): stays
|
||||
// open, so several can be set in one go.
|
||||
return (
|
||||
<ComposerToolbarMenuCheckItem
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
checked={action.checked}
|
||||
description={action.description}
|
||||
onToggle={() => { void runAction(action, surface, typeof context === "function" ? context() : context); }}
|
||||
>
|
||||
{action.label}
|
||||
</ComposerToolbarMenuCheckItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => {
|
||||
onSelect?.();
|
||||
void runAction(action, surface, typeof context === "function" ? context() : context);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground",
|
||||
action.danger && "text-destructive",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">
|
||||
<span className="block">{action.label}</span>
|
||||
{action.description && <span className="block text-xs text-muted-foreground">{action.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</NarrowOnly>
|
||||
);
|
||||
}
|
||||
|
||||
+459
-102
@@ -34,7 +34,11 @@ import {
|
||||
Reply,
|
||||
Forward,
|
||||
} from "@/components/icons";
|
||||
import { ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit";
|
||||
import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { schedulePresets } from "@/lib/schedule-presets";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -61,14 +65,19 @@ import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
|
||||
@@ -98,7 +107,10 @@ import {
|
||||
type Recipient,
|
||||
enrichChipsWithColorsAndIcons,
|
||||
ICON_MAP,
|
||||
plainTextToComposerBody,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import type { ComposePrefill } from "@/lib/plugin-compose-gate";
|
||||
import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { buildMentionCandidates } from "@/lib/recipient-mentions";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
@@ -235,6 +247,20 @@ interface EmailComposerProps {
|
||||
className?: string;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
/**
|
||||
* Fields a plugin filled in through onBeforeCompose, over what the composer
|
||||
* would start with. The host passes it for the first mount of a session
|
||||
* only: a remount restores the user's own text from `initialData`.
|
||||
*/
|
||||
prefill?: ComposePrefill | null;
|
||||
/**
|
||||
* Identifies the compose session across remounts (a pop-out, a layout
|
||||
* switch), so plugins hear onComposerOpen / onComposerClose once per
|
||||
* message rather than once per mount.
|
||||
*/
|
||||
sessionKey?: string;
|
||||
/** Id of the message being replied to or forwarded, for onComposerOpen. */
|
||||
sourceEmailId?: string;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
/**
|
||||
* Email of the mailbox/account the user is viewing when they start a new
|
||||
@@ -323,6 +349,28 @@ function getDefaultScheduleValue(): string {
|
||||
// the body instead (#1114).
|
||||
const PINNED_FIELDS_MIN_BODY_HEIGHT = 200;
|
||||
|
||||
/**
|
||||
* An attachment a plugin uploaded itself, as the composer stores it, or null
|
||||
* when the plugin sent something that isn't one.
|
||||
*/
|
||||
export function normalizePluginAttachment(raw: unknown): { name: string; type: string; size: number; blobId: string } | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.blobId !== 'string' || !/^[A-Za-z0-9_-]{1,255}$/.test(r.blobId)) return null;
|
||||
if (typeof r.size !== 'number' || !Number.isFinite(r.size) || r.size < 0) return null;
|
||||
// eslint-disable-next-line no-control-regex -- removing control characters is the point
|
||||
const name = typeof r.name === 'string' ? r.name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) : '';
|
||||
if (!name) return null;
|
||||
const type = typeof r.type === 'string' && /^[\w.+-]+\/[\w.+-]+$/.test(r.type) ? r.type : 'application/octet-stream';
|
||||
return { name, type, size: Math.floor(r.size), blobId: r.blobId };
|
||||
}
|
||||
|
||||
/** How long a plugin may take over onBeforeDraftAutoSave before the save goes on without it. */
|
||||
const DRAFT_HOOK_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Longest text a plugin may insert into the message at once. */
|
||||
const MAX_PLUGIN_INSERT_LENGTH = 100_000;
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
@@ -331,8 +379,11 @@ export function EmailComposer({
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
className,
|
||||
initialDraftText,
|
||||
initialData,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
prefill,
|
||||
sessionKey,
|
||||
sourceEmailId,
|
||||
mode = 'compose',
|
||||
composeFromAccountEmail,
|
||||
replyTo
|
||||
@@ -340,14 +391,34 @@ export function EmailComposer({
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
|
||||
// Per-message format (#1022). Seeded from the "plain text only" setting -
|
||||
// or the format a re-opened / stashed draft was written in - and switchable
|
||||
// from the toolbar for just this message.
|
||||
const [plainTextMode, setPlainTextMode] = useState<boolean>(
|
||||
() => initialData?.plainTextMode ?? plainTextModeDefault
|
||||
() => initialDataProp?.plainTextMode ?? plainTextModeDefault
|
||||
);
|
||||
// A plugin's onBeforeCompose prefill (plain text body) folded into what the
|
||||
// composer starts from: over `initialData` when the host hands one in (a
|
||||
// mailto:, a re-opened draft), otherwise over the fields built below.
|
||||
const [initialPlainTextMode] = useState(plainTextMode);
|
||||
const initialData = useMemo<ComposerDraftData | null | undefined>(() => {
|
||||
if (!initialDataProp || !prefill) return initialDataProp;
|
||||
const next = { ...initialDataProp };
|
||||
if (prefill.to) next.to = prefill.to.join(', ');
|
||||
if (prefill.cc) {
|
||||
next.cc = prefill.cc.join(', ');
|
||||
if (prefill.cc.length > 0) next.showCc = true;
|
||||
}
|
||||
if (prefill.subject !== undefined) next.subject = prefill.subject;
|
||||
if (prefill.body !== undefined) {
|
||||
next.body = initialPlainTextMode ? prefill.body : plainTextToComposerBody(prefill.body);
|
||||
}
|
||||
return next;
|
||||
}, [initialDataProp, prefill, initialPlainTextMode]);
|
||||
const initialDraftText = !initialDataProp && prefill?.body !== undefined ? prefill.body : initialDraftTextProp;
|
||||
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
|
||||
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
|
||||
const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch);
|
||||
@@ -626,17 +697,22 @@ export function EmailComposer({
|
||||
// is typing lives in a separate `*Input` string per field. This keeps a
|
||||
// display name containing a comma (e.g. "Doo, John") intact instead of
|
||||
// tearing it apart on a delimiter.
|
||||
const [to, setTo] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.to) : getInitialTo());
|
||||
const [cc, setCc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
|
||||
const [to, setTo] = useState<Recipient[]>(() => initialData
|
||||
? parseRecipientList(initialData.to)
|
||||
: prefill?.to ? parseRecipientList(prefill.to.join(', ')) : getInitialTo());
|
||||
const [cc, setCc] = useState<Recipient[]>(() => initialData
|
||||
? parseRecipientList(initialData.cc)
|
||||
: prefill?.cc ? parseRecipientList(prefill.cc.join(', ')) : getInitialCc());
|
||||
const [bcc, setBcc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.bcc) : []);
|
||||
const [toInput, setToInput] = useState('');
|
||||
const [ccInput, setCcInput] = useState('');
|
||||
const [bccInput, setBccInput] = useState('');
|
||||
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
|
||||
const [subject, setSubject] = useState(() => initialData?.subject ?? prefill?.subject ?? getInitialSubject());
|
||||
const [body, setBody] = useState(() =>
|
||||
initialData?.body != null ? withSignatureEmbedded(initialData.body) : getInitialBody()
|
||||
);
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0);
|
||||
const [showCc, setShowCc] = useState(() => initialData?.showCc
|
||||
?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0));
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
// Committed recipients plus any not-yet-committed text the user has typed.
|
||||
// Send/validation/draft paths treat a typed-but-uncommitted address as a
|
||||
@@ -869,9 +945,11 @@ export function EmailComposer({
|
||||
// Alias identities often lack a configured signature - fall back to the primary
|
||||
// identity's signature so replies (which auto-select a matching alias) still
|
||||
// populate the user's signature.
|
||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
const ownSignatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
// Plugins may rewrite the signature (onSignatureRender).
|
||||
const { identity: signatureIdentity, revision: pluginSignatureRevision } = usePluginSignature(ownSignatureIdentity);
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
@@ -882,15 +960,18 @@ export function EmailComposer({
|
||||
const replyFocusedRef = useRef(false);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
const prevPluginSignatureRevisionRef = useRef(pluginSignatureRevision);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
const pluginSignatureChanged = prevPluginSignatureRevisionRef.current !== pluginSignatureRevision;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
prevPluginSignatureRevisionRef.current = pluginSignatureRevision;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (!identityChanged && !separatorChanged && !pluginSignatureChanged) return;
|
||||
if (plainTextMode) return;
|
||||
// Replies/forwards only embed when configured for "above quote". Compose
|
||||
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||
@@ -928,7 +1009,7 @@ export function EmailComposer({
|
||||
// would re-run on unrelated identity-field changes and re-splice the
|
||||
// signature into the live editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
@@ -1282,6 +1363,31 @@ export function EmailComposer({
|
||||
// open the next composer with the discarded reply's mode/replyTo.
|
||||
const explicitCloseRef = useRef(false);
|
||||
|
||||
// onComposerOpen / onComposerClose, once per session (see
|
||||
// lib/plugin-composer-hooks.ts). Without a key from the host every mount is
|
||||
// its own session.
|
||||
const fallbackSessionKeyRef = useRef<string | null>(null);
|
||||
if (!sessionKey && fallbackSessionKeyRef.current === null) {
|
||||
fallbackSessionKeyRef.current = `composer-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
const effectiveSessionKey = sessionKey ?? fallbackSessionKeyRef.current ?? 'composer';
|
||||
// Whether this session ended in a send, and the draft it ended with.
|
||||
const sentRef = useRef(false);
|
||||
const lastDraftIdRef = useRef<string | null>(initialData?.draftId ?? null);
|
||||
useEffect(() => {
|
||||
composerMounted(effectiveSessionKey, {
|
||||
mode: composerContextMode(mode),
|
||||
...(sourceEmailId && mode !== 'compose' ? { inReplyToId: sourceEmailId } : {}),
|
||||
...(replyTo?.subject && mode !== 'compose' ? { originalSubject: replyTo.subject } : {}),
|
||||
});
|
||||
return () => composerUnmounted(effectiveSessionKey, () => ({
|
||||
draftId: draftIdRef.current ?? lastDraftIdRef.current,
|
||||
sent: sentRef.current,
|
||||
}));
|
||||
// The session is fixed for the life of this mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveSessionKey]);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1563,8 +1669,8 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record<string, string>) => {
|
||||
const filledSubject = Object.keys(filledValues).length > 0
|
||||
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
|
||||
let filledSubject = Object.keys(filledValues).length > 0
|
||||
? substitutePlaceholders(template.subject, filledValues)
|
||||
: template.subject;
|
||||
const filledBody = Object.keys(filledValues).length > 0
|
||||
@@ -1572,9 +1678,19 @@ export function EmailComposer({
|
||||
: template.body;
|
||||
|
||||
// In plain text mode, use template body as-is; otherwise convert to HTML
|
||||
const bodyContent = plainTextMode || template.isHTML
|
||||
let bodyContent = plainTextMode || template.isHTML
|
||||
? filledBody
|
||||
: `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
// Plugins may rewrite the template before it goes in (onTemplateRender).
|
||||
const rendered = await renderTemplateWithPlugins(template.id, {
|
||||
subject: filledSubject,
|
||||
html: plainTextMode && !template.isHTML ? `<p>${escapeHtml(filledBody).replace(/\n/g, '<br>')}</p>` : bodyContent,
|
||||
text: template.isHTML ? htmlToPlainText(filledBody) : filledBody,
|
||||
});
|
||||
if (rendered) {
|
||||
filledSubject = rendered.subject;
|
||||
bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html);
|
||||
}
|
||||
|
||||
if (mode === 'compose') {
|
||||
// An empty template subject must not wipe one the user typed (#540).
|
||||
@@ -1659,6 +1775,7 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
setShowTemplatePicker(false);
|
||||
notifyTemplateApplied(template);
|
||||
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1935,17 +2052,42 @@ export function EmailComposer({
|
||||
// offers "attach from there". No `file` is set, matching the existing
|
||||
// draft-part hydration path above (a blobId-only attachment is already a
|
||||
// supported shape, just never previously reachable from a plugin).
|
||||
const handlePluginAttachmentAdd = useCallback((upload: PluginAttachmentUpload) => {
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
name: upload.name,
|
||||
type: upload.type,
|
||||
size: upload.size,
|
||||
blobId: upload.blobId,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload): Promise<boolean> => {
|
||||
// The values come from a plugin: check them, and run the same intercept
|
||||
// and size limit every other way of attaching a file goes through.
|
||||
const upload = normalizePluginAttachment(raw);
|
||||
if (!upload) {
|
||||
debug.warn('email', 'Refused a malformed attachment from a plugin', raw);
|
||||
return false;
|
||||
}
|
||||
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
|
||||
name: upload.name, type: upload.type, size: upload.size,
|
||||
});
|
||||
if (!ok) return false;
|
||||
const maxTotal = composerClient?.getMaxSizeAttachmentsPerEmail?.() ?? 0;
|
||||
const total = attachmentsRef.current.reduce((sum, att) => sum + (att.size || 0), 0);
|
||||
if (maxTotal > 0 && total + upload.size > maxTotal) {
|
||||
toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) }));
|
||||
return false;
|
||||
}
|
||||
setAttachments(prev => [...prev, upload]);
|
||||
return true;
|
||||
}, [composerClient, t]);
|
||||
// Stable props, so a re-render of the composer doesn't post them to the
|
||||
// plugin's iframe again.
|
||||
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
|
||||
// The plugin side panels fold when the composer gets too narrow for them.
|
||||
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
|
||||
const pluginSidebars = useComposerSidebars(composerRootEl);
|
||||
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
|
||||
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
|
||||
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
|
||||
// Plugin entries make the Send and Attach buttons menus even where the app
|
||||
// itself has nothing else to put there.
|
||||
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
|
||||
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
|
||||
// Ticked plugin options count in the Options button's badge like the app's own.
|
||||
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
|
||||
|
||||
// Files from the Files app are already blobs on the server, so they are
|
||||
// attached by blobId with no download or upload (#1179). Stalwart resolves a
|
||||
@@ -2030,7 +2172,10 @@ export function EmailComposer({
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
// `auto`: the debounced save while typing. Only that one may be skipped
|
||||
// by a plugin; saves on close and before send always run, or the text
|
||||
// would be lost.
|
||||
const saveDraftOnce = async (auto = false): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
const versionAtStart = changeVersionRef.current;
|
||||
|
||||
@@ -2133,7 +2278,19 @@ export function EmailComposer({
|
||||
fromName,
|
||||
htmlBody: draftHtmlBody
|
||||
}
|
||||
savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
|
||||
if (emailHooks.onBeforeDraftAutoSave.size > 0) {
|
||||
const outcome = await emailHooks.onBeforeDraftAutoSave.interceptWithValue(
|
||||
savedDraft,
|
||||
(result) => (result && typeof result === 'object' ? result as typeof savedDraft : undefined),
|
||||
{ timeoutMs: DRAFT_HOOK_TIMEOUT_MS },
|
||||
);
|
||||
if (!outcome.allowed && auto) {
|
||||
// Skipped: leave lastSavedDataRef alone so the next save retries.
|
||||
setSaveStatus('idle');
|
||||
return draftIdRef.current;
|
||||
}
|
||||
if (outcome.allowed) savedDraft = outcome.value;
|
||||
}
|
||||
|
||||
// Use the JMAP client and raw identity id for the *owning* account
|
||||
// - falls back to active client for single-account / same-account
|
||||
@@ -2196,6 +2353,8 @@ export function EmailComposer({
|
||||
|
||||
setSaveStatus('saved');
|
||||
saveFailedRef.current = false;
|
||||
lastDraftIdRef.current = savedDraftId;
|
||||
draftSaved(savedDraftId, savedDraft.subject);
|
||||
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
@@ -2218,13 +2377,13 @@ export function EmailComposer({
|
||||
// racing two `Email/set { destroy, create }` requests against the same
|
||||
// draftId, which left orphan drafts and (when EmailSubmission failed)
|
||||
// looked like "send didn't happen" (#303).
|
||||
const saveDraft = (): Promise<string | null> => {
|
||||
const saveDraft = (opts: { auto?: boolean } = {}): Promise<string | null> => {
|
||||
const previous = inflightSaveRef.current;
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
if (previous) {
|
||||
try { await previous; } catch { /* prior failure already reported */ }
|
||||
}
|
||||
return saveDraftOnce();
|
||||
return saveDraftOnce(!!opts.auto);
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
promise.finally(() => {
|
||||
@@ -2238,6 +2397,162 @@ export function EmailComposer({
|
||||
// Keep saveDraftRef pointing to latest saveDraft
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
// The draft as plugins see it: onDraftChange, the frames above and below
|
||||
// the body, and clicks on plugin entries in the Send menu.
|
||||
const draftSnapshot = () => ({
|
||||
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
|
||||
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: plainTextMode ? '' : body,
|
||||
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||
identityId: selectedIdentityId || '',
|
||||
attachments: attachments
|
||||
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
});
|
||||
const draftSnapshotRef = useRef(draftSnapshot);
|
||||
draftSnapshotRef.current = draftSnapshot;
|
||||
|
||||
// composer-above-body / composer-below-body get the draft when they appear
|
||||
// and then 2 seconds after the last change, like onDraftChange: a new prop
|
||||
// object on every keystroke would post the whole body into their iframes.
|
||||
const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers;
|
||||
const [pluginDraft, setPluginDraft] = useState<ReturnType<typeof draftSnapshot> | null>(null);
|
||||
const pluginDraftShownRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!wantsPluginDraft) {
|
||||
pluginDraftShownRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!pluginDraftShownRef.current) {
|
||||
pluginDraftShownRef.current = true;
|
||||
setPluginDraft(draftSnapshotRef.current());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setPluginDraft(draftSnapshotRef.current()), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [wantsPluginDraft, toStr, ccStr, bccStr, subject, body, plainTextMode, selectedIdentityId, attachments]);
|
||||
// Plugins write into the message through these (slot props, gated by the
|
||||
// catalogue): plain text at the caret, replacing the selection, so a
|
||||
// rewrite of a selected paragraph is one call and Ctrl+Z takes it back in
|
||||
// the rich editor. Stable, so the slot iframes don't get new props.
|
||||
const plainTextModeRef = useRef(plainTextMode);
|
||||
plainTextModeRef.current = plainTextMode;
|
||||
const pluginInsertText = useCallback((value: unknown) => {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PLUGIN_INSERT_LENGTH) return false;
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return false;
|
||||
const start = textarea.selectionStart ?? textarea.value.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
setBody((prev) => prev.slice(0, start) + value + prev.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + value.length, start + value.length);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return false;
|
||||
const html = value.split(/\r?\n\r?\n/).map((para) => `<p>${escapeHtml(para).replace(/\r?\n/g, '<br>')}</p>`).join('');
|
||||
editor.chain().focus().insertContent(html).run();
|
||||
return true;
|
||||
}, []);
|
||||
const pluginSelectedText = useCallback(() => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return '';
|
||||
return textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0);
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return '';
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to, '\n\n');
|
||||
}, []);
|
||||
// Plugins read and change this message through api.composer, with the id
|
||||
// this composer hands out in its action clicks and slot props (see
|
||||
// composer-registry.ts). The rich editor is changed only through its own
|
||||
// transactions, so one Ctrl+Z takes an edit back; the user's own text ends
|
||||
// at the signature and the quote (composer-edit.ts).
|
||||
const pluginEditOptsRef = useRef<OwnTextOptions & { signature: string }>({ signature: '' });
|
||||
pluginEditOptsRef.current = {
|
||||
replyLike: mode === 'reply' || mode === 'replyAll' || mode === 'forward',
|
||||
forwardedSeparator: tQuote('forwarded_separator'),
|
||||
signature: getPlainTextSignature(signatureIdentity),
|
||||
};
|
||||
const pluginAttachRef = useRef(handlePluginAttachmentAdd);
|
||||
pluginAttachRef.current = handlePluginAttachmentAdd;
|
||||
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
|
||||
const { id, unregister } = registerComposer({
|
||||
readBody: () => {
|
||||
if (plainTextModeRef.current) {
|
||||
const value = bodyRef.current?.value ?? '';
|
||||
const { from, to } = plainRange(value);
|
||||
const text = value.slice(from, to);
|
||||
return { text, html: textToEditorHtml(text) };
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return editor ? readOwnContent(editor, pluginEditOptsRef.current) : { html: '', text: '' };
|
||||
},
|
||||
readSelection: () => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
const text = textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : '';
|
||||
return { text, html: textToEditorHtml(text), empty: text.length === 0 };
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return editor ? readEditorSelection(editor) : { html: '', text: '', empty: true };
|
||||
},
|
||||
replaceBody: (html, text) => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return false;
|
||||
const { from, to } = plainRange(textarea.value);
|
||||
return replaceTextareaRange(textarea, from, to, text);
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return !!editor && replaceOwnContent(editor, html, pluginEditOptsRef.current);
|
||||
},
|
||||
replaceSelection: (html, text, { below }) => {
|
||||
if (plainTextModeRef.current) {
|
||||
const textarea = bodyRef.current;
|
||||
if (!textarea) return false;
|
||||
const start = textarea.selectionStart ?? textarea.value.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
return below
|
||||
? replaceTextareaRange(textarea, end, end, `\n\n${text}`)
|
||||
: replaceTextareaRange(textarea, start, end, text);
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
return !!editor && replaceEditorSelection(editor, html, { below });
|
||||
},
|
||||
attach: (file) => pluginAttachRef.current(file as PluginAttachmentUpload),
|
||||
});
|
||||
setPluginComposerId(id);
|
||||
return unregister;
|
||||
}, []);
|
||||
|
||||
const pluginDraftProps = useMemo(
|
||||
() => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
|
||||
[pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId],
|
||||
);
|
||||
const pluginToolbarProps = useMemo(
|
||||
() => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
|
||||
[pluginInsertText, pluginSelectedText, pluginComposerId],
|
||||
);
|
||||
// A cloud picker attaches files, or puts share links in the text.
|
||||
const pluginAttachmentSourceProps = useMemo(
|
||||
() => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText, composerId: pluginComposerId }),
|
||||
[handlePluginAttachmentAdd, pluginInsertText, pluginComposerId],
|
||||
);
|
||||
const pluginComposerIdRef = useRef(pluginComposerId);
|
||||
pluginComposerIdRef.current = pluginComposerId;
|
||||
const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current(), composerId: pluginComposerIdRef.current }), []);
|
||||
const composerActionContext = useCallback(() => ({ composerId: pluginComposerIdRef.current }), []);
|
||||
|
||||
// Trigger auto-save when content changes (only if user modified something)
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
@@ -2257,19 +2572,8 @@ export function EmailComposer({
|
||||
// tracked via inflightSaveRef.
|
||||
saveTimeoutRef.current = null;
|
||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||
emailHooks.onDraftChange.emit({
|
||||
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
|
||||
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: plainTextMode ? '' : body,
|
||||
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||
identityId: selectedIdentityId || '',
|
||||
attachments: attachments
|
||||
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
});
|
||||
saveDraft();
|
||||
emailHooks.onDraftChange.emit(draftSnapshot());
|
||||
saveDraft({ auto: true });
|
||||
}, 2000);
|
||||
|
||||
// Cleanup on unmount
|
||||
@@ -2623,11 +2927,14 @@ export function EmailComposer({
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) {
|
||||
const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview);
|
||||
if (sendCheck !== 'allowed') {
|
||||
// A plugin vetoed the send (it is expected to show its own UI).
|
||||
// Leave a trace so a silent no-op send is diagnosable (#592).
|
||||
debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler');
|
||||
debug.log('email', `Send stopped by an onBeforeEmailSend plugin handler (${sendCheck})`);
|
||||
// A guard that crashed or never answered showed nothing; say why
|
||||
// the message is still here.
|
||||
if (sendCheck === 'failed') toast.error(t('send_guard_failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2670,6 +2977,7 @@ export function EmailComposer({
|
||||
}
|
||||
const sendHandledByPlugin = pluginOutcome === 'handled';
|
||||
if (sendHandledByPlugin) {
|
||||
sentRef.current = true;
|
||||
if (finalDraftId) {
|
||||
// The draft was created through the identity's owning account
|
||||
// (see saveDraft), so clean it up there too - not on the active
|
||||
@@ -2726,6 +3034,9 @@ export function EmailComposer({
|
||||
// draft ("Continue draft" flashed after every send). Reset below if
|
||||
// the send fails and the composer stays open.
|
||||
explicitCloseRef.current = true;
|
||||
// Read when the close is reported: the host tears the composer down
|
||||
// while onSend is still running. Reset below if the send fails.
|
||||
sentRef.current = true;
|
||||
await onSend?.({
|
||||
to: outgoing.to,
|
||||
cc: outgoing.cc,
|
||||
@@ -2784,6 +3095,7 @@ export function EmailComposer({
|
||||
} catch (err) {
|
||||
// Not sent, so the composer stays open and is an unsent draft again.
|
||||
explicitCloseRef.current = false;
|
||||
sentRef.current = false;
|
||||
debug.error('Failed to send email:', err);
|
||||
if (err instanceof RecipientsRejectedError) {
|
||||
// Nothing went out; the message stays open here so the addresses
|
||||
@@ -2947,7 +3259,16 @@ export function EmailComposer({
|
||||
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
|
||||
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
|
||||
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
|
||||
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
|
||||
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) {
|
||||
// A plugin's composer button with a shortcut (composer-toolbar-actions)
|
||||
// works while the composer has focus, also while typing.
|
||||
if (!e.altKey && !hasPrimaryModifier) return;
|
||||
const action = actionsFor('composer-toolbar-actions').find((a) => a.shortcut && matchesCombo(a.shortcut, e.nativeEvent));
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
void runAction(action, 'composer-toolbar-actions', composerActionContext());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
showTemplatePicker ||
|
||||
@@ -2988,14 +3309,12 @@ export function EmailComposer({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setComposerRootEl}
|
||||
data-testid="email-composer"
|
||||
className={cn("flex h-full bg-background", className)}
|
||||
className={cn("flex h-full bg-background relative", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
/>
|
||||
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
|
||||
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||
<div
|
||||
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||
@@ -3122,7 +3441,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
@@ -3139,7 +3458,8 @@ export function EmailComposer({
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
size="sm"
|
||||
className="h-9 rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
@@ -3149,15 +3469,23 @@ export function EmailComposer({
|
||||
</Button>
|
||||
{showSendMenu && (
|
||||
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
)}
|
||||
<ComposerPluginMenuItems
|
||||
surface="composer-send-menu"
|
||||
context={sendMenuContext}
|
||||
onSelect={() => setShowSendMenu(false)}
|
||||
separator={!!composerClient?.hasDelayedSend()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3213,7 +3541,10 @@ export function EmailComposer({
|
||||
<select
|
||||
data-testid="composer-from"
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedIdentityId(e.target.value);
|
||||
notifyIdentitySelected(identities, e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
>
|
||||
{identityGroups.length > 0
|
||||
@@ -3476,6 +3807,9 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
{/* Body */}
|
||||
{plainTextMode ? (
|
||||
<textarea
|
||||
@@ -3534,6 +3868,9 @@ export function EmailComposer({
|
||||
dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3634,18 +3971,36 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin buttons get their own row: the composer is often too narrow
|
||||
for them next to Attach, Templates, Options, Discard and Send, and
|
||||
squeezing them in pushed Send out of the window. */}
|
||||
{(pluginToolbarOffers.length > 0 || pluginAttachmentSourceOffers.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1 border-t border-border px-4 py-1.5 bg-background shrink-0" data-composer-plugin-row="">
|
||||
<PluginSlot name="composer-toolbar" extraProps={pluginToolbarProps} />
|
||||
{/* Lets a plugin offer an alternative attachment source (an
|
||||
external file browser, cloud storage picker, etc). The plugin
|
||||
calls `onAttach` once it has uploaded the chosen file to JMAP
|
||||
itself (api.jmap.uploadBlob) and just wants it added to this
|
||||
draft. */}
|
||||
<PluginSlot
|
||||
name="composer-attachment-source"
|
||||
extraProps={pluginAttachmentSourceProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar. In Mountain View's window both halves fold into
|
||||
one row (see [data-mv-compose-footer] in globals.css): Send first,
|
||||
the tools after it, and Discard as a bin at the far end. */}
|
||||
<div
|
||||
data-mv-compose-footer={mvWindow ? "" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-2.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
|
||||
"@container/composer-bar flex items-center justify-between px-4 py-2.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
|
||||
mvWindow ? "gap-1" : "border-t",
|
||||
)}
|
||||
>
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -3654,7 +4009,7 @@ export function EmailComposer({
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() ? (
|
||||
{(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
|
||||
<ComposerToolbarMenu icon={Paperclip} label={t('attach')} testId="composer-attach-menu">
|
||||
{(close) => <>
|
||||
<ComposerToolbarMenuItem
|
||||
@@ -3663,13 +4018,16 @@ export function EmailComposer({
|
||||
>
|
||||
{t('attach_from_device')}
|
||||
</ComposerToolbarMenuItem>
|
||||
<ComposerToolbarMenuItem
|
||||
icon={FolderOpen}
|
||||
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
|
||||
testId="composer-attach-from-files"
|
||||
>
|
||||
{t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() && (
|
||||
<ComposerToolbarMenuItem
|
||||
icon={FolderOpen}
|
||||
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
|
||||
testId="composer-attach-from-files"
|
||||
>
|
||||
{t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-attach-menu" context={composerActionContext} onSelect={close} separator />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
) : (
|
||||
@@ -3719,10 +4077,10 @@ export function EmailComposer({
|
||||
<ComposerToolbarMenu
|
||||
icon={SlidersHorizontal}
|
||||
label={t('options')}
|
||||
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length}
|
||||
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length + pluginOptionsOn}
|
||||
testId="composer-options-menu"
|
||||
>
|
||||
{() => <>
|
||||
{(close) => <>
|
||||
<ComposerToolbarMenuCheckItem
|
||||
checked={plainTextMode}
|
||||
onToggle={togglePlainTextMode}
|
||||
@@ -3759,22 +4117,15 @@ export function EmailComposer({
|
||||
{t('option_require_tls')}
|
||||
</ComposerToolbarMenuCheckItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
|
||||
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
{/* Lets a plugin offer an alternative attachment source (an
|
||||
external file browser, cloud storage picker, etc). The plugin
|
||||
calls `onAttach` once it has uploaded the chosen file to JMAP
|
||||
itself (api.jmap.uploadBlob) and just wants it added to this
|
||||
draft. */}
|
||||
<PluginSlot
|
||||
name="composer-attachment-source"
|
||||
extraProps={{ onAttach: handlePluginAttachmentAdd }}
|
||||
/>
|
||||
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscardClick}
|
||||
@@ -3788,7 +4139,7 @@ export function EmailComposer({
|
||||
>
|
||||
{mvWindow ? <Trash2 className="w-5 h-5" /> : t('discard')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div ref={sendMenuRef} data-mv-send={mvWindow ? "" : undefined} className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
@@ -3804,7 +4155,8 @@ export function EmailComposer({
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
className="rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
@@ -3816,15 +4168,23 @@ export function EmailComposer({
|
||||
role="menu"
|
||||
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
)}
|
||||
<ComposerPluginMenuItems
|
||||
surface="composer-send-menu"
|
||||
context={sendMenuContext}
|
||||
onSelect={() => setShowSendMenu(false)}
|
||||
separator={!!composerClient?.hasDelayedSend()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -4063,10 +4423,7 @@ export function EmailComposer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
|
||||
/>
|
||||
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
@@ -266,6 +267,23 @@ export function EmailContextMenu({
|
||||
// Stable, so the Rules entry does not re-resolve its account every render.
|
||||
const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]);
|
||||
|
||||
// The row's plugin buttons (message-row-actions). On phones the long press
|
||||
// opens this menu, so it is where those buttons live there.
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
|
||||
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<PluginActions
|
||||
surface="message-row-actions"
|
||||
variant="menu"
|
||||
context={rowPluginContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={onClose}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// The menu's entries, composed below in the default layout's order or,
|
||||
// under Mountain View, in Gmail's.
|
||||
const replyBlock = (
|
||||
@@ -564,6 +582,7 @@ export function EmailContextMenu({
|
||||
{findBlock}
|
||||
<ContextMenuSeparator />
|
||||
{permalinkBlock}
|
||||
{rowPluginBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -581,6 +600,7 @@ export function EmailContextMenu({
|
||||
{spamBlock}
|
||||
<ContextMenuSeparator />
|
||||
{readItem}
|
||||
{rowPluginBlock}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -27,6 +28,8 @@ interface EmailHoverActionsProps {
|
||||
onUndoSpam?: () => void;
|
||||
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||
spamApplicable?: boolean;
|
||||
/** Role of the folder the row is listed in; plugin buttons can be limited to some. */
|
||||
mailboxRole?: string | null;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
@@ -105,6 +108,7 @@ export function EmailHoverActions({
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
spamApplicable = true,
|
||||
mailboxRole = null,
|
||||
}: EmailHoverActionsProps) {
|
||||
const configuredHoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
@@ -113,6 +117,9 @@ export function EmailHoverActions({
|
||||
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
const isMobile = useIsMobile();
|
||||
// Buttons plugins add to the row (message-row-actions), after the app's own.
|
||||
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
|
||||
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
|
||||
// The tag quick-action opens the same picker the context menu and the reading
|
||||
// pane use. It is portalled to the body because every row sits inside a
|
||||
@@ -159,7 +166,7 @@ export function EmailHoverActions({
|
||||
const hoverBackgroundClassName = backgroundClassName;
|
||||
|
||||
if (isMobile) return null;
|
||||
if (hoverActions.length === 0) return null;
|
||||
if (hoverActions.length === 0 && pluginActionCount === 0) return null;
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||
e.stopPropagation();
|
||||
@@ -251,6 +258,18 @@ export function EmailHoverActions({
|
||||
</button>
|
||||
);
|
||||
});
|
||||
if (pluginActionCount > 0) {
|
||||
actionButtons.push(
|
||||
<PluginActions
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const tagPicker =
|
||||
tagPickerPos && onSetTag
|
||||
|
||||
@@ -9,7 +9,7 @@ import { listVerificationCode } from "@/lib/verification-code";
|
||||
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
|
||||
import { BatchTagButton } from "./batch-tag-button";
|
||||
import { cn, cleanPreview } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X } from "@/components/icons";
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
@@ -31,7 +31,10 @@ import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-displa
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
@@ -72,6 +75,9 @@ interface EmailListProps {
|
||||
hideBatchToolbar?: boolean;
|
||||
}
|
||||
|
||||
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
|
||||
const BATCH_TOOLBAR_WIDE = 480;
|
||||
|
||||
export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
@@ -264,6 +270,20 @@ export function EmailList({
|
||||
observer.observe(toolbar);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
// A narrow list (folder sidebar open, phones) has no room for the batch
|
||||
// toolbar's labels: plugin buttons go behind one "more" button and "Clear
|
||||
// selection" becomes an icon, instead of wrapping into each other.
|
||||
const [batchNarrow, setBatchNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const toolbar = batchToolbarRef.current;
|
||||
if (!toolbar || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? toolbar.getBoundingClientRect().width;
|
||||
if (width > 0) setBatchNarrow(width < BATCH_TOOLBAR_WIDE);
|
||||
});
|
||||
observer.observe(toolbar);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
// One tag treatment for the whole list, measured from the scroll container.
|
||||
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -380,6 +400,19 @@ export function EmailList({
|
||||
);
|
||||
|
||||
const hasSelection = selectedEmailKeys.size > 0;
|
||||
// What plugin buttons in the selection toolbar act on: the JMAP ids of the
|
||||
// selected messages, thread members included.
|
||||
const selectedEmailIds = useMemo(
|
||||
() => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id),
|
||||
[emails, threadEmailsCache, selectedEmailKeys],
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const listSlotProps = useMemo(
|
||||
() => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }),
|
||||
[selectedMailbox, effectiveMailboxRole],
|
||||
);
|
||||
const isSearching = !!searchQuery || !isFilterEmpty(searchFilters);
|
||||
const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]);
|
||||
|
||||
const handleBatchMarkAsRead = async (read: boolean) => {
|
||||
if (!client || isProcessing) return;
|
||||
@@ -497,18 +530,34 @@ export function EmailList({
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
|
||||
|
||||
// The messages of a thread as the list knows them, for onThreadExpand.
|
||||
const threadMembers = useCallback((threadKey: string): Email[] => (
|
||||
useEmailStore.getState().threadEmailsCache.get(threadKey)
|
||||
?? threadGroups.find((g) => g.threadKey === threadKey)?.emails
|
||||
?? []
|
||||
), [threadGroups]);
|
||||
|
||||
const handleToggleThreadExpansion = useCallback(async (threadKey: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadKey);
|
||||
|
||||
if (!isExpanded && client) {
|
||||
toggleThreadExpansion(threadKey);
|
||||
await fetchThreadEmails(client, threadKey);
|
||||
emitThreadExpand(threadKey, threadMembers(threadKey), true);
|
||||
// Mark all unread emails in this thread as read
|
||||
void markThreadAsRead(client, threadKey);
|
||||
} else {
|
||||
toggleThreadExpansion(threadKey);
|
||||
emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]);
|
||||
|
||||
const collapseAllThreadsAndNotify = useCallback(() => {
|
||||
const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
|
||||
const members = wereExpanded.map((key) => [key, threadMembers(key)] as const);
|
||||
collapseAllThreads();
|
||||
for (const [key, emails] of members) emitThreadExpand(key, emails, false);
|
||||
}, [collapseAllThreads, threadMembers]);
|
||||
|
||||
// Range-based load more: trigger when last visible item is near the end.
|
||||
// Debounce to prevent rapid cascade when thread grouping reduces item
|
||||
@@ -562,13 +611,13 @@ export function EmailList({
|
||||
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<div className={cn("py-2 border-b bg-accent/30 border-border flex items-center justify-between gap-2", batchNarrow ? "px-2" : "px-4")} data-batch-narrow={batchNarrow ? "" : undefined}>
|
||||
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
|
||||
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate">
|
||||
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
|
||||
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0.5 [&_button]:px-2" : "gap-1")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -636,16 +685,27 @@ export function EmailList({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
{hasSelection && (
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={bulkActionContext}
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
compact
|
||||
overflow={batchNarrow}
|
||||
className="flex-nowrap"
|
||||
/>
|
||||
)}
|
||||
<div className={cn("w-px h-6 bg-border", batchNarrow ? "mx-0.5" : "mx-1")} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
title={t('batch_actions.clear_selection')}
|
||||
aria-label={t('batch_actions.clear_selection')}
|
||||
disabled={isProcessing}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{t('batch_actions.clear_selection')}
|
||||
{batchNarrow ? <X className="w-4 h-4" /> : t('batch_actions.clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -691,6 +751,8 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
{/* Email List */}
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||
{/* Loading overlay */}
|
||||
@@ -722,6 +784,7 @@ export function EmailList({
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -756,14 +819,14 @@ export function EmailList({
|
||||
isLoading={isLoadingThread === thread.threadKey}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadKey)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)}
|
||||
onCollapseAllThreads={collapseAllThreads}
|
||||
onCollapseAllThreads={collapseAllThreadsAndNotify}
|
||||
onEmailSelect={(email) => {
|
||||
// Collapse expanded threads when selecting an email outside the expanded thread
|
||||
const currentExpanded = useEmailStore.getState().expandedThreadIds;
|
||||
if (currentExpanded.size > 0) {
|
||||
// Check if the selected email belongs to any expanded thread via its threadId
|
||||
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
|
||||
collapseAllThreads();
|
||||
collapseAllThreadsAndNotify();
|
||||
}
|
||||
}
|
||||
onEmailSelect?.(email);
|
||||
|
||||
+242
-151
@@ -4,7 +4,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
@@ -26,6 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
import {
|
||||
Reply,
|
||||
@@ -111,6 +112,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||
@@ -120,10 +122,14 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emailHooks, renderHooks } from "@/lib/plugin-hooks";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { resolveExternalLink } from "@/lib/plugin-hook-result";
|
||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
@@ -951,6 +957,18 @@ function MvPager({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin slot under one "more details" category. Props are memoised so
|
||||
* re-renders of the viewer don't post them to the plugin again.
|
||||
*/
|
||||
function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; category: string | null }) {
|
||||
const props = useMemo(() => ({ email, category }), [email, category]);
|
||||
return <PluginSlot name="email-details-section" className="mt-2 empty:mt-0" extraProps={props} />;
|
||||
}
|
||||
|
||||
/** Narrowest the message may get next to a plugin panel before the panel collapses. */
|
||||
const MIN_READER_WIDTH = 480;
|
||||
|
||||
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -1106,10 +1124,17 @@ export function EmailViewer({
|
||||
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
|
||||
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
|
||||
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
|
||||
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
|
||||
const threadSummaryProps = useMemo(
|
||||
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }),
|
||||
[email?.threadId, mvConversationIds],
|
||||
);
|
||||
// Mountain View: which other messages of the conversation are unfolded. A
|
||||
// conversation opens with its unread messages unfolded, as Gmail does, so
|
||||
// reading the thread reads all of them.
|
||||
const [mvExpandedIds, setMvExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const mvExpandedIdsRef = useRef(mvExpandedIds);
|
||||
mvExpandedIdsRef.current = mvExpandedIds;
|
||||
const mvExpandedThreadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!mvConversation || mvConversation.length === 0 || !email) {
|
||||
@@ -1122,6 +1147,7 @@ export function EmailViewer({
|
||||
setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id)));
|
||||
}, [mvConversation, email]);
|
||||
const toggleMvExpanded = useCallback((target: Email) => {
|
||||
emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
|
||||
setMvExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(target.id)) next.delete(target.id);
|
||||
@@ -1133,6 +1159,12 @@ export function EmailViewer({
|
||||
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
|
||||
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
|
||||
const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed;
|
||||
// Folding or unfolding the open message of a conversation, told to plugins
|
||||
// like any other conversation message (onThreadExpand).
|
||||
const setMvOpenFolded = (folded: boolean) => {
|
||||
if (email) emitThreadExpand(email.threadId, [email], !folded);
|
||||
setMvOpenCollapsed(folded);
|
||||
};
|
||||
// The conversation is not here yet (the one on hand is another thread's, or
|
||||
// none). Hold the message back instead of drawing it alone and then pushing
|
||||
// it down under the earlier messages.
|
||||
@@ -1390,6 +1422,22 @@ export function EmailViewer({
|
||||
// Whether any plugin offers a "more details" section, so we only render the
|
||||
// bottom plugin category wrapper when something will fill it.
|
||||
const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0;
|
||||
const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0;
|
||||
const hasHeaderOffers = usePluginSlotOffers('reader-header').length > 0;
|
||||
const hasBelowSubjectOffers = usePluginSlotOffers('reader-below-subject').length > 0;
|
||||
const hasAboveQuoteOffers = usePluginSlotOffers('reader-above-quote').length > 0;
|
||||
// One projected view per message for all plugin slots on it, built only
|
||||
// when a plugin is there to see it. A fresh object on every render made
|
||||
// each slot post its props to the plugin again and re-run shouldShow.
|
||||
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
|
||||
|| hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
|
||||
const pluginEmailView = useMemo(
|
||||
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
|
||||
[email, wantsPluginEmailView],
|
||||
);
|
||||
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
|
||||
@@ -1402,6 +1450,29 @@ export function EmailViewer({
|
||||
} catch { return 280; }
|
||||
});
|
||||
const detailSidebarWidthRef = useRef(detailSidebarWidth);
|
||||
// The panel takes its width from the message. When that would leave the
|
||||
// message too narrow to read (three panes on a laptop), the panel starts
|
||||
// collapsed; opening it anyway is still the user's call.
|
||||
// A callback ref: the root only exists once a message is open (the empty
|
||||
// and loading states render other markup), so the observer has to attach
|
||||
// when the element appears, not when the plugin offer does.
|
||||
const [viewerRootEl, setViewerRootEl] = useState<HTMLDivElement | null>(null);
|
||||
const [viewerWidth, setViewerWidth] = useState(0);
|
||||
const [detailSidebarForcedOpen, setDetailSidebarForcedOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!hasDetailSidebar || !viewerRootEl || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const width = Math.round(entries[0]?.contentRect.width ?? 0);
|
||||
setViewerWidth(width);
|
||||
});
|
||||
ro.observe(viewerRootEl);
|
||||
return () => ro.disconnect();
|
||||
}, [hasDetailSidebar, viewerRootEl]);
|
||||
const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH;
|
||||
const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen);
|
||||
// Opened by hand while the message is too narrow for it: the panel lies over
|
||||
// the message instead of squeezing it.
|
||||
const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
|
||||
@@ -2725,6 +2796,15 @@ export function EmailViewer({
|
||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||
// it doesn't flash in below a still-resizing iframe.
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
// One object per body: React writes innerHTML again whenever this object
|
||||
// changes, which would drop what was mounted in the body and fold the quote
|
||||
// back up on every render.
|
||||
const plainBodyHtml = useMemo(
|
||||
() => ({ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }),
|
||||
[effectiveEmailContent.html],
|
||||
);
|
||||
// Tracks which parsed document we've already wired up, so setup runs exactly
|
||||
// once per srcDoc even though both the readiness poll below and the iframe
|
||||
// 'load' event can trigger it.
|
||||
@@ -2747,6 +2827,30 @@ export function EmailViewer({
|
||||
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
|
||||
}, [emailIframeSrcDoc]);
|
||||
|
||||
// onEmailContentRender: once per opened message, after its body is on
|
||||
// screen (the iframe has loaded an HTML body; a text body renders at once).
|
||||
const contentRenderedForRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!email || isBodyLoading) return;
|
||||
if (effectiveEmailContent.isHtml && !iframeReady) return;
|
||||
if (contentRenderedForRef.current === email.id) return;
|
||||
contentRenderedForRef.current = email.id;
|
||||
if (emailHooks.onEmailContentRender.size === 0) return;
|
||||
let hasExternalContent = hasBlockedContent;
|
||||
if (!hasExternalContent && effectiveEmailContent.isHtml) {
|
||||
// Loaded rather than blocked (allowed by the user or the policy): look
|
||||
// at what the rendered body actually references.
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) =>
|
||||
['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr))));
|
||||
}
|
||||
void emailHooks.onEmailContentRender.emit({
|
||||
emailId: email.id,
|
||||
hasHtml: effectiveEmailContent.isHtml,
|
||||
hasExternalContent,
|
||||
});
|
||||
}, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]);
|
||||
|
||||
// Open a blob: link the rendered body points at a cid: part. Mirrors the
|
||||
// attachment-chip gate: inert previewable types open in a tab, everything
|
||||
// else (and any blob: URL we did not mint) is downloaded under its part name.
|
||||
@@ -2872,7 +2976,17 @@ export function EmailViewer({
|
||||
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
|
||||
if (!targetEl) return;
|
||||
const href = targetEl.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
|
||||
if (!href || href.startsWith('#')) return;
|
||||
if (/^mailto:/i.test(href)) {
|
||||
// Write it here, like any other address in the app; the mail page
|
||||
// asks onMailtoIntercept first. Not taken (no recipient, nobody
|
||||
// listening): the browser's own handling stays.
|
||||
if (requestInternalMailto(href)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
// A link at a cid: part now carries the blob: URL we minted for it.
|
||||
@@ -2887,14 +3001,16 @@ export function EmailViewer({
|
||||
// data:, unresolved cid: and relative paths would land inside our
|
||||
// own origin.
|
||||
if (!isOpenableLinkHref(href)) return;
|
||||
const ctx = {
|
||||
const ctx: ExternalLinkContext = {
|
||||
href,
|
||||
target: targetEl.getAttribute('target') ?? undefined,
|
||||
emailId: email?.id,
|
||||
};
|
||||
const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
|
||||
if (!ok || !isOpenableLinkHref(ctx.href)) return;
|
||||
window.open(ctx.href, '_blank', 'noopener,noreferrer');
|
||||
// A plugin returns false to stop the link or { href } to send it
|
||||
// somewhere else (a web address only).
|
||||
const target = await resolveExternalLink(ctx);
|
||||
if (!target) return;
|
||||
window.open(target, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
doc.addEventListener('click', onLinkClick, true);
|
||||
|
||||
@@ -3430,6 +3546,15 @@ export function EmailViewer({
|
||||
</Button>
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
{/* Phones get these in the More panel. */}
|
||||
{!isMobile && email && (
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
@@ -3973,6 +4098,7 @@ export function EmailViewer({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setViewerRootEl}
|
||||
data-tour="email-viewer"
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
@@ -4174,6 +4300,14 @@ export function EmailViewer({
|
||||
{t('keyboard_shortcuts')}
|
||||
</button>
|
||||
)}
|
||||
{email && (
|
||||
<PluginPanelEntries
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||
@@ -4246,6 +4380,8 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* === SUBJECT BLOCK === */}
|
||||
<div className={cn(
|
||||
"bg-background",
|
||||
@@ -4377,6 +4513,11 @@ export function EmailViewer({
|
||||
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
|
||||
<div className="min-h-full flex flex-col">
|
||||
|
||||
{/* Above the messages of the open conversation. */}
|
||||
{mvOpenIndex >= 0 && (
|
||||
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
|
||||
)}
|
||||
|
||||
{/* Mountain View: the conversation's earlier messages, folded. */}
|
||||
{mvBefore.length > 0 && (
|
||||
<div data-mv-conversation-before="">
|
||||
@@ -4387,7 +4528,7 @@ export function EmailViewer({
|
||||
{/* Mountain View: the open message folded like the rest of the conversation. */}
|
||||
{mvOpenFolded && email ? (
|
||||
<div data-mv-open-folded="">
|
||||
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
</div>
|
||||
) : mvAwaitingConversation ? (
|
||||
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
|
||||
@@ -4409,7 +4550,7 @@ export function EmailViewer({
|
||||
// message, as Gmail does; its buttons and links keep their own job.
|
||||
if (isInteractiveClick(e.target, e.currentTarget)) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
setMvOpenCollapsed(true);
|
||||
setMvOpenFolded(true);
|
||||
} : undefined}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
|
||||
@@ -4561,7 +4702,7 @@ export function EmailViewer({
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||
@@ -4599,7 +4740,7 @@ export function EmailViewer({
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
@@ -4618,22 +4759,11 @@ export function EmailViewer({
|
||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -4657,7 +4787,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
@@ -4674,7 +4804,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -4691,22 +4821,11 @@ export function EmailViewer({
|
||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -4727,7 +4846,7 @@ export function EmailViewer({
|
||||
onClick={mvOpenIndex >= 0 ? (e) => {
|
||||
if (isInteractiveClick(e.target, e.currentTarget)) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
setMvOpenCollapsed(true);
|
||||
setMvOpenFolded(true);
|
||||
} : undefined}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
@@ -4827,6 +4946,8 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginSlot name="reader-below-subject" className="w-full bg-background" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* Expandable Details (shared across mobile/tablet/desktop) */}
|
||||
{showFullHeaders && (() => {
|
||||
const translateAuthResult = (result?: string) => {
|
||||
@@ -4917,20 +5038,13 @@ export function EmailViewer({
|
||||
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
|
||||
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
|
||||
|
||||
// Projected, read-only view handed to plugins that render in the
|
||||
// "more details" panel. Includes the parsed `headers` map and full
|
||||
// `source` so plugins can inspect raw headers / message source.
|
||||
// Built lazily here - only when the details panel is expanded.
|
||||
const detailsView = emailToReadView(email);
|
||||
// Lets a plugin add rows under an existing category. The plugin's
|
||||
// own `shouldShow({ email, category })` decides which category it
|
||||
// appears under (or `category === null` for the new bottom section).
|
||||
const CategorySlot = ({ category }: { category: string }) => (
|
||||
<PluginSlot
|
||||
name="email-details-section"
|
||||
className="mt-2 empty:mt-0"
|
||||
extraProps={{ email: detailsView, category }}
|
||||
/>
|
||||
// A component defined here would be a new type on every render and
|
||||
// remount the plugin's iframe each time, so it lives outside.
|
||||
const categorySlot = (category: string) => (
|
||||
<DetailsCategorySlot email={pluginEmailView} category={category} />
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -5004,7 +5118,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</Row>
|
||||
</dl>
|
||||
<CategorySlot category="recipients_routing" />
|
||||
{categorySlot("recipients_routing")}
|
||||
</section>
|
||||
|
||||
{hasAuthSection && (
|
||||
@@ -5090,7 +5204,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CategorySlot category="authentication_security" />
|
||||
{categorySlot("authentication_security")}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -5125,7 +5239,7 @@ export function EmailViewer({
|
||||
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
|
||||
)}
|
||||
</dl>
|
||||
<CategorySlot category="identifiers_threading" />
|
||||
{categorySlot("identifiers_threading")}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -5153,7 +5267,7 @@ export function EmailViewer({
|
||||
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
||||
)}
|
||||
</dl>
|
||||
<CategorySlot category="message_properties" />
|
||||
{categorySlot("message_properties")}
|
||||
</section>
|
||||
|
||||
{hasListInfo && (
|
||||
@@ -5179,7 +5293,7 @@ export function EmailViewer({
|
||||
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
||||
)}
|
||||
</dl>
|
||||
<CategorySlot category="mailing_list" />
|
||||
{categorySlot("mailing_list")}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -5187,10 +5301,7 @@ export function EmailViewer({
|
||||
`category === null` render their own titled section here. */}
|
||||
{hasDetailsSlotOffers && (
|
||||
<section className="lg:col-span-2 min-w-0">
|
||||
<PluginSlot
|
||||
name="email-details-section"
|
||||
extraProps={{ email: detailsView, category: null }}
|
||||
/>
|
||||
<DetailsCategorySlot email={pluginEmailView} category={null} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -5373,7 +5484,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="email-banner" extraProps={{ email: emailToReadView(email) }} />
|
||||
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
|
||||
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
|
||||
@@ -5425,7 +5536,7 @@ export function EmailViewer({
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
|
||||
@@ -5468,7 +5579,7 @@ export function EmailViewer({
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
|
||||
"absolute rounded-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
|
||||
)}>
|
||||
<button
|
||||
@@ -5487,22 +5598,11 @@ export function EmailViewer({
|
||||
<Eye className="w-4 h-4 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -5526,7 +5626,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
@@ -5543,7 +5643,7 @@ export function EmailViewer({
|
||||
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -5560,22 +5660,11 @@ export function EmailViewer({
|
||||
<Eye className="w-4 h-4 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -5597,7 +5686,7 @@ export function EmailViewer({
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
|
||||
@@ -5633,9 +5722,11 @@ export function EmailViewer({
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
{/* Phones have no hover: the plugin menu stays visible. */}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
|
||||
)}>
|
||||
<button
|
||||
@@ -5654,22 +5745,10 @@ export function EmailViewer({
|
||||
<Eye className="w-4 h-4 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -5692,7 +5771,7 @@ export function EmailViewer({
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (
|
||||
{(dragProps) => (<>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||
@@ -5709,7 +5788,8 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={t('download')}
|
||||
@@ -5726,22 +5806,10 @@ export function EmailViewer({
|
||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<PluginSlot
|
||||
name="attachment-actions"
|
||||
className="contents"
|
||||
extraProps={{
|
||||
attachment: {
|
||||
name: attachment.name || '',
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
blobId: attachment.blobId,
|
||||
emailId: email?.id,
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
);
|
||||
})}
|
||||
@@ -5758,7 +5826,7 @@ export function EmailViewer({
|
||||
|
||||
{/* Email Body */}
|
||||
<div className={cn(
|
||||
"email-content-wrapper overflow-x-auto",
|
||||
"email-content-wrapper overflow-x-auto relative",
|
||||
// Folded, the body stays mounted so unfolding does not reload it.
|
||||
(mvOpenFolded || mvAwaitingConversation) && "hidden",
|
||||
// Mountain View indents the body under the sender's name, as Gmail does.
|
||||
@@ -5790,9 +5858,10 @@ export function EmailViewer({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={setPlainBodyEl}
|
||||
className="email-content-text text-foreground"
|
||||
dir="auto"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
|
||||
dangerouslySetInnerHTML={plainBodyHtml}
|
||||
style={{
|
||||
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
|
||||
fontSize: '14px',
|
||||
@@ -5802,9 +5871,17 @@ export function EmailViewer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isBodyLoading && (
|
||||
<ReaderAboveQuoteSlot
|
||||
root={effectiveEmailContent.isHtml ? (iframeReady ? iframeRef.current?.contentDocument?.body ?? null : null) : plainBodyEl}
|
||||
frame={effectiveEmailContent.isHtml ? iframeRef.current : null}
|
||||
extraProps={pluginEmailProps}
|
||||
version={effectiveEmailContent.html}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PluginSlot name="email-footer" />
|
||||
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
|
||||
|
||||
{/* Mountain View: the conversation's later messages, folded. */}
|
||||
{mvAfter.length > 0 && (
|
||||
@@ -6063,47 +6140,61 @@ export function EmailViewer({
|
||||
{hasDetailSidebar && !isMobile && (
|
||||
<>
|
||||
{/* Collapse toggle when sidebar is collapsed */}
|
||||
{detailSidebarCollapsed && (
|
||||
{sidebarCollapsed && (
|
||||
<div className="flex flex-col items-center border-s border-border bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDetailSidebarCollapsed(false)}
|
||||
onClick={() => {
|
||||
setDetailSidebarCollapsed(false);
|
||||
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
|
||||
}}
|
||||
className="h-8 w-8 m-1"
|
||||
aria-label="Expand panel"
|
||||
aria-label={t('plugin_panel_show')}
|
||||
>
|
||||
<PanelRightOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!detailSidebarCollapsed && (
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||
onResize={(delta) => {
|
||||
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
|
||||
setDetailSidebarWidth(newWidth);
|
||||
}}
|
||||
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||
onDoubleClick={() => setDetailSidebarWidth(280)}
|
||||
/>
|
||||
{!sidebarOverlay && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||
onResize={(delta) => {
|
||||
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
|
||||
setDetailSidebarWidth(newWidth);
|
||||
}}
|
||||
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
|
||||
onDoubleClick={() => setDetailSidebarWidth(280)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col h-full border-s border-border bg-background overflow-hidden"
|
||||
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
|
||||
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
|
||||
className={cn(
|
||||
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
|
||||
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
|
||||
)}
|
||||
style={sidebarOverlay
|
||||
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
|
||||
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
|
||||
>
|
||||
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDetailSidebarCollapsed(true)}
|
||||
onClick={() => {
|
||||
setDetailSidebarCollapsed(true);
|
||||
setDetailSidebarForcedOpen(false);
|
||||
}}
|
||||
className="h-7 w-7"
|
||||
aria-label="Collapse panel"
|
||||
aria-label={t('plugin_panel_hide')}
|
||||
>
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -6139,10 +6230,10 @@ export function EmailViewer({
|
||||
}} : {}),
|
||||
};
|
||||
if (client && supportsSync) {
|
||||
createContact(client, contactData).then(() => toast.success('Contact added'));
|
||||
createContact(client, contactData).then((created) => { if (created) toast.success('Contact added'); });
|
||||
} else {
|
||||
addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
toast.success('Contact added');
|
||||
void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
|
||||
.then((created) => { if (created) toast.success('Contact added'); });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { cn, type MailboxNode } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface MvListToolbarProps {
|
||||
/** Loaded conversation count, for the right-hand range readout. */
|
||||
@@ -606,6 +607,13 @@ export function MvListToolbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={{ emailIds: selectedEmails.map((email) => email.id) }}
|
||||
mailboxRole={currentRole ?? null}
|
||||
compact
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="ms-2 text-sm text-muted-foreground truncate">
|
||||
{tBatch("selected_messages", { count: selectionCount })}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"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 { 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, 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;
|
||||
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) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user