Persist mention routing in message drafts

Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-07-17 09:13:23 -04:00
parent 6df85bb3d6
commit faab914521
8 changed files with 444 additions and 29 deletions
@@ -0,0 +1,51 @@
import type { DraftMentionRef } from "./useDrafts";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { hasMention } from "./hasMention";
export function snapshotDraftMentionRefs(
content: string,
mentions: ReadonlyMap<string, string>,
selectedAgentNames: readonly string[],
): DraftMentionRef[] {
const agentNames = new Set(
selectedAgentNames.map((name) => name.trim().toLowerCase()),
);
return [...mentions.entries()]
.filter(([displayName]) => hasMention(content, displayName))
.map(([displayName, pubkey]) => ({
displayName,
pubkey: normalizePubkey(pubkey),
isAgent: agentNames.has(displayName.trim().toLowerCase()),
}));
}
function normalizeDraftMentionRefs(
refs: readonly DraftMentionRef[],
): DraftMentionRef[] {
const normalized: DraftMentionRef[] = [];
for (const ref of refs) {
const displayName = ref.displayName.trim();
const pubkey = normalizePubkey(ref.pubkey);
if (displayName && pubkey) {
normalized.push({ displayName, pubkey, isAgent: ref.isAgent });
}
}
return normalized;
}
export function replaceWithDraftMentionRefs(
refs: readonly DraftMentionRef[],
mentions: Map<string, string>,
personaMentions: Map<string, string>,
): { names: string[]; agentNames: string[] } {
mentions.clear();
personaMentions.clear();
const normalized = normalizeDraftMentionRefs(refs);
for (const ref of normalized) mentions.set(ref.displayName, ref.pubkey);
const names = normalized.map((ref) => ref.displayName);
const agentNames = normalized
.filter((ref) => ref.isAgent)
.map((ref) => ref.displayName);
return { names, agentNames };
}
@@ -0,0 +1,47 @@
import * as React from "react";
import type { DraftMentionRef } from "./useDrafts";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import {
replaceWithDraftMentionRefs,
snapshotDraftMentionRefs,
} from "./draftMentionRefs";
export function useDraftMentionRouting(params: {
mentionMapRef: React.MutableRefObject<Map<string, string>>;
personaMentionMapRef: React.MutableRefObject<Map<string, string>>;
selectedAgentNamesRef: React.MutableRefObject<string[]>;
cancelAutocomplete: () => void;
setSelectedNames: (names: string[]) => void;
setSelectedAgentNames: (names: string[]) => void;
}): {
getDraftMentionRefs: (content: string) => DraftMentionRef[];
restoreDraftMentionRefs: (refs: readonly DraftMentionRef[]) => void;
} {
const getDraftMentionRefs = React.useCallback(
(content: string) =>
snapshotDraftMentionRefs(
content,
params.mentionMapRef.current,
params.selectedAgentNamesRef.current,
),
[params.mentionMapRef, params.selectedAgentNamesRef],
);
const restoreDraftMentionRefs = React.useCallback(
(refs: readonly DraftMentionRef[]) => {
params.cancelAutocomplete();
const { names, agentNames } = replaceWithDraftMentionRefs(
refs,
params.mentionMapRef.current,
params.personaMentionMapRef.current,
);
trimMapToSize(params.mentionMapRef.current, 200);
params.selectedAgentNamesRef.current = agentNames;
params.setSelectedNames(names);
params.setSelectedAgentNames(agentNames);
},
[params],
);
return { getDraftMentionRefs, restoreDraftMentionRefs };
}
@@ -905,3 +905,83 @@ test("renameDraftEntry identical records: legacy removed, canonical kept, one no
"legacy key absent from localStorage",
);
});
test("persist_draft_round_trips_stable_mention_refs_across_restart", () => {
setup("mention-owner");
const mentionRefs = [
{
displayName: "Agent Ada",
pubkey: "abcdef1234",
isAgent: true,
},
{
displayName: "Pat Person",
pubkey: "987654fedc",
isAgent: false,
},
];
persistDraftEntry(
"chan-mentions",
"Hi @Agent Ada and @Pat Person",
"chan-mentions",
[],
[],
mentionRefs,
);
clearAllDrafts();
initDraftStore("mention-owner");
assert.deepEqual(loadDraftEntry("chan-mentions")?.mentionRefs, mentionRefs);
});
test("legacy_draft_without_mention_refs_migrates_to_empty_refs", () => {
const storage = installFreshLocalStorage();
clearAllDrafts();
const now = new Date().toISOString();
storage.setItem(
"buzz-drafts.v1:legacy-owner",
JSON.stringify({
"chan-legacy": {
content: "legacy @Ada",
selectionStart: 11,
selectionEnd: 11,
channelId: "chan-legacy",
createdAt: now,
updatedAt: now,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
}),
);
initDraftStore("legacy-owner");
assert.deepEqual(loadDraftEntry("chan-legacy")?.mentionRefs, []);
});
test("invalid_mention_ref_rejects_corrupt_draft", () => {
const storage = installFreshLocalStorage();
clearAllDrafts();
const now = new Date().toISOString();
storage.setItem(
"buzz-drafts.v1:corrupt-mention-owner",
JSON.stringify({
"chan-corrupt": {
content: "bad ref",
selectionStart: 7,
selectionEnd: 7,
channelId: "chan-corrupt",
createdAt: now,
updatedAt: now,
pendingImeta: [],
mentionRefs: [{ displayName: "Ada", pubkey: 123, isAgent: true }],
spoileredAttachmentUrls: [],
status: "active",
},
}),
);
initDraftStore("corrupt-mention-owner");
assert.equal(loadDraftEntry("chan-corrupt"), undefined);
});
@@ -39,6 +39,12 @@ function getStoreSnapshot(): number {
*/
export { subscribeToStore, getStoreSnapshot };
export type DraftMentionRef = {
displayName: string;
pubkey: string;
isAgent: boolean;
};
export type DraftState = {
content: string;
selectionStart: number;
@@ -56,6 +62,8 @@ export type DraftState = {
updatedAt: string;
/** Pasted/uploaded image attachments, preserved across channel-switch. */
pendingImeta: ImetaMedia[];
/** Stable identity references for autocomplete-selected mentions in content. */
mentionRefs?: DraftMentionRef[];
/** URLs of imeta attachments marked as spoilered. */
spoileredAttachmentUrls: string[];
/**
@@ -170,6 +178,25 @@ function isValidDraftState(v: unknown): v is DraftState {
) {
return false;
}
// Migration: drafts written before mention routing was persisted have no
// mentionRefs. Preserve them as ordinary drafts with no selected identities.
if (d.mentionRefs === undefined) {
(d as DraftState).mentionRefs = [];
} else if (
!Array.isArray(d.mentionRefs) ||
d.mentionRefs.some(
(ref) =>
typeof ref !== "object" ||
ref === null ||
typeof ref.displayName !== "string" ||
ref.displayName.trim().length === 0 ||
typeof ref.pubkey !== "string" ||
ref.pubkey.trim().length === 0 ||
typeof ref.isAgent !== "boolean",
)
) {
return false;
}
// Migration: entries written before the status field was introduced have no
// status field. Treat absent status as "active" to avoid data loss on the
// first run after the upgrade.
@@ -254,6 +281,7 @@ function draftStatesEqual(a: DraftState, b: DraftState): boolean {
a.updatedAt !== b.updatedAt ||
a.status !== b.status ||
a.pendingImeta.length !== b.pendingImeta.length ||
(a.mentionRefs?.length ?? 0) !== (b.mentionRefs?.length ?? 0) ||
a.spoileredAttachmentUrls.length !== b.spoileredAttachmentUrls.length
) {
return false;
@@ -278,6 +306,19 @@ function draftStatesEqual(a: DraftState, b: DraftState): boolean {
return false;
}
}
const aMentionRefs = a.mentionRefs ?? [];
const bMentionRefs = b.mentionRefs ?? [];
for (let i = 0; i < aMentionRefs.length; i++) {
const ar = aMentionRefs[i];
const br = bMentionRefs[i];
if (
ar.displayName !== br.displayName ||
ar.pubkey !== br.pubkey ||
ar.isAgent !== br.isAgent
) {
return false;
}
}
for (let i = 0; i < a.spoileredAttachmentUrls.length; i++) {
if (a.spoileredAttachmentUrls[i] !== b.spoileredAttachmentUrls[i]) {
return false;
@@ -345,6 +386,7 @@ export function persistDraftEntry(
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
mentionRefs: DraftMentionRef[] = [],
): void {
const hasContent = content.trim().length > 0 || pendingImeta.length > 0;
if (hasContent) {
@@ -359,6 +401,7 @@ export function persistDraftEntry(
createdAt: existing?.createdAt ?? now,
updatedAt: now,
pendingImeta,
mentionRefs,
spoileredAttachmentUrls,
status: "active",
});
@@ -457,6 +500,7 @@ export function useDrafts() {
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
mentionRefs: DraftMentionRef[] = [],
) =>
persistDraftEntry(
draftKey,
@@ -464,6 +508,7 @@ export function useDrafts() {
channelId,
pendingImeta,
spoileredAttachmentUrls,
mentionRefs,
),
[],
);
@@ -1,5 +1,4 @@
import * as React from "react";
import {
useManagedAgentsQuery,
usePersonasQuery,
@@ -37,6 +36,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { flushMentionDebounce } from "./flushMentionDebounce";
import { hasMention } from "./hasMention";
import { useDraftMentionRouting } from "./useDraftMentionRouting";
import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
import {
@@ -46,34 +46,26 @@ import {
type MentionCandidate,
mentionCandidateLabel,
} from "./mentionCandidates";
const MENTION_DEBOUNCE_MS = 120;
const MENTION_SUGGESTION_LIMIT = 50;
export type PersonaMentionTarget = {
displayName: string;
persona: AgentPersona;
};
type UseMentionsOptions = {
channelType?: ChannelType | null;
};
function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
@@ -81,7 +73,6 @@ function appendUniqueName(current: string[], name: string): string[] {
? current
: [...current, name];
}
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
@@ -96,10 +87,11 @@ export function useMentions(
>([]);
const [selectedAgentMentionNames, setSelectedAgentMentionNames] =
React.useState<string[]>([]);
const selectedAgentMentionNamesRef = React.useRef<string[]>([]);
selectedAgentMentionNamesRef.current = selectedAgentMentionNames;
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
const personaMentionMapRef = React.useRef<Map<string, string>>(new Map());
const previousSuggestionsRef = React.useRef<MentionSuggestion[]>([]);
void options?.channelType;
const mentionSearchQuery = mentionQuery?.trim() ?? "";
const canSearchGlobalPeople = mentionSearchQuery.length > 0;
@@ -245,7 +237,6 @@ export function useMentions(
new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))),
[members],
);
const mentionCandidates = React.useMemo<MentionCandidate[]>(() => {
const candidatesByPubkey = new Map<string, MentionCandidate>();
@@ -514,13 +505,11 @@ export function useMentions(
return names;
}, [selectedAgentMentionNames]);
/** Lower-cased searchable names, used for case-insensitive prefix matching. */
const searchableNamesLower = React.useMemo<string[]>(
() => searchableNames.map((n) => n.toLowerCase()),
[searchableNames],
);
// --- Debounce infrastructure for updateMentionQuery ---
const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -529,12 +518,10 @@ export function useMentions(
const flushedMentionStartIndexRef = React.useRef<number | null>(null);
const searchableNamesLowerRef = React.useRef<string[]>(searchableNamesLower);
// Keep the known-names ref in sync so the debounced callback never reads stale data.
React.useEffect(() => {
searchableNamesLowerRef.current = searchableNamesLower;
}, [searchableNamesLower]);
// Clean up any pending debounce timer on unmount.
React.useEffect(() => {
return () => {
if (debounceTimerRef.current !== null) {
@@ -622,7 +609,6 @@ export function useMentions(
const insertMention = React.useCallback(
(suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => {
// Cancel any pending debounced detection — user already selected
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -666,12 +652,14 @@ export function useMentions(
if (isAgentMention) {
setSelectedAgentMentionNames((current) => {
const known = new Set(current.map((name) => name.toLowerCase()));
return [
const next = [
...current,
...selectedMentions
.map((selected) => selected.displayName)
.filter((name) => !known.has(name.toLowerCase())),
];
selectedAgentMentionNamesRef.current = next;
return next;
});
}
trimMapToSize(mentions, 200);
@@ -707,9 +695,11 @@ export function useMentions(
);
if (options?.isAgent) {
setSelectedAgentMentionNames((current) =>
appendUniqueName(current, trimmedName),
);
setSelectedAgentMentionNames((current) => {
const next = appendUniqueName(current, trimmedName);
selectedAgentMentionNamesRef.current = next;
return next;
});
}
},
[],
@@ -798,7 +788,6 @@ export function useMentions(
}
}, MENTION_DEBOUNCE_MS);
},
// Stable: refs are used inside the timeout, so no reactive deps needed.
[],
);
@@ -881,10 +870,21 @@ export function useMentions(
cancelMentionAutocomplete();
mentionMapRef.current.clear();
personaMentionMapRef.current.clear();
selectedAgentMentionNamesRef.current = [];
setSelectedMentionNames([]);
setSelectedAgentMentionNames([]);
}, [cancelMentionAutocomplete]);
const { getDraftMentionRefs, restoreDraftMentionRefs } =
useDraftMentionRouting({
mentionMapRef,
personaMentionMapRef,
selectedAgentNamesRef: selectedAgentMentionNamesRef,
cancelAutocomplete: cancelMentionAutocomplete,
setSelectedNames: setSelectedMentionNames,
setSelectedAgentNames: setSelectedAgentMentionNames,
});
const handleMentionKeyDown = React.useCallback(
(
event: React.KeyboardEvent,
@@ -919,8 +919,6 @@ export function useMentions(
) {
event.preventDefault();
// If a debounce is pending, the suggestions array reflects a stale query.
// Flush: re-detect synchronously and re-derive the correct suggestion.
if (debounceTimerRef.current !== null) {
const flushed = flushMentionDebounce({
debounceTimerRef,
@@ -943,7 +941,6 @@ export function useMentions(
setMentionQuery(null);
return { handled: true };
}
// Plain `@` after flush intentionally falls through to existing suggestions.
}
return { handled: true, suggestion: suggestions[mentionSelectedIndex] };
@@ -975,6 +972,7 @@ export function useMentions(
clearMentions,
extractMentionPersonas,
extractMentionPubkeys,
getDraftMentionRefs,
getMentionDisplayName,
handleMentionKeyDown,
hasResolvedMembers: members !== undefined,
@@ -988,6 +986,7 @@ export function useMentions(
memberPubkeys,
mentionSelectedIndex,
registerMentionPubkey,
restoreDraftMentionRefs,
suggestions,
fetchMoreSuggestions,
hasMoreSuggestions: Boolean(userSearchQuery.hasNextPage),
@@ -247,6 +247,8 @@ function MessageComposerImpl({
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
setContent: (content) => {
@@ -261,12 +263,10 @@ function MessageComposerImpl({
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
}, [effectiveDraftKey]);
@@ -267,6 +267,17 @@ async function mountStrictMode(Comp) {
);
});
return {
rerender: async () => {
await act(async () => {
root.render(
React.createElement(
React.StrictMode,
null,
React.createElement(Comp),
),
);
});
},
unmount: async () => {
await act(async () => {
root.unmount();
@@ -321,6 +332,8 @@ test("strictmode_draft_restore_cleanup_preserves_images_via_production_hook", as
persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => {
persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls);
},
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: asyncState,
setPendingImeta: (imeta) => {
asyncState = imeta; // async — won't commit before StrictMode cleanup
@@ -374,6 +387,8 @@ test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => {
persistDraft: (key, content, channelId, pendingImeta, spoileredUrls) => {
persistDraftEntry(key, content, channelId, pendingImeta, spoileredUrls);
},
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: () => {},
@@ -400,3 +415,167 @@ test("strictmode_draft_no_draft_cleanup_persists_empty_imeta", async () => {
await handle.unmount();
});
test("draft_lifecycle_restores_and_repersists_mention_routing_refs", async () => {
const DRAFT_KEY = "chan-lifecycle-mentions";
const MENTION_REFS = [
{ displayName: "Agent Ada", pubkey: "abcdef1234", isAgent: true },
];
setupStore("pubkey-lifecycle-mentions");
persistDraftEntry(
DRAFT_KEY,
"hello @Agent Ada",
DRAFT_KEY,
[],
[],
MENTION_REFS,
);
let restoredRefs = null;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: (content) =>
content.includes("@Agent Ada") ? MENTION_REFS : [],
restoreMentionRefs: (refs) => {
restoredRefs = [...refs];
},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: () => {},
clearContent: () => {},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => "hello @Agent Ada",
});
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.deepEqual(restoredRefs, MENTION_REFS);
assert.deepEqual(loadDraftEntry(DRAFT_KEY)?.mentionRefs, MENTION_REFS);
await handle.unmount();
});
test("draft_lifecycle_switches_a_b_a_without_leaking_or_losing_mention_refs", async () => {
const REFS_A = [
{ displayName: "Agent Ada", pubkey: "aaaaaaaa", isAgent: true },
];
const REFS_B = [
{ displayName: "Person Bea", pubkey: "bbbbbbbb", isAgent: false },
];
setupStore("pubkey-switch-mentions");
persistDraftEntry("chan-a", "hello @Agent Ada", "chan-a", [], [], REFS_A);
persistDraftEntry("chan-b", "hello @Person Bea", "chan-b", [], [], REFS_B);
let draftKey = "chan-a";
let editorContent = "";
let activeRefs = [];
const restored = [];
const spoileredRef = { current: new Set() };
function HarnessComposer() {
useDraftPersistLifecycle({
effectiveDraftKey: draftKey,
channelId: draftKey,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: (content) =>
activeRefs.filter((ref) => content.includes(`@${ref.displayName}`)),
restoreMentionRefs: (refs) => {
activeRefs = [...refs];
restored.push(activeRefs);
},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
});
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.deepEqual(activeRefs, REFS_A);
draftKey = "chan-b";
await handle.rerender();
assert.deepEqual(activeRefs, REFS_B, "B replaces A refs rather than merging");
draftKey = "chan-a";
await handle.rerender();
assert.deepEqual(
activeRefs,
REFS_A,
"A refs survive a full A → B → A switch",
);
assert.deepEqual(loadDraftEntry("chan-a")?.mentionRefs, REFS_A);
assert.deepEqual(loadDraftEntry("chan-b")?.mentionRefs, REFS_B);
assert.equal(
restored.some((refs) => refs.length === 0),
false,
"saved channel switches never transiently restore empty routing refs",
);
await handle.unmount();
});
test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => {
const REFS_A = [
{ displayName: "Agent Ada", pubkey: "aaaaaaaa", isAgent: true },
];
setupStore("pubkey-empty-target-mentions");
persistDraftEntry("chan-a", "hello @Agent Ada", "chan-a", [], [], REFS_A);
let draftKey = "chan-a";
let editorContent = "";
let activeRefs = [];
const spoileredRef = { current: new Set() };
function HarnessComposer() {
useDraftPersistLifecycle({
effectiveDraftKey: draftKey,
channelId: draftKey,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: (content) =>
activeRefs.filter((ref) => content.includes(`@${ref.displayName}`)),
restoreMentionRefs: (refs) => {
activeRefs = [...refs];
},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
});
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.deepEqual(activeRefs, REFS_A);
draftKey = "chan-empty";
await handle.rerender();
assert.deepEqual(activeRefs, []);
assert.equal(editorContent, "");
assert.equal(loadDraftEntry("chan-empty"), undefined);
await handle.unmount();
});
@@ -1,7 +1,10 @@
import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { DraftState } from "@/features/messages/lib/useDrafts";
import type {
DraftMentionRef,
DraftState,
} from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
effectiveDraftKey: string | null | undefined;
@@ -15,7 +18,12 @@ type UseDraftPersistLifecycleParams = {
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
mentionRefs: DraftMentionRef[],
) => void;
/** Snapshot selected mention identities still present in current content. */
getMentionRefs: (content: string) => DraftMentionRef[];
/** Replace mention routing/highlight state when a draft is restored or cleared. */
restoreMentionRefs: (refs: readonly DraftMentionRef[]) => void;
/** Live `pendingImeta` from React state — used for render-time ref sync. */
livePendingImeta: ImetaMedia[];
/** Async setter for pendingImeta — called after the synchronous snapshot. */
@@ -68,6 +76,8 @@ export function useDraftPersistLifecycle({
channelId,
loadDraft,
persistDraft,
getMentionRefs,
restoreMentionRefs,
livePendingImeta,
setPendingImeta,
setContent,
@@ -92,6 +102,7 @@ export function useDraftPersistLifecycle({
const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined;
if (saved) {
setContent(saved.content);
restoreMentionRefs(saved.mentionRefs ?? []);
// Set the persist-snapshot ref SYNCHRONOUSLY before calling the async
// state setter, so the cleanup closure (which may fire before the state
// update commits in React StrictMode's simulate-unmount pass) reads the
@@ -101,6 +112,7 @@ export function useDraftPersistLifecycle({
setSpoileredAttachmentUrls(new Set(saved.spoileredAttachmentUrls));
} else {
clearContent();
restoreMentionRefs([]);
// Same synchronous snapshot on the empty path.
pendingImetaForPersistRef.current = [];
setPendingImeta([]);
@@ -109,12 +121,14 @@ export function useDraftPersistLifecycle({
return () => {
if (effectiveDraftKey) {
const content = syncComposerContentFromEditor();
persistDraft(
effectiveDraftKey,
syncComposerContentFromEditor(),
content,
channelId ?? effectiveDraftKey,
[...pendingImetaForPersistRef.current],
[...spoileredAttachmentUrlsRef.current],
getMentionRefs(content),
);
}
};