fix(desktop): omit invalid snapshot imeta thumbnails (#1800)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 11:03:13 -04:00
committed by GitHub
parent 095f3c7280
commit b32f2c08f7
5 changed files with 58 additions and 65 deletions

View File

@@ -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.

View File

@@ -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 = [];

View File

@@ -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<boolean> {
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<boolean>;
/** 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<boolean> {
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),

View File

@@ -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(

View File

@@ -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,
};
}