aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
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 /src/components
parenta1400eea7b1bd9b7e66c0a698bfb02dc82eadb48 (diff)
downloadcoinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.tar.gz
coinos-ui-13b2a17e6f6ed1697c51a6650874313a6da4aa8f.zip
render decrypted images
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ChatMessage.svelte109
1 files changed, 98 insertions, 11 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;