feat: P2.1 extended signatures — multiple per identity + TipTap editor

- New stores/signature-store.ts: Zustand persist with CRUD, default/reply
  signature IDs, per-identity signature mapping
- New signature-settings.tsx: list management with add/edit/delete/duplicate
- New signature-editor-modal.tsx: TipTap rich text editor for signatures
- email-composer.tsx: auto-insert signature based on mode (compose/reply)
  + signature selector dropdown in toolbar
- identity-form.tsx: per-identity default/reply signature dropdowns
- settings/page.tsx: Signatures tab in Mail settings group
This commit is contained in:
Bernd Rodler
2026-08-07 12:55:14 +02:00
parent f34537adcf
commit 67f61f18d0
6 changed files with 975 additions and 3 deletions
+100 -3
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -24,6 +24,7 @@ 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 { useSignatureStore } from "@/stores/signature-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
@@ -298,6 +299,34 @@ export function EmailComposer({
const { isFeatureEnabled } = usePolicyStore();
const templatesEnabled = isFeatureEnabled('templatesEnabled');
const {
signatures,
defaultSignatureId,
replySignatureId,
getSignatureById,
getIdentityDefaultSignatureId,
getIdentityReplySignatureId,
} = useSignatureStore();
const resolveStoreSignatureId = (): string | null => {
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
if (mode === 'compose') {
if (perIdentityId) {
const id = getIdentityDefaultSignatureId(perIdentityId);
if (id) return id;
}
return defaultSignatureId;
}
if (perIdentityId) {
const id = getIdentityReplySignatureId(perIdentityId);
if (id) return id;
}
return replySignatureId ?? defaultSignatureId;
};
const [selectedSignatureId, setSelectedSignatureId] = useState<string | null>(resolveStoreSignatureId);
const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null;
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
// below, but uses initialData (or primary) since selectedIdentityId state
@@ -592,6 +621,7 @@ export function EmailComposer({
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
const editorRef = useRef<Editor | null>(null);
const [editorReady, setEditorReady] = useState(false);
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
@@ -668,6 +698,28 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
const sigInsertedRef = useRef(false);
useEffect(() => {
if (plainTextMode) return;
const editor = editorRef.current;
if (!editor) return;
if (!selectedSignatureId) return;
if (sigInsertedRef.current) return;
const sig = getSignatureById(selectedSignatureId);
if (!sig) return;
const currentHtml = serializeEditorContent(editor);
if (currentHtml.includes(sig.body)) {
sigInsertedRef.current = true;
return;
}
sigInsertedRef.current = true;
if (mode === 'compose') {
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
editor.chain().focus('start').insertContent(sig.body).run();
}
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
@@ -2498,7 +2550,7 @@ export function EmailComposer({
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
onEditorReady={(ed) => { editorRef.current = ed; }}
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
/>
</div>
)}
@@ -2657,8 +2709,53 @@ export function EmailComposer({
<PluginSlot name="composer-toolbar" />
</div>
{/* Right side - Discard + Send (desktop) */}
{/* Right side - Signature selector + Discard + Send (desktop) */}
<div className="flex items-center gap-2">
{signatures.length > 0 && (
<div className="relative hidden md:inline-flex">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (!editorRef.current) return;
const sig = selectedSignature;
if (sig) {
editorRef.current.chain().focus().insertContent(sig.body).run();
}
}}
title={t('insert_signature')}
className="h-8 px-2 text-xs gap-1"
disabled={!selectedSignature}
>
<PenLine className="w-4 h-4" />
{selectedSignature?.name ?? t('no_signature')}
</Button>
<select
value={selectedSignatureId ?? ''}
onChange={(e) => {
const id = e.target.value;
setSelectedSignatureId(id || null);
if (id && editorRef.current) {
const sig = getSignatureById(id);
if (sig) {
editorRef.current.chain().focus().insertContent(sig.body).run();
}
}
}}
className="absolute inset-0 opacity-0 cursor-pointer"
title={t('select_signature')}
aria-label={t('select_signature')}
>
<option value="">{t('no_signature')}</option>
{signatures.map((sig) => (
<option key={sig.id} value={sig.id}>
{sig.name}
</option>
))}
</select>
</div>
)}
<button
type="button"
onClick={handleClose}