mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
6df85bb3d6
commit
faab914521
@@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user