diff options
| author | Adam Soltys <[email protected]> | 2026-03-07 08:54:47 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-07 08:54:47 -0800 |
| commit | ad0b4009b2b2ae9173fffb41f0cfa7b35edcb303 (patch) | |
| tree | 49c2eceb273b73ce5b9c0aba74dafbf6fddce442 | |
| parent | 13b2a17e6f6ed1697c51a6650874313a6da4aa8f (diff) | |
| download | coinos-ui-ad0b4009b2b2ae9173fffb41f0cfa7b35edcb303.tar.gz coinos-ui-ad0b4009b2b2ae9173fffb41f0cfa7b35edcb303.zip | |
image upload
| -rw-r--r-- | src/lib/messaging.ts | 1 | ||||
| -rw-r--r-- | src/lib/mls-relay.ts | 60 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[id]/+page.svelte | 80 |
3 files changed, 141 insertions, 0 deletions
diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index ba2d21ad..4cebd2b0 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -12,6 +12,7 @@ export { getGroups, getGroupRumours, sendGroupMessage, + sendGroupMediaMessage, findOrCreateDmGroup, subscribeToMessages, fetchGroupHistory, diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts index 5eb8dbe3..d29cd535 100644 --- a/src/lib/mls-relay.ts +++ b/src/lib/mls-relay.ts @@ -10,8 +10,12 @@ 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 { BlossomClient } from "nostr-tools/nipb7"; +import { ensureSigner } from "$lib/nip07"; import { post } from "$lib/utils"; +const BLOSSOM_SERVER = "https://blossom.primal.net"; + const pool = new SimplePool(); /** Extract member pubkeys from an MLS group's ratchet tree (pass group.state). */ @@ -412,6 +416,62 @@ export async function sendGroupMessage( cacheGroupRumour(groupId, rumour); } +/** Send a media message (image/video/audio) to a group. */ +export async function sendGroupMediaMessage( + user: any, + groupId: string, + file: File | Blob, +): Promise<void> { + const client = getMarmotClient(user.pubkey); + const group = await client.getGroup(groupId); + + const mime = file.type || "application/octet-stream"; + const filename = file instanceof File ? file.name : "file"; + + // Encrypt with MLS group key + const { encrypted, attachment } = await group.encryptMedia(file, { + type: mime, + filename, + }); + + // Upload encrypted blob to blossom + const sk = await ensureSigner(user.pubkey); + const signer = sk + ? { + getPublicKey: () => user.pubkey, + signEvent: async (draft: any) => { + const { finalizeEvent } = await import("nostr-tools/pure"); + return finalizeEvent(draft, sk); + }, + } + : (window as any).nostr; + + const blossom = new BlossomClient(BLOSSOM_SERVER, signer); + const encryptedBlob = new Blob([encrypted as BlobPart], { type: "application/octet-stream" }); + const desc = await blossom.uploadBlob(encryptedBlob); + + // Build imeta tag with the upload URL and MIP-04 fields + const imetaTag = [ + "imeta", + `url ${desc.url}`, + `m ${attachment.type}`, + `x ${attachment.sha256}`, + `filename ${attachment.filename}`, + `n ${attachment.nonce}`, + `v ${attachment.version}`, + ]; + if (attachment.size) imetaTag.push(`size ${attachment.size}`); + + // Pre-cache plaintext so the image renders instantly for us + await putCachedMedia(attachment.sha256, file, mime); + + // Send as chat message with the imeta tag + await group.sendChatMessage("", [imetaTag]); + + const rumour = makeRumour("", user.pubkey, groupId, [imetaTag]); + cacheGroupRumour(groupId, rumour); +} + // --------------------------------------------------------------------------- // Dynamic group subscription // --------------------------------------------------------------------------- diff --git a/src/routes/(app)/messages/[id]/+page.svelte b/src/routes/(app)/messages/[id]/+page.svelte index f9aea4da..a5768df7 100644 --- a/src/routes/(app)/messages/[id]/+page.svelte +++ b/src/routes/(app)/messages/[id]/+page.svelte @@ -1,11 +1,13 @@ <script lang="ts"> import PhPaperPlaneRightFill from "virtual:icons/ph/paper-plane-right-fill"; + import PhPlusBold from "virtual:icons/ph/plus-bold"; import { tick, onMount, onDestroy } from "svelte"; import { page } from "$app/state"; import { goto } from "$app/navigation"; import { getGroups, getGroupRumours, sendGroupMessage, + sendGroupMediaMessage, subscribeToMessages, fetchGroupHistory, findOrCreateDmGroup, resolveUser, displayName, @@ -45,6 +47,7 @@ let messageRumours: Map<number, any[]> = $state(new Map()); let dates: number[] = $state([]); let sendError = $state(""); + let uploading = $state(false); let senderInfos = $state(new Map<string, any>()); const groupDisplayName = (g: GroupInfo | undefined): string => { @@ -223,6 +226,22 @@ } }; + const sendMedia = async (file: File) => { + sendError = ""; + uploading = true; + try { + await sendGroupMediaMessage(user, groupId, file); + updateMessages(); + await tick(); + scrollChat(); + } catch (e: any) { + console.error("Failed to send media:", e); + sendError = e.message || "Failed to send media"; + } finally { + uploading = false; + } + }; + let closeSub: (() => void) | undefined; onMount(() => { @@ -297,8 +316,32 @@ <div id="messages-end"></div> </div> + <input + type="file" + accept="image/*,video/*,audio/*" + id="media-upload" + hidden + onchange={(e) => { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; + if (file) sendMedia(file); + (e.currentTarget as HTMLInputElement).value = ""; + }} + /> + <div class="input-bar"> <div class="input-row"> + <button + class="attach-btn" + disabled={uploading || !groupId} + onclick={() => document.getElementById("media-upload")?.click()} + aria-label="Attach media" + > + {#if uploading} + <span class="spinner"></span> + {:else} + <PhPlusBold width="20" /> + {/if} + </button> <textarea id="message-contents" bind:value={text} @@ -521,4 +564,41 @@ opacity: 0.35; cursor: default; } + + .attach-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 9999px; + background: transparent; + color: inherit; + border: 1px solid rgba(128, 128, 128, 0.3); + cursor: pointer; + flex-shrink: 0; + opacity: 0.7; + } + + .attach-btn:hover { + opacity: 1; + } + + .attach-btn:disabled { + opacity: 0.35; + cursor: default; + } + + .spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(128, 128, 128, 0.3); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.6s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } </style> |
