From b32f2c08f7cdbad1bcc3b6a73711c2e7a21c71bd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 13 Jul 2026 11:03:13 -0400 Subject: [PATCH] fix(desktop): omit invalid snapshot imeta thumbnails (#1800) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../agents/ui/AgentSnapshotSendDialog.tsx | 3 -- .../agents/ui/agentSnapshotSend.test.mjs | 49 +++++++++++++++++++ .../agents/ui/useSnapshotSendController.ts | 21 ++------ .../src/shared/ui/markdownFileCard.test.mjs | 39 +-------------- desktop/src/shared/ui/markdownFileCard.ts | 11 ++--- 5 files changed, 58 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx index 3897ed8032..23794b0ab6 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotSendDialog.tsx @@ -171,9 +171,6 @@ export function AgentSnapshotSendDialog({ memorySourcePubkey: linkedAgentPubkey, }), destination.id, - // Pass the agent's avatar URL as the NIP-92 thumb hint so the recipient - // card can show the avatar instead of the generic Bot icon. - persona.avatarUrl ?? undefined, ); // Phase transitions (done/error) are driven by beginSend via setState // inside the controller; the mirror effect above keeps `step` in sync. diff --git a/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs index 1576a478aa..32670594cf 100644 --- a/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs +++ b/desktop/src/features/agents/ui/agentSnapshotSend.test.mjs @@ -15,6 +15,7 @@ import { recordTimeoutFromRejection, clearTimeoutState, } from "../../moderation/lib/timeoutStore.ts"; +import { buildOutgoingMessage } from "../../messages/lib/imetaMediaMarkdown.ts"; // ── isSendableDestination ───────────────────────────────────────────────────── @@ -403,6 +404,54 @@ test("runSendPipeline_checkpoint2_blocks_upload_after_encode", async () => { ); }); +test("runSendPipeline_avatar_bearing_snapshot_omits_thumb_and_keeps_filename", async () => { + let sentMediaTags = null; + + const result = await runSendPipeline({ + channelId: "ch-1", + checkEligibilityFn: () => null, + encodeFn: async () => ({ + fileBytes: [1], + fileName: "avatar-bearing.agent.json", + }), + uploadFn: async () => ({ + url: "https://example.com/avatar-bearing.agent.json", + sha256: "a".repeat(64), + size: 1, + type: "application/json", + uploaded: 0, + // This represents an upload descriptor that happens to include a thumb. + // A snapshot send must not serialize it as NIP-92 imeta metadata. + thumb: "https://example.com/avatar.png", + }), + sendFn: async ({ mediaTags }) => { + sentMediaTags = mediaTags; + }, + setStateFn: () => {}, + buildMessageFn: (descriptor) => buildOutgoingMessage("", [descriptor]), + }); + + assert.equal(result, true); + assert.deepEqual(sentMediaTags, [ + [ + "imeta", + "url https://example.com/avatar-bearing.agent.json", + "m application/json", + `x ${"a".repeat(64)}`, + "size 1", + "filename avatar-bearing.agent.json", + ], + ]); + assert.ok( + sentMediaTags[0].includes("filename avatar-bearing.agent.json"), + "snapshot imeta must retain its filename", + ); + assert.ok( + !sentMediaTags[0].some((entry) => entry.startsWith("thumb ")), + "snapshot imeta must not include a thumb field", + ); +}); + test("runSendPipeline_happy_path_sets_all_phases", async () => { // All checkpoints pass and encode/upload/send succeed — full phase sequence. const states = []; diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index cf9dff72a3..2a16312bef 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -212,10 +212,6 @@ export async function runSendPipeline(deps: { }; checkEligibilityFn: () => string | null; channelId: string; - /** Optional avatar URL emitted as the NIP-92 `thumb` field on the snapshot - * attachment. Decorative — lets the recipient card show the agent's avatar - * without trusting sender-supplied identity. */ - thumbUrl?: string; }): Promise { const { encodeFn, @@ -225,7 +221,6 @@ export async function runSendPipeline(deps: { buildMessageFn, checkEligibilityFn, channelId, - thumbUrl, } = deps; // ── Eligibility checkpoint 1: before encode ─────────────────────────────── @@ -284,13 +279,13 @@ export async function runSendPipeline(deps: { } // Preserve the original filename so `buildImetaTags` emits a `filename` - // field and the recipient's FileCard renders the correct label. - // Also carry the agent's avatar URL as the NIP-92 `thumb` field when - // the caller supplied one — decorative hint for the recipient card. + // field and the recipient's FileCard renders the correct label. Snapshot + // sends never emit `thumb`: NIP-92 requires it to be this upload's local + // thumbnail sidecar, which an agent avatar is not. + const { thumb: _thumb, ...descriptorWithoutThumb } = descriptor; const descriptorWithFilename: BlobDescriptor = { - ...descriptor, + ...descriptorWithoutThumb, filename: fileName, - ...(thumbUrl ? { thumb: thumbUrl } : {}), }; // ── Build message content + NIP-92 imeta tags ───────────────────────────── @@ -361,14 +356,10 @@ export type UseSnapshotSendControllerResult = { * false and sets error state if blocked, ineligible, or if any step fails. * Never throws. * - * @param thumbUrl Optional avatar URL emitted as the NIP-92 `thumb` imeta - * field on the snapshot attachment — decorative, lets the recipient card - * show the agent's avatar without any identity assertion. */ beginSend: ( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, channelId: string, - thumbUrl?: string, ) => Promise; /** Set state to error with a message (for pre-send gate failures). */ setErrorState: (message: string) => void; @@ -461,12 +452,10 @@ export function useSnapshotSendController(): UseSnapshotSendControllerResult { async function beginSend( encodeFn: () => Promise<{ fileBytes: number[]; fileName: string }>, channelId: string, - thumbUrl?: string, ): Promise { return runGuardedSend(guardRef.current, { encodeFn, channelId, - thumbUrl, // Eligibility is checked by reading directly from the React Query cache // and the timeout external store — not from rendered React state. checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), diff --git a/desktop/src/shared/ui/markdownFileCard.test.mjs b/desktop/src/shared/ui/markdownFileCard.test.mjs index 083f088781..285fae43de 100644 --- a/desktop/src/shared/ui/markdownFileCard.test.mjs +++ b/desktop/src/shared/ui/markdownFileCard.test.mjs @@ -225,44 +225,7 @@ test("resolveSnapshotCard: uppercase .AGENT.JSON classifies as snapshot card", ( assert.equal(card.sha256, SHA256); }); -// ── thumb plumbing ──────────────────────────────────────────────────────────── - -test("resolveSnapshotCard: thumb field from imeta is forwarded for .agent.json", () => { - const thumbUrl = "https://relay.example/avatar.png"; - const card = resolveSnapshotCard( - { - m: "application/json", - size: 500, - filename: "bot.agent.json", - x: SHA256, - thumb: thumbUrl, - }, - JSON_URL, - "", - ); - assert.ok(card !== null); - assert.equal(card.thumb, thumbUrl); -}); - -test("resolveSnapshotCard: relay-hosted JSON thumb is rewritten through media proxy", () => { - const thumbUrl = `https://relay.example/media/${"b".repeat(64)}.png`; - const card = resolveSnapshotCard( - { - m: "application/json", - size: 500, - filename: "bot.agent.json", - x: SHA256, - thumb: thumbUrl, - }, - JSON_URL, - "", - ); - assert.ok(card !== null); - assert.equal( - card.thumb, - `buzz-media://localhost/media/${"b".repeat(64)}.png`, - ); -}); +// ── snapshot card thumbnails ───────────────────────────────────────────────── test("resolveSnapshotCard: .agent.png uses its own URL as thumb", () => { const card = resolveSnapshotCard( diff --git a/desktop/src/shared/ui/markdownFileCard.ts b/desktop/src/shared/ui/markdownFileCard.ts index 83e8868cd7..b2461086d8 100644 --- a/desktop/src/shared/ui/markdownFileCard.ts +++ b/desktop/src/shared/ui/markdownFileCard.ts @@ -86,14 +86,9 @@ export function resolveSnapshotCard( size: entry.size, sha256, snapshotKind: "agent", - // For PNG snapshots, use the attachment URL as the thumb source (it IS - // the avatar card image). For JSON, use the explicit imeta `thumb` field - // if the sender included one. - thumb: isPng - ? rewriteRelayUrl(href) - : entry.thumb - ? rewriteRelayUrl(entry.thumb) - : undefined, + // PNG snapshots use the attachment URL as the thumb source because it is + // the avatar card image. JSON snapshots use the generic icon. + thumb: isPng ? rewriteRelayUrl(href) : undefined, }; }