fix(webmail): Undo send really undoes, and a send is not made twice
The countdown sent from inside a state updater and Undo's button submitted the form, so Undo stopped the timer and then started the send over. The timer is now held in a ref, Undo is type=button, and leaving the screen flushes a pending send once. Snippets drop a stylesheet the preview was cut inside of. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2080489319
commit
897f0122f4
5 files changed
+97
-12
No files matched your search
@@ -87,6 +87,11 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
// way when that happens would otherwise land afterwards and bring the draft
|
||||
// back, so every save checks this before and after it talks to the server.
|
||||
const discardedRef = useRef(false);
|
||||
// The send waiting out its ten seconds, so Undo can stop it and leaving the
|
||||
// screen cannot lose it.
|
||||
const pendingSend = useRef<{ timer: ReturnType<typeof setInterval>; html: string } | null>(null);
|
||||
const sendRef = useRef<(html: string) => Promise<void>>(async () => undefined);
|
||||
const unmounted = useRef(false);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
// The body, mirrored into React state.
|
||||
@@ -98,6 +103,21 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
// that is why a draft left behind by a sent message was empty.
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
// Leaving with a send still counting down sends it now. Dropping it would lose
|
||||
// a message the writer was told was on its way; Undo is the way to cancel.
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
const pending = pendingSend.current;
|
||||
if (pending) {
|
||||
clearInterval(pending.timer);
|
||||
pendingSend.current = null;
|
||||
void sendRef.current(pending.html);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// An inline reply is opened to be written in, so the cursor starts in it.
|
||||
useEffect(() => {
|
||||
if (inline) editorRef.current?.focus();
|
||||
@@ -290,16 +310,24 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
|
||||
const bodyHtml = editorRef.current?.innerHTML || body;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setUndoSeconds((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(interval);
|
||||
void sendActualMessage(bodyHtml);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
// The countdown is counted here and not inside a state updater. The send
|
||||
// used to be a side effect of that updater, which React may run twice (the
|
||||
// message went out twice) and which Undo could not stop: Undo only cleared
|
||||
// the number, and the next tick - finding it empty - sent the message
|
||||
// anyway.
|
||||
let remaining = 10;
|
||||
const timer = setInterval(() => {
|
||||
remaining -= 1;
|
||||
if (remaining > 0) {
|
||||
setUndoSeconds(remaining);
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
pendingSend.current = null;
|
||||
setUndoSeconds(null);
|
||||
void sendRef.current(bodyHtml);
|
||||
}, 1000);
|
||||
pendingSend.current = { timer, html: bodyHtml };
|
||||
};
|
||||
|
||||
const sendActualMessage = async (htmlBody: string) => {
|
||||
@@ -329,14 +357,22 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
onSent();
|
||||
// A flush after leaving must not drag the writer back to Sent.
|
||||
if (!unmounted.current) onSent();
|
||||
} catch {
|
||||
setSendError(t("errors.serverError"));
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
sendRef.current = sendActualMessage;
|
||||
|
||||
const handleUndo = () => {
|
||||
// Stops the timer itself: clearing the number on screen is not enough.
|
||||
if (pendingSend.current) {
|
||||
clearInterval(pendingSend.current.timer);
|
||||
pendingSend.current = null;
|
||||
}
|
||||
setUndoSeconds(null);
|
||||
setSending(false);
|
||||
};
|
||||
@@ -412,7 +448,9 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
||||
remaining) (9s remaining)". */}
|
||||
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={handleUndo}>
|
||||
{/* type="button": inside this form a button submits it, so Undo used to
|
||||
stop the countdown and then send the message again. */}
|
||||
<Button type="button" variant="primary" size="sm" onClick={handleUndo}>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
{t("mail.undoSendAction")}
|
||||
</Button>
|
||||
|
||||
@@ -119,3 +119,32 @@ describe("discarding a draft", () => {
|
||||
expect(compose).toMatch(/discardedRef\.current && draftUidRef\.current > 0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoing a send", () => {
|
||||
// The countdown sent the message from inside a state updater. Undo cleared the
|
||||
// number on screen but not the timer, whose next tick found it empty and sent
|
||||
// the message anyway - so "Undo" undid nothing. React may also run an updater
|
||||
// twice, which sent it twice.
|
||||
it("does not send from inside a state updater", () => {
|
||||
const compose = code(COMPOSE);
|
||||
expect(compose).not.toMatch(/setUndoSeconds\(\(prev\)/);
|
||||
});
|
||||
|
||||
it("stops the timer when the writer undoes", () => {
|
||||
const compose = code(COMPOSE);
|
||||
expect(compose).toMatch(/const handleUndo = \(\) => \{[\s\S]*?clearInterval\(pendingSend\.current\.timer\)/);
|
||||
});
|
||||
|
||||
// A button inside a form submits it unless it says otherwise. Undo stopped the
|
||||
// timer and then submitted the form, which started the send over.
|
||||
it("is a button that does not submit the form", () => {
|
||||
const compose = code(COMPOSE);
|
||||
expect(compose).toMatch(/<Button type="button" variant="primary" size="sm" onClick=\{handleUndo\}>/);
|
||||
});
|
||||
|
||||
it("sends a message still counting down when the screen is left", () => {
|
||||
const compose = code(COMPOSE);
|
||||
expect(compose).toMatch(/sendRef\.current\(pending\.html\)/);
|
||||
expect(compose).toMatch(/if \(!unmounted\.current\) onSent\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,10 @@ export function plainSnippet(raw: string | null | undefined): string {
|
||||
const withoutTags = raw
|
||||
// A block boundary is a space, or "Hello,</p><p>You" would read "Hello,You".
|
||||
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
||||
// A stylesheet or script is not text. The preview is cut at a fixed
|
||||
// length, so the closing tag may be missing: then everything after the
|
||||
// opening one is the rule set, and all of it goes.
|
||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?(?:<\/\1>|$)/gi, " ")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
// The server cuts the preview at a fixed length, which can leave the start
|
||||
// of a tag with no end ("...open</p").
|
||||
|
||||
@@ -19,6 +19,10 @@ describe("plainSnippet", () => {
|
||||
it("drops a tag the preview was cut in the middle of", () => {
|
||||
expect(plainSnippet("<p>Open cyber model access</p")).toBe("Open cyber model access");
|
||||
});
|
||||
it("drops a stylesheet the preview was cut inside of", () => {
|
||||
expect(plainSnippet("<style>.card{border:2px solid #0a7;padding:16px")).toBe("");
|
||||
expect(plainSnippet("<style>.a{color:red}</style><p>Hello")).toBe("Hello");
|
||||
});
|
||||
it("keeps an entity it does not know", () => {
|
||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user