diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts new file mode 100644 index 000000000..861d3b8e9 --- /dev/null +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -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, + 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, + personaMentions: Map, +): { 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 }; +} diff --git a/desktop/src/features/messages/lib/useDraftMentionRouting.ts b/desktop/src/features/messages/lib/useDraftMentionRouting.ts new file mode 100644 index 000000000..d8e375e87 --- /dev/null +++ b/desktop/src/features/messages/lib/useDraftMentionRouting.ts @@ -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>; + personaMentionMapRef: React.MutableRefObject>; + selectedAgentNamesRef: React.MutableRefObject; + 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 }; +} diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index ba8dd3db6..49de2c98f 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 293eefef7..5e756f66d 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -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, ), [], ); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 07fe7debe..ba6d06e06 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -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([]); + const selectedAgentMentionNamesRef = React.useRef([]); + selectedAgentMentionNamesRef.current = selectedAgentMentionNames; const mentionMapRef = React.useRef>(new Map()); const personaMentionMapRef = React.useRef>(new Map()); const previousSuggestionsRef = React.useRef([]); - 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(() => { const candidatesByPubkey = new Map(); @@ -514,13 +505,11 @@ export function useMentions( return names; }, [selectedAgentMentionNames]); - /** Lower-cased searchable names, used for case-insensitive prefix matching. */ const searchableNamesLower = React.useMemo( () => searchableNames.map((n) => n.toLowerCase()), [searchableNames], ); - // --- Debounce infrastructure for updateMentionQuery --- const debounceTimerRef = React.useRef | null>( null, ); @@ -529,12 +518,10 @@ export function useMentions( const flushedMentionStartIndexRef = React.useRef(null); const searchableNamesLowerRef = React.useRef(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), diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index bd190394c..8c0c57404 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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]); diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index ba4c1a22e..f6bfff384 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -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(); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index d1b0f70f6..e2c4134bd 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -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), ); } };