fix: editable HTML signature in new mail; clean state on every compose entry #329
This commit is contained in:
@@ -608,6 +608,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCompose: () => {
|
onCompose: () => {
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -1219,6 +1220,15 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [tCommon]);
|
}, [tCommon]);
|
||||||
|
|
||||||
|
// Force a clean composer remount on every fresh entry point so prior
|
||||||
|
// compose state can't bleed into the new session (#329 C). The composer is
|
||||||
|
// keyed on composerSessionId, and pendingDraft would otherwise pin the
|
||||||
|
// composer to a stale draft from a discarded reply.
|
||||||
|
const startFreshComposerSession = useCallback(() => {
|
||||||
|
setComposerSessionId(id => id + 1);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReply = async (draftText?: string) => {
|
const handleReply = async (draftText?: string) => {
|
||||||
if (selectedEmail) {
|
if (selectedEmail) {
|
||||||
const ok = await emailHooks.onBeforeReply.intercept({
|
const ok = await emailHooks.onBeforeReply.intercept({
|
||||||
@@ -1231,6 +1241,7 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
setComposerQuoteHeader(null);
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerDraftText(draftText || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1340,6 +1351,7 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
setComposerQuoteHeader(null);
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -1357,6 +1369,7 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
setComposerQuoteHeader(null);
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -2278,6 +2291,7 @@ export default function Home() {
|
|||||||
const handleConversationReply = async (email: Email) => {
|
const handleConversationReply = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
await prepareComposerQuoteHeader(email, 'reply');
|
await prepareComposerQuoteHeader(email, 'reply');
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -2286,6 +2300,7 @@ export default function Home() {
|
|||||||
const handleConversationReplyAll = async (email: Email) => {
|
const handleConversationReplyAll = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
await prepareComposerQuoteHeader(email, 'replyAll');
|
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -2294,6 +2309,7 @@ export default function Home() {
|
|||||||
const handleConversationForward = async (email: Email) => {
|
const handleConversationForward = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
await prepareComposerQuoteHeader(email, 'forward');
|
await prepareComposerQuoteHeader(email, 'forward');
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -2421,6 +2437,7 @@ export default function Home() {
|
|||||||
onImportEmail={handleImportEmailFromContextMenu}
|
onImportEmail={handleImportEmailFromContextMenu}
|
||||||
onRefreshMailboxes={handleRefreshMailboxes}
|
onRefreshMailboxes={handleRefreshMailboxes}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -2805,6 +2822,7 @@ export default function Home() {
|
|||||||
{/* Floating Compose Button */}
|
{/* Floating Compose Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -3023,6 +3041,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCompose={() => {
|
onCompose={() => {
|
||||||
|
startFreshComposerSession();
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -228,10 +228,16 @@ export function EmailComposer({
|
|||||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||||
? initialCurrentIdentityForSig
|
? initialCurrentIdentityForSig
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||||
const shouldEmbedSignatureAboveQuote =
|
const shouldEmbedSignatureAboveQuote =
|
||||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
signaturePosition === 'above_quote' &&
|
signaturePosition === 'above_quote' &&
|
||||||
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
hasInitialSignature;
|
||||||
|
// New-mail composes always embed the signature into the editor body so it's
|
||||||
|
// editable/removable (the previous read-only preview below the editor was
|
||||||
|
// never spec-correct - see #329). Compose mode also ignores any leftover
|
||||||
|
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||||
|
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
@@ -272,6 +278,14 @@ export function EmailComposer({
|
|||||||
if (plainTextMode) {
|
if (plainTextMode) {
|
||||||
// Plain text mode: produce plain text body with no HTML
|
// Plain text mode: produce plain text body with no HTML
|
||||||
const prefix = initialDraftText || "";
|
const prefix = initialDraftText || "";
|
||||||
|
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
|
||||||
|
// viewer) and embed the signature directly into the body so it's
|
||||||
|
// editable. Fixes #329 (A,B).
|
||||||
|
if (mode === 'compose') {
|
||||||
|
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||||
|
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||||
|
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
|
||||||
|
}
|
||||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
@@ -305,6 +319,18 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||||
|
// Compose mode: ignore any leftover replyTo and embed the signature
|
||||||
|
// directly into the body so the user can edit/delete it. The leading
|
||||||
|
// empty paragraph gives the cursor a place to land above the signature.
|
||||||
|
if (mode === 'compose') {
|
||||||
|
if (!shouldEmbedSignatureInNewMail) return prefix;
|
||||||
|
const composePrefix = prefix || '<p></p>';
|
||||||
|
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||||
|
embed: true,
|
||||||
|
separator: signatureSeparatorEnabled,
|
||||||
|
});
|
||||||
|
return `${composePrefix}${embedded}`;
|
||||||
|
}
|
||||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
@@ -471,8 +497,11 @@ export function EmailComposer({
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (!identityChanged && !separatorChanged) return;
|
if (!identityChanged && !separatorChanged) return;
|
||||||
if (plainTextMode) return;
|
if (plainTextMode) return;
|
||||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
// Replies/forwards only embed when configured for "above quote". Compose
|
||||||
if (signaturePosition !== 'above_quote') return;
|
// always embeds (see getInitialBody), so swap on identity change there too.
|
||||||
|
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
|
||||||
|
if (!isReplyLike && mode !== 'compose') return;
|
||||||
|
if (isReplyLike && signaturePosition !== 'above_quote') return;
|
||||||
|
|
||||||
const currentHtml = editor.getHTML();
|
const currentHtml = editor.getHTML();
|
||||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||||
@@ -703,9 +732,16 @@ export function EmailComposer({
|
|||||||
// Ref to latest saveDraft for use in event handlers with stale closures
|
// Ref to latest saveDraft for use in event handlers with stale closures
|
||||||
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||||
|
|
||||||
|
// Set by the explicit close paths (clean close, save-and-close, discard) so
|
||||||
|
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
|
||||||
|
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
|
||||||
|
// open the next composer with the discarded reply's mode/replyTo.
|
||||||
|
const explicitCloseRef = useRef(false);
|
||||||
|
|
||||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (explicitCloseRef.current) return;
|
||||||
if (onSaveState && isDirtyRef.current) {
|
if (onSaveState && isDirtyRef.current) {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
onSaveState({
|
onSaveState({
|
||||||
@@ -1372,12 +1408,14 @@ export function EmailComposer({
|
|||||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||||
|
|
||||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||||
// When "above quote" mode is configured for replies/forwards, the signature
|
// The signature is embedded into the body during init for compose mode
|
||||||
// was embedded into the body during init (see getInitialBody) so the
|
// (when the initial identity had a signature) and for above-quote
|
||||||
// trailing append must be skipped to avoid duplicating it.
|
// replies/forwards - skip the trailing append in those cases so we don't
|
||||||
|
// duplicate it.
|
||||||
const signatureAlreadyInBody =
|
const signatureAlreadyInBody =
|
||||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
shouldEmbedSignatureInNewMail ||
|
||||||
signaturePosition === 'above_quote';
|
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||||
|
signaturePosition === 'above_quote');
|
||||||
|
|
||||||
// Build HTML signature block (used only in rich text mode)
|
// Build HTML signature block (used only in rich text mode)
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
@@ -1693,6 +1731,7 @@ export function EmailComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cleanClose = () => {
|
const cleanClose = () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -1701,6 +1740,7 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraftAndClose = async () => {
|
const handleSaveDraftAndClose = async () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
setShowCloseDialog(false);
|
setShowCloseDialog(false);
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -1711,6 +1751,7 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardAndClose = () => {
|
const handleDiscardAndClose = () => {
|
||||||
|
explicitCloseRef.current = true;
|
||||||
setShowCloseDialog(false);
|
setShowCloseDialog(false);
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
@@ -2107,8 +2148,9 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hide the visual signature preview when the signature has already been
|
{/* Hide the visual signature preview when the signature has already been
|
||||||
embedded into the body above the quote (otherwise it would appear twice). */}
|
embedded into the body (compose, or above-quote replies). */}
|
||||||
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
{(shouldEmbedSignatureInNewMail
|
||||||
|
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
|
||||||
: plainTextMode ? (
|
: plainTextMode ? (
|
||||||
getPlainTextSignature(signatureIdentity) ? (
|
getPlainTextSignature(signatureIdentity) ? (
|
||||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||||
|
|||||||
Reference in New Issue
Block a user