Skip to content
52 changes: 24 additions & 28 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
import { submitMessageEdit } from "./submitMessageEdit";
import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
import type { MessageComposerProps } from "./MessageComposer.types";
function MessageComposerImpl({
audienceContext = null,
Expand Down Expand Up @@ -100,11 +101,13 @@ function MessageComposerImpl({
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const deferredPreviewContent = React.useDeferredValue(previewContent);
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(deferredPreviewContent);
hasPendingSnapshots: hasPendingLinkPreviewSnapshots,
// Ref lets the submit guard block Enter/form/auto-submit until snapshots settle.
hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef,
} = useComposerLinkPreviews(previewContent, editTarget == null);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
Expand Down Expand Up @@ -198,6 +201,8 @@ function MessageComposerImpl({
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
// Sync lock: taken before any async send so rapid Enter can't double-submit.
const isSubmitLockedRef = React.useRef(false);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
Expand Down Expand Up @@ -562,7 +567,9 @@ function MessageComposerImpl({
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isSubmitLockedRef.current ||
isUploadingRef.current ||
hasPendingLinkPreviewSnapshotsRef.current ||
mentionSendFlow.isPreparingMentionSend
) {
return;
Expand All @@ -574,6 +581,7 @@ function MessageComposerImpl({
) {
return;
}
isSubmitLockedRef.current = true;
onPreparingMentionSendChange?.(true);
persistentMentionHydration.beginSubmit();
try {
Expand All @@ -594,6 +602,7 @@ function MessageComposerImpl({
audienceRevision: audienceScope ? persistentAudience.revision : null,
});
} finally {
isSubmitLockedRef.current = false;
persistentMentionHydration.endSubmit();
onPreparingMentionSendChange?.(false);
}
Expand All @@ -604,6 +613,7 @@ function MessageComposerImpl({
drafts.loadDraft,
emojiAutocomplete.clearEmojis,
getReadyLinkPreviewTags,
hasPendingLinkPreviewSnapshotsRef,
media.clearQueuedAttachments,
media.pendingImetaRef,
media.queuedAttachmentsRef,
Expand Down Expand Up @@ -654,15 +664,10 @@ function MessageComposerImpl({
// Clear the trigger BEFORE firing so any navigation from the send cannot
// loop back with the param still present.
onAutoSubmitCompleteRef.current?.();
// Defer by one macrotask so the draft-persist lifecycle effect (which runs
// synchronously after mount) has a chance to load the draft content into
// the Tiptap editor before we try to submit.
const timer = window.setTimeout(() => {
submitMessageRef.current();
}, 0);
return () => {
window.clearTimeout(timer);
};
return scheduleSettleGatedAutoSubmit({
isPending: () => hasPendingLinkPreviewSnapshotsRef.current,
submit: () => submitMessageRef.current(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount-only
const handleSubmit = React.useCallback(
Expand Down Expand Up @@ -802,23 +807,14 @@ function MessageComposerImpl({
});
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
composerDisabled ||
media.isUploading ||
mentionSendFlow.isPreparingMentionSend ||
(isContentEmpty &&
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0),
[
composerDisabled,
media.isUploading,
mentionSendFlow.isPreparingMentionSend,
isContentEmpty,
media.pendingImeta.length,
media.queuedAttachments.length,
],
);
const sendDisabled =
composerDisabled ||
media.isUploading ||
hasPendingLinkPreviewSnapshots ||
mentionSendFlow.isPreparingMentionSend ||
(isContentEmpty &&
media.pendingImeta.length === 0 &&
media.queuedAttachments.length === 0);
const handleCaptureSelection = React.useCallback(() => {}, []);

const handlePaperclipClick = React.useCallback(() => {
Expand Down
103 changes: 103 additions & 0 deletions desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Unit tests for `scheduleSettleGatedAutoSubmit` — the auto-submit scheduler
* that fires a ?autoSend draft submit exactly once, after link-preview settling
* finishes.
*
* Imports and exercises the ACTUAL source helper. Regression guard for the
* auto-send-drop blocker (PR #5245, Blocker A): a confirmed draft with a
* supported link is normally still settling at mount, so an immediate submit
* bails on the pending guard. The prior one-shot `setTimeout(0)` consumed the
* trigger and silently dropped the draft. The scheduler must instead poll while
* pending and submit exactly once when settling clears — never zero, never
* twice.
*
* A controllable fake timer drives the poll deterministically, so there is no
* real-time flakiness (the E2E form could not reliably send inside the ~350 ms
* window headless).
*/

import assert from "node:assert/strict";
import test from "node:test";
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit.ts";

// Minimal deterministic timer: records scheduled callbacks so the test can
// advance them one "tick" at a time and assert exact call counts.
function makeFakeTimers() {
const pending = new Map();
let nextId = 1;
return {
set(fn, _ms) {
const id = nextId++;
pending.set(id, fn);
return id;
},
clear(id) {
pending.delete(id);
},
// Fire the earliest-scheduled still-pending callback.
tick() {
const [id, fn] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
fn();
return true;
},
pendingCount() {
return pending.size;
},
};
}

test("submits once immediately when nothing is pending", () => {
const timers = makeFakeTimers();
let submits = 0;
scheduleSettleGatedAutoSubmit({
isPending: () => false,
submit: () => submits++,
timers,
});
timers.tick(); // fire the initial setTimeout(0)
assert.equal(submits, 1);
assert.equal(timers.pendingCount(), 0, "no retry should be scheduled");
});

test("waits while settling then submits exactly once (the drop-guard)", () => {
const timers = makeFakeTimers();
let submits = 0;
let pending = true; // still settling at mount
scheduleSettleGatedAutoSubmit({
isPending: () => pending,
submit: () => submits++,
timers,
});
timers.tick(); // initial attempt: pending → reschedules, does NOT submit
assert.equal(submits, 0, "must not send while a snapshot is still pending");
assert.equal(timers.pendingCount(), 1, "a retry must be scheduled");

timers.tick(); // retry: still pending
assert.equal(submits, 0);

pending = false; // settling finished
timers.tick(); // retry: fires the send
assert.equal(submits, 1, "must send exactly once after settling clears");
assert.equal(timers.pendingCount(), 0);
});

test("cleanup before settling finishes cancels the submit (no orphan send)", () => {
const timers = makeFakeTimers();
let submits = 0;
const cleanup = scheduleSettleGatedAutoSubmit({
isPending: () => true,
submit: () => submits++,
timers,
});
timers.tick(); // initial attempt reschedules a retry
assert.equal(timers.pendingCount(), 1);
cleanup(); // unmount
assert.equal(
timers.pendingCount(),
0,
"cleanup must clear the pending retry",
);
assert.equal(submits, 0);
});
45 changes: 45 additions & 0 deletions desktop/src/features/messages/ui/messageComposerAutoSubmit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A
// draft containing a supported link is normally still settling (350 ms
// debounce + metadata/upload) at mount, so a submit fired immediately bails on
// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the
// trigger and silently drop the draft; instead poll until settling finishes
// (bounded by the preview hook's own anti-trap cap) then submit exactly once.
// The `didSubmit` guard prevents a double fire, and the initial defer lets the
// draft-persist lifecycle effect load the draft into the editor first.
//
// Extracted from MessageComposer as a pure, timer-injectable helper so the
// retry/one-shot contract is unit-testable without mounting the composer.
export function scheduleSettleGatedAutoSubmit({
isPending,
submit,
retryDelayMs = 50,
timers = {
set: (fn: () => void, ms: number) => window.setTimeout(fn, ms),
clear: (id: number) => window.clearTimeout(id),
},
}: {
isPending: () => boolean;
submit: () => void;
retryDelayMs?: number;
timers?: {
set: (fn: () => void, ms: number) => number;
clear: (id: number) => void;
};
}): () => void {
let didSubmit = false;
let retryTimer = 0;
const attempt = () => {
if (didSubmit) return;
if (isPending()) {
retryTimer = timers.set(attempt, retryDelayMs);
return;
}
didSubmit = true;
submit();
};
const initialTimer = timers.set(attempt, 0);
return () => {
timers.clear(initialTimer);
timers.clear(retryTimer);
};
}
78 changes: 78 additions & 0 deletions desktop/src/features/messages/ui/selectSubmitTags.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Unit tests for `selectSubmitTags` — the pure selector that decides which
* link-preview snapshot tags a composer submit emits.
*
* These import and exercise the ACTUAL source helper (not a mirrored copy), so
* they fail if the submit-tag selection ever regresses.
*
* Regression guard for the "removed-URL tag leak" defect (PR #5245, Blocker B):
* a ready snapshot tag for URL A lingers in the tag map for the 350 ms
* debounce window after A is deleted from the draft. Submit must key off the
* LIVE hrefs in the content being sent — never that debounced set — so deleting
* A and immediately sending replacement text can never attach A's tag (and its
* media refs) to a body that no longer contains A.
*
* The E2E form of this test was flaky: sending inside the 350 ms window from a
* headless browser did not reliably fire a submit, so it could not isolate the
* leak. A pure unit test against the extracted selector is deterministic and
* targets the fix logic directly.
*/

import assert from "node:assert/strict";
import test from "node:test";
import { selectSubmitTags } from "./useComposerLinkPreviews.tsx";

const tagA = ["link-preview", "snapshot", "1", "https://a.example/x", "A"];
const tagB = ["link-preview", "snapshot", "1", "https://b.example/y", "B"];

test("emits the tag for a live href that has a ready snapshot", () => {
const tags = selectSubmitTags(
["https://a.example/x"],
{ "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [tagA]);
});

test("LEAK GUARD: a ready tag whose href is no longer live is NOT emitted", () => {
// A resolved (tag still cached), but A was deleted from the draft and the
// live content is now different — the debounced map still holds A's tag.
const tags = selectSubmitTags(
[], // live content no longer contains A
{ "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [], "removed URL A must never leak its snapshot tag");
});

test("LEAK GUARD: replacing A with a live B emits only B's tag, never A's", () => {
const tags = selectSubmitTags(
["https://b.example/y"], // A deleted, B is what's live now
{ "https://a.example/x": tagA, "https://b.example/y": tagB },
false,
);
assert.deepEqual(tags, [tagB]);
});

test("a live href with no ready tag is omitted (sends as a bare link)", () => {
const tags = selectSubmitTags(["https://a.example/x"], {}, false);
assert.deepEqual(tags, []);
});

test("preserves live href order for multiple ready tags", () => {
const tags = selectSubmitTags(
["https://a.example/x", "https://b.example/y"],
{ "https://b.example/y": tagB, "https://a.example/x": tagA },
false,
);
assert.deepEqual(tags, [tagA, tagB]);
});

test("suppressed emits only the 'none' marker, ignoring any ready tags", () => {
const tags = selectSubmitTags(
["https://a.example/x"],
{ "https://a.example/x": tagA },
true,
);
assert.deepEqual(tags, [["link-preview", "none"]]);
});
Loading
Loading