aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-07 08:41:23 -0800
committerAdam Soltys <[email protected]>2026-03-07 08:41:23 -0800
commit13b2a17e6f6ed1697c51a6650874313a6da4aa8f (patch)
tree44ba523709d52f17ca79cc501e684b0a67539dfc
parenta1400eea7b1bd9b7e66c0a698bfb02dc82eadb48 (diff)
downloadcoinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.tar.gz
coinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.zip
render decrypted images
-rw-r--r--src/components/ChatMessage.svelte109
-rw-r--r--src/lib/messaging.ts1
-rw-r--r--src/lib/mls-relay.ts75
-rw-r--r--src/routes/(app)/messages/[id]/+page.svelte3
-rw-r--r--test/e2e/blossom-image.spec.ts144
-rw-r--r--test/e2e/blossom-real.spec.ts81
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
+});