diff options
| author | Adam Soltys <[email protected]> | 2026-03-07 08:41:23 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-07 08:41:23 -0800 |
| commit | 13b2a17e6f6ed1697c51a6650874313a6da4aa8f (patch) | |
| tree | 44ba523709d52f17ca79cc501e684b0a67539dfc | |
| parent | a1400eea7b1bd9b7e66c0a698bfb02dc82eadb48 (diff) | |
| download | coinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.tar.gz coinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.zip | |
render decrypted images
| -rw-r--r-- | src/components/ChatMessage.svelte | 109 | ||||
| -rw-r--r-- | src/lib/messaging.ts | 1 | ||||
| -rw-r--r-- | src/lib/mls-relay.ts | 75 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[id]/+page.svelte | 3 | ||||
| -rw-r--r-- | test/e2e/blossom-image.spec.ts | 144 | ||||
| -rw-r--r-- | test/e2e/blossom-real.spec.ts | 81 |
6 files changed, 401 insertions, 12 deletions
diff --git a/src/components/ChatMessage.svelte b/src/components/ChatMessage.svelte index aeaac210..d28aefc4 100644 --- a/src/components/ChatMessage.svelte +++ b/src/components/ChatMessage.svelte @@ -1,14 +1,50 @@ <script lang="ts"> import { parseContent, isImage, isVideo, isAudio } from "$lib/parseContent"; - let { content, tags = [] }: { content: string; tags?: any[] } = $props(); + let { + content, + tags = [], + decryptMedia, + }: { + content: string; + tags?: any[]; + decryptMedia?: (tag: string[]) => Promise<string>; + } = $props(); - // Extract image URLs from imeta tags (NIP-92) - let imageUrls: string[] = $derived( + // Detect MIP-04 encrypted media (has "v mip04-v2" field) + let encryptedMedia = $derived( + tags.filter( + (t: string[]) => + t[0] === "imeta" && + t.some((v) => v === "v mip04-v2") && + t.some((v) => v.startsWith("url ")), + ), + ); + + // Plain (unencrypted) media from imeta tags + let plainMedia = $derived( tags - .filter((t: string[]) => t[0] === "imeta" && t.some((v) => v.startsWith("m image/"))) - .map((t: string[]) => t.find((v) => v.startsWith("url "))?.slice(4)) - .filter((url): url is string => !!url), + .filter( + (t: string[]) => + t[0] === "imeta" && + !t.some((v) => v === "v mip04-v2") && + t.some((v) => v.startsWith("url ")), + ) + .map((t: string[]) => ({ + url: t.find((v) => v.startsWith("url "))?.slice(4) || "", + mime: t.find((v) => v.startsWith("m "))?.slice(2) || "", + })) + .filter((m) => m.url), + ); + + // Set of all imeta URLs to suppress duplicate link rendering + let imetaUrls = $derived( + new Set([ + ...plainMedia.map((m) => m.url), + ...encryptedMedia + .map((t: string[]) => t.find((v) => v.startsWith("url "))?.slice(4) || "") + .filter(Boolean), + ]), ); let parts = $derived(parseContent({ content, tags })); @@ -28,12 +64,47 @@ } catch {} return `@${pubkey.slice(0, 8)}...`; }; + + const getMime = (tag: string[]): string => + tag.find((v) => v.startsWith("m "))?.slice(2) || "image/jpeg"; </script> -{#each imageUrls as url} - <a href={url} target="_blank" rel="noopener noreferrer"> - <img src={url} alt="" class="chat-img" /> - </a> +{#each encryptedMedia as tag} + {#if decryptMedia} + {@const mime = getMime(tag)} + {#await decryptMedia(tag)} + <div class="chat-img-placeholder"></div> + {:then blobUrl} + {#if mime.startsWith("image/")} + <a href={blobUrl} target="_blank" rel="noopener noreferrer"> + <img src={blobUrl} alt="" class="chat-img" /> + </a> + {:else if mime.startsWith("video/")} + <!-- svelte-ignore a11y_media_has_caption --> + <video src={blobUrl} controls class="chat-video"></video> + {:else if mime.startsWith("audio/")} + <audio src={blobUrl} controls></audio> + {/if} + {:catch} + {@const url = tag.find((v) => v.startsWith("url "))?.slice(4) || ""} + <a href={url} target="_blank" rel="noopener noreferrer" class="chat-link">{truncUrl(url)}</a> + {/await} + {/if} +{/each} + +{#each plainMedia as media} + {#if media.mime.startsWith("image/")} + <a href={media.url} target="_blank" rel="noopener noreferrer"> + <img src={media.url} alt="" class="chat-img" /> + </a> + {:else if media.mime.startsWith("video/")} + <!-- svelte-ignore a11y_media_has_caption --> + <video src={media.url} controls class="chat-video"></video> + {:else if media.mime.startsWith("audio/")} + <audio src={media.url} controls></audio> + {:else} + <a href={media.url} target="_blank" rel="noopener noreferrer" class="chat-link">{truncUrl(media.url)}</a> + {/if} {/each} {#each parts as { type, value }} @@ -42,7 +113,9 @@ {:else if type === "newline"} <br /> {:else if type === "link"} - {#if isImage(value.url)} + {#if imetaUrls.has(value.url)} + <!-- Already rendered as imeta media above --> + {:else if isImage(value.url)} <a href={value.url} target="_blank" rel="noopener noreferrer"> <img src={value.url} alt="" class="chat-img" /> </a> @@ -78,6 +151,20 @@ margin: 4px 0; } + .chat-img-placeholder { + width: 200px; + height: 150px; + border-radius: 8px; + margin: 4px 0; + background: var(--bg-secondary, #e5e7eb); + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.8; } + } + .chat-video { max-width: 100%; border-radius: 8px; diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index becc1714..ba2d21ad 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -15,6 +15,7 @@ export { findOrCreateDmGroup, subscribeToMessages, fetchGroupHistory, + decryptMediaUrl, } from "$lib/mls-relay"; export type { GroupInfo } from "$lib/mls-relay"; diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts index 8ab89e98..5eb8dbe3 100644 --- a/src/lib/mls-relay.ts +++ b/src/lib/mls-relay.ts @@ -9,6 +9,7 @@ import { nip19 } from "nostr-tools"; import { SimplePool } from "nostr-tools/pool"; import { Relay } from "nostr-tools/relay"; import { getMarmotClient, getInviteReader, DM_RELAYS } from "$lib/marmot"; +import { parseMediaImetaTag } from "@internet-privacy/marmot-ts"; import { post } from "$lib/utils"; const pool = new SimplePool(); @@ -53,11 +54,21 @@ export interface GroupInfo { // LocalStorage cache (per-user) // --------------------------------------------------------------------------- +const CACHE_VERSION = 2; // bump to clear stale caches (v1 lacked tags) + function loadLocalCache(userPubkey?: string): void { if (userPubkey) _cacheUserPubkey = userPubkey; const pk = _cacheUserPubkey; if (!pk) return; try { + const ver = localStorage.getItem(`mls:cacheVersion:${pk}`); + if (ver !== String(CACHE_VERSION)) { + // Stale cache — clear it so messages are re-fetched with current code + localStorage.removeItem(`mls:groupRumours:${pk}`); + localStorage.removeItem(`mls:processedEvents:${pk}`); + localStorage.setItem(`mls:cacheVersion:${pk}`, String(CACHE_VERSION)); + return; + } const cached = localStorage.getItem(`mls:groupRumours:${pk}`); if (cached) { const obj = JSON.parse(cached); @@ -598,6 +609,7 @@ async function handleGroupMessageEvent( content = rumor.content ?? raw; senderPubkey = rumor.pubkey ?? event.pubkey; if (Array.isArray(rumor.tags)) tags = rumor.tags; + console.log("[mls] decoded rumor:", { content: content?.slice(0, 100), tags, keys: Object.keys(rumor) }); } catch { content = raw; senderPubkey = event.pubkey; @@ -708,3 +720,66 @@ function cacheGroupRumour(groupId: string, rumour: any): void { groupRumourCache.set(groupId, existing); saveLocalCache(); } + +// --------------------------------------------------------------------------- +// MIP-04 media decryption +// --------------------------------------------------------------------------- + +const MEDIA_CACHE_NAME = "coinos-media-v1"; +const _mediaBlobCache = new Map<string, string>(); // sha256 → blob URL (in-memory) + +async function getCachedMedia(sha256: string, mime: string): Promise<string | null> { + if (_mediaBlobCache.has(sha256)) return _mediaBlobCache.get(sha256)!; + try { + const cache = await caches.open(MEDIA_CACHE_NAME); + const resp = await cache.match(`/media/${sha256}`); + if (!resp) return null; + const blob = await resp.blob(); + const blobUrl = URL.createObjectURL(blob); + _mediaBlobCache.set(sha256, blobUrl); + return blobUrl; + } catch { + return null; + } +} + +async function putCachedMedia(sha256: string, data: BlobPart, mime: string): Promise<string> { + const blob = new Blob([data], { type: mime }); + const blobUrl = URL.createObjectURL(blob); + _mediaBlobCache.set(sha256, blobUrl); + try { + const cache = await caches.open(MEDIA_CACHE_NAME); + await cache.put(`/media/${sha256}`, new Response(blob.slice())); + } catch {} + return blobUrl; +} + +/** + * Decrypt a MIP-04 encrypted media attachment and return a blob URL. + * Results are cached in the Cache API (persists across page loads). + */ +export async function decryptMediaUrl(groupId: string, tag: string[]): Promise<string> { + const attachment = parseMediaImetaTag(tag); + if (!attachment) throw new Error("Invalid MIP-04 imeta tag"); + + const cached = await getCachedMedia(attachment.sha256, attachment.type); + if (cached) return cached; + + const userPubkey = _cacheUserPubkey; + if (!userPubkey) throw new Error("No user pubkey"); + + const client = getMarmotClient(userPubkey); + const group = await client.getGroup(groupId); + + // Fetch encrypted blob from blossom + const url = attachment.url; + if (!url) throw new Error("No URL in attachment"); + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`); + const encryptedBytes = new Uint8Array(await resp.arrayBuffer()); + + // Decrypt using group's MLS epoch key + const { data } = await group.decryptMedia(encryptedBytes, attachment); + + return putCachedMedia(attachment.sha256, data as BlobPart, attachment.type); +} diff --git a/src/routes/(app)/messages/[id]/+page.svelte b/src/routes/(app)/messages/[id]/+page.svelte index c8ba8051..f9aea4da 100644 --- a/src/routes/(app)/messages/[id]/+page.svelte +++ b/src/routes/(app)/messages/[id]/+page.svelte @@ -9,6 +9,7 @@ subscribeToMessages, fetchGroupHistory, findOrCreateDmGroup, resolveUser, displayName, + decryptMediaUrl, } from "$lib/messaging"; import type { GroupInfo } from "$lib/messaging"; import { t } from "$lib/translations"; @@ -282,7 +283,7 @@ {#if j === 0} <div class="sender-name">{senderName(run.pubkey)}</div> {/if} - <ChatMessage content={rumour.content} tags={rumour.tags} /> + <ChatMessage content={rumour.content} tags={rumour.tags} decryptMedia={(tag) => decryptMediaUrl(groupId, tag)} /> <span class="timestamp secondary text-xs"> {TIME.format(new Date(rumour.created_at * 1000))} </span> diff --git a/test/e2e/blossom-image.spec.ts b/test/e2e/blossom-image.spec.ts new file mode 100644 index 00000000..f9c8a094 --- /dev/null +++ b/test/e2e/blossom-image.spec.ts @@ -0,0 +1,144 @@ +import { test, expect } from "@playwright/test"; + +// 1x1 red JPEG as base64 +const TINY_JPEG_B64 = + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS" + + "Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJ" + + "CQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy" + + "MjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/" + + "EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA//9k="; + +test.describe("Blossom image rendering", () => { + test("Chrome renders <img src> with application/octet-stream", async ({ page }) => { + await page.route("**/mock-blossom/**", async (route) => { + const jpegBytes = Buffer.from(TINY_JPEG_B64, "base64"); + await route.fulfill({ + status: 200, + contentType: "application/octet-stream", + body: jpegBytes, + }); + }); + + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + + const result = await page.evaluate(async () => { + const img = document.createElement("img"); + img.src = "/mock-blossom/test.jpg"; + document.body.prepend(img); + + await new Promise<void>((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); + setTimeout(resolve, 3000); + }); + + return { + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + }; + }); + + console.log("Direct <img src> with octet-stream:", result); + expect(result.naturalWidth, "Image should render despite octet-stream content-type").toBeGreaterThan(0); + }); + + test("ChatMessage component renders imeta images", async ({ page }) => { + // Mock blossom-like endpoint + await page.route("**/mock-blossom/**", async (route) => { + const jpegBytes = Buffer.from(TINY_JPEG_B64, "base64"); + await route.fulfill({ + status: 200, + contentType: "application/octet-stream", + body: jpegBytes, + }); + }); + + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + + // Inject a ChatMessage-like rendering with imeta tags + const result = await page.evaluate(async () => { + const container = document.createElement("div"); + container.id = "test-chat"; + document.body.prepend(container); + + // Simulate the imeta extraction logic from ChatMessage.svelte + const tags = [ + ["imeta", "url /mock-blossom/chat-image", "m image/jpeg", "x abc123"], + ]; + + const imetaImages = tags + .filter((t) => t[0] === "imeta" && t.some((v) => v.startsWith("m image/"))) + .map((t) => ({ + url: t.find((v) => v.startsWith("url "))?.slice(4) || "", + mime: t.find((v) => v.startsWith("m "))?.slice(2) || "image/jpeg", + })) + .filter((m) => m.url); + + // Render like the template does + for (const img of imetaImages) { + const a = document.createElement("a"); + a.href = img.url; + a.target = "_blank"; + const imgEl = document.createElement("img"); + imgEl.src = img.url; + imgEl.alt = ""; + imgEl.style.maxWidth = "100%"; + imgEl.style.borderRadius = "8px"; + a.appendChild(imgEl); + container.appendChild(a); + } + + // Wait for images to load + await new Promise((r) => setTimeout(r, 2000)); + + const imgs = container.querySelectorAll("img"); + const results: any[] = []; + for (const img of imgs) { + results.push({ + naturalWidth: (img as HTMLImageElement).naturalWidth, + naturalHeight: (img as HTMLImageElement).naturalHeight, + src: (img as HTMLImageElement).src.substring(0, 60), + }); + } + return { imageCount: imetaImages.length, rendered: results }; + }); + + console.log("ChatMessage rendering:", JSON.stringify(result, null, 2)); + expect(result.imageCount).toBe(1); + expect(result.rendered[0].naturalWidth).toBeGreaterThan(0); + }); + + test("imeta URL is suppressed from link rendering", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + + // Simulate the duplicate-suppression logic + const result = await page.evaluate(() => { + const tags = [ + ["imeta", "url https://blossom.example.com/abc123", "m image/jpeg"], + ]; + const content = "https://blossom.example.com/abc123"; + + // Extract imeta URLs + const imetaImages = tags + .filter((t) => t[0] === "imeta" && t.some((v) => v.startsWith("m image/"))) + .map((t) => ({ + url: t.find((v) => v.startsWith("url "))?.slice(4) || "", + })) + .filter((m) => m.url); + const imetaUrls = new Set(imetaImages.map((i) => i.url)); + + // Check if the content URL would be suppressed + return { + contentUrl: content, + imetaUrls: [...imetaUrls], + wouldSuppress: imetaUrls.has("https://blossom.example.com/abc123"), + }; + }); + + console.log("Duplicate suppression:", result); + expect(result.wouldSuppress).toBe(true); + }); +}); diff --git a/test/e2e/blossom-real.spec.ts b/test/e2e/blossom-real.spec.ts new file mode 100644 index 00000000..a2feff82 --- /dev/null +++ b/test/e2e/blossom-real.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; + +const BLOSSOM_URL = + "https://blossom.primal.net/7a2fd4e46f5054fa102c4db68f58c57e630c4f7fdfa06fa08a4b81b21741c541"; + +test("diagnose blossom image loading", async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + + const result = await page.evaluate(async (url) => { + const diag: any = {}; + + // Step 1: Fetch + try { + const r = await fetch(url); + diag.fetchOk = r.ok; + diag.fetchStatus = r.status; + diag.fetchContentType = r.headers.get("content-type"); + diag.fetchUrl = r.url; // final URL after redirects + + const blob = await r.blob(); + diag.blobSize = blob.size; + diag.blobType = blob.type; + + // Check first bytes (JPEG magic: FF D8 FF) + const arr = new Uint8Array(await blob.slice(0, 4).arrayBuffer()); + diag.magic = Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join(" "); + diag.isJpeg = arr[0] === 0xff && arr[1] === 0xd8 && arr[2] === 0xff; + + // Step 2: Re-type blob + const retyped = new Blob([blob], { type: "image/jpeg" }); + diag.retypedSize = retyped.size; + diag.retypedType = retyped.type; + + const blobUrl = URL.createObjectURL(retyped); + diag.blobUrl = blobUrl; + + // Step 3: Try to load as img + const img = document.createElement("img"); + img.src = blobUrl; + document.body.prepend(img); + + const imgResult = await new Promise<string>((resolve) => { + img.onload = () => resolve("loaded"); + img.onerror = () => resolve("error"); + setTimeout(() => resolve("timeout"), 10000); + }); + + diag.imgResult = imgResult; + diag.naturalWidth = img.naturalWidth; + diag.naturalHeight = img.naturalHeight; + + // Step 4: Also try data URL + const reader = new FileReader(); + const dataUrl = await new Promise<string>((resolve) => { + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(retyped); + }); + diag.dataUrlPrefix = dataUrl.substring(0, 40); + + const img2 = document.createElement("img"); + img2.src = dataUrl; + document.body.prepend(img2); + + const img2Result = await new Promise<string>((resolve) => { + img2.onload = () => resolve("loaded"); + img2.onerror = () => resolve("error"); + setTimeout(() => resolve("timeout"), 10000); + }); + diag.dataUrlResult = img2Result; + diag.dataUrlWidth = img2.naturalWidth; + } catch (e: any) { + diag.error = e.message; + } + + return diag; + }, BLOSSOM_URL); + + console.log("Blossom diagnosis:", JSON.stringify(result, null, 2)); + // Just log, don't assert +}); |
