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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user