an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { X, Loader2, Send, Paperclip } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useGlobal } from 'hooks/useGlobal';
|
|
|
|
export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string };
|
|
|
|
// Shared open/close handle. Set a draft to open the composer; null closes it.
|
|
export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE', null);
|
|
|
|
type Contact = { address: string; name: string };
|
|
|
|
// A recipient field with contact autocomplete on the last comma-separated segment.
|
|
const RecipientInput = ({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
autoFocus,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder: string;
|
|
autoFocus?: boolean;
|
|
}) => {
|
|
const client = useClient();
|
|
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const seg = value.split(',').pop()?.trim() ?? '';
|
|
|
|
useEffect(() => {
|
|
if (seg.length < 1) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
const t = setTimeout(() => {
|
|
client
|
|
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
|
|
.then(setSuggestions)
|
|
.catch(() => {});
|
|
}, 180);
|
|
return () => clearTimeout(t);
|
|
}, [seg]);
|
|
|
|
const pick = (address: string) => {
|
|
const segs = value.split(',');
|
|
segs[segs.length - 1] = ` ${address}`;
|
|
onChange(segs.join(',').replace(/^\s+/, '') + ', ');
|
|
setOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<input
|
|
autoFocus={autoFocus}
|
|
value={value}
|
|
onChange={(ev) => {
|
|
onChange(ev.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
placeholder={placeholder}
|
|
className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40"
|
|
/>
|
|
{open && suggestions.length > 0 && (
|
|
<div className="absolute left-0 right-0 top-full z-10 mt-0.5 max-h-56 overflow-y-auto rounded-md border bg-background shadow-lg">
|
|
{suggestions.map((s) => (
|
|
<button
|
|
key={s.address}
|
|
onMouseDown={(ev) => {
|
|
ev.preventDefault();
|
|
pick(s.address);
|
|
}}
|
|
className="flex w-full flex-col items-start px-3 py-1.5 text-left text-sm hover:bg-accent cursor-pointer"
|
|
>
|
|
{s.name && <span className="font-medium">{s.name}</span>}
|
|
<span className={s.name ? 'text-xs opacity-60' : ''}>{s.address}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
export const ComposeModal = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [draft, setDraft] = useComposer();
|
|
const [to, setTo] = useState('');
|
|
const [cc, setCc] = useState('');
|
|
const [showCc, setShowCc] = useState(false);
|
|
const [bcc, setBcc] = useState('');
|
|
const [showBcc, setShowBcc] = useState(false);
|
|
const [subject, setSubject] = useState('');
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [bodyEmpty, setBodyEmpty] = useState(true);
|
|
const [sending, setSending] = useState(false);
|
|
const seeded = useRef<ComposeDraft | null>(null);
|
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
|
const inlineMap = useRef(new Map<string, File>()); // data-inline-id → File, for the images embedded in the body
|
|
const nextImgId = useRef(0);
|
|
|
|
const refreshEmpty = () => {
|
|
const el = editorRef.current;
|
|
setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img')));
|
|
};
|
|
|
|
// Free object URLs for any images still in the editor, and clear the id→File map.
|
|
const revokeInline = () => {
|
|
editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => {
|
|
const src = (img as HTMLImageElement).src;
|
|
if (src.startsWith('blob:')) URL.revokeObjectURL(src);
|
|
});
|
|
inlineMap.current.clear();
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (draft && draft !== seeded.current) {
|
|
seeded.current = draft;
|
|
setTo(draft.to ?? '');
|
|
setCc(draft.cc ?? '');
|
|
setShowCc(!!draft.cc);
|
|
setBcc('');
|
|
setShowBcc(false);
|
|
setSubject(draft.subject ?? '');
|
|
setFiles([]);
|
|
inlineMap.current.clear();
|
|
nextImgId.current = 0;
|
|
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
|
if (editorRef.current)
|
|
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
|
refreshEmpty();
|
|
}
|
|
if (!draft) seeded.current = null;
|
|
}, [draft]);
|
|
|
|
// Clipboard images often come nameless — give them a sensible filename.
|
|
const named = (f: File) =>
|
|
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
|
|
|
|
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
|
const addAttachments = (incoming: FileList | File[]) => {
|
|
const list = Array.from(incoming).map(named);
|
|
if (list.length) setFiles((prev) => [...prev, ...list]);
|
|
};
|
|
|
|
// Insert an image into the body at the given range (or the current caret, or the end).
|
|
const insertImage = (file: File, at?: Range | null) => {
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
const id = `img-${nextImgId.current++}`;
|
|
inlineMap.current.set(id, file);
|
|
const img = document.createElement('img');
|
|
img.src = URL.createObjectURL(file);
|
|
img.setAttribute('data-inline-id', id);
|
|
img.style.maxWidth = '100%';
|
|
img.style.height = 'auto';
|
|
|
|
const sel = window.getSelection();
|
|
const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null);
|
|
if (caret && el.contains(caret.startContainer)) {
|
|
caret.deleteContents();
|
|
caret.insertNode(img);
|
|
caret.setStartAfter(img);
|
|
caret.collapse(true);
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(caret);
|
|
} else {
|
|
el.appendChild(img);
|
|
}
|
|
refreshEmpty();
|
|
};
|
|
|
|
// Paste inside the body: images insert at the caret, other files attach, text pastes as plain text.
|
|
const onEditorPaste = (ev: React.ClipboardEvent) => {
|
|
const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
|
|
if (fileItems.length) {
|
|
ev.preventDefault();
|
|
fileItems
|
|
.map((it) => it.getAsFile())
|
|
.filter((f): f is File => !!f)
|
|
.map(named)
|
|
.forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f])));
|
|
return;
|
|
}
|
|
const text = ev.clipboardData?.getData('text/plain');
|
|
if (text) {
|
|
ev.preventDefault();
|
|
document.execCommand('insertText', false, text);
|
|
}
|
|
};
|
|
|
|
const onDrop = (ev: React.DragEvent) => {
|
|
ev.preventDefault();
|
|
setDragging(false);
|
|
const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named);
|
|
if (!dropped.length) return;
|
|
// Place images at the drop point if it lands inside the body, otherwise append at the end.
|
|
const el = editorRef.current;
|
|
const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null;
|
|
const range = point && el?.contains(point.startContainer) ? point : null;
|
|
dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f])));
|
|
};
|
|
|
|
const close = () => {
|
|
revokeInline();
|
|
setDraft(null);
|
|
};
|
|
|
|
const send = async () => {
|
|
if (!to.trim()) {
|
|
toast.error('Add at least one recipient');
|
|
return;
|
|
}
|
|
setSending(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('to', to);
|
|
if (cc.trim()) fd.append('cc', cc);
|
|
if (bcc.trim()) fd.append('bcc', bcc);
|
|
fd.append('subject', subject);
|
|
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
|
files.forEach((f) => fd.append('files', f));
|
|
|
|
// Serialize the body. Clone it, rewrite each inline <img> to a `cid:inline-N` ref (backend
|
|
// matches by order), and collect those files. Plain text goes as the fallback part.
|
|
const el = editorRef.current;
|
|
const inlineFiles: File[] = [];
|
|
if (el) {
|
|
const clone = el.cloneNode(true) as HTMLElement;
|
|
clone.querySelectorAll('img[data-inline-id]').forEach((node) => {
|
|
const img = node as HTMLImageElement;
|
|
const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? '');
|
|
if (!file) return img.remove();
|
|
img.setAttribute('src', `cid:inline-${inlineFiles.length}`);
|
|
img.removeAttribute('data-inline-id');
|
|
inlineFiles.push(file);
|
|
});
|
|
fd.append('body', el.innerText);
|
|
if (inlineFiles.length) {
|
|
fd.append('html', clone.innerHTML);
|
|
inlineFiles.forEach((f) => fd.append('inline', f));
|
|
}
|
|
}
|
|
|
|
await client.post('/email/send', fd);
|
|
toast.success('Email sent');
|
|
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
|
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
|
close();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
const fmtSize = (n: number) =>
|
|
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
|
|
if (!draft) return null;
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && close()}>
|
|
<DialogContent
|
|
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
|
onDrop={onDrop}
|
|
onDragOver={(ev) => {
|
|
ev.preventDefault();
|
|
setDragging(true);
|
|
}}
|
|
onDragLeave={(ev) => {
|
|
if (ev.currentTarget === ev.target) setDragging(false);
|
|
}}
|
|
>
|
|
{dragging && (
|
|
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
|
|
Drop images to inline · other files attach
|
|
</div>
|
|
)}
|
|
<div className="flex items-center border-b px-4 py-2.5 pr-10">
|
|
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
|
|
</div>
|
|
|
|
<div className="flex items-center border-b">
|
|
<span className="pl-4 text-xs opacity-50">To</span>
|
|
<div className="flex-1">
|
|
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
|
|
</div>
|
|
<div className="flex items-center gap-2 px-3">
|
|
{!showCc && (
|
|
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
|
Cc
|
|
</button>
|
|
)}
|
|
{!showBcc && (
|
|
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
|
Bcc
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{showCc && (
|
|
<div className="flex items-center border-b">
|
|
<span className="pl-4 text-xs opacity-50">Cc</span>
|
|
<div className="flex-1">
|
|
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showBcc && (
|
|
<div className="flex items-center border-b">
|
|
<span className="pl-4 text-xs opacity-50">Bcc</span>
|
|
<div className="flex-1">
|
|
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<input
|
|
value={subject}
|
|
onChange={(ev) => setSubject(ev.target.value)}
|
|
placeholder="Subject"
|
|
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
|
/>
|
|
<div className="relative flex-1 overflow-hidden">
|
|
{bodyEmpty && (
|
|
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
|
|
)}
|
|
<div
|
|
ref={editorRef}
|
|
contentEditable
|
|
onInput={refreshEmpty}
|
|
onPaste={onEditorPaste}
|
|
className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
|
|
/>
|
|
</div>
|
|
|
|
{files.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
|
{files.map((f, i) => (
|
|
<span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
|
|
<Paperclip className="h-3 w-3 opacity-60" />
|
|
<span className="max-w-[16rem] truncate">{f.name}</span>
|
|
<span className="opacity-50">{fmtSize(f.size)}</span>
|
|
<button
|
|
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
|
className="opacity-50 hover:opacity-100 cursor-pointer"
|
|
title="Remove"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(ev) => {
|
|
if (ev.target.files?.length) addAttachments(ev.target.files);
|
|
ev.target.value = '';
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => fileInput.current?.click()}
|
|
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
|
title="Attach files"
|
|
>
|
|
<Paperclip className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={close}
|
|
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={send}
|
|
disabled={sending}
|
|
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
|
Send
|
|
</button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
|
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
|
// is a follow-up: store the Message-Id header on ingest.)
|
|
export const replyDraft = (m: {
|
|
from: string;
|
|
subject: string;
|
|
date: string;
|
|
text?: string;
|
|
snippet?: string;
|
|
}): ComposeDraft => {
|
|
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
|
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
|
const original = (m.text || m.snippet || '').trim();
|
|
const quoted = original
|
|
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
|
|
.split('\n')
|
|
.map((l) => `> ${l}`)
|
|
.join('\n')}`
|
|
: '';
|
|
return { to: addr, subject, body: quoted };
|
|
};
|