aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-07 08:54:47 -0800
committerAdam Soltys <[email protected]>2026-03-07 08:54:47 -0800
commitad0b4009b2b2ae9173fffb41f0cfa7b35edcb303 (patch)
tree49c2eceb273b73ce5b9c0aba74dafbf6fddce442
parent13b2a17e6f6ed1697c51a6650874313a6da4aa8f (diff)
downloadcoinos-ui-ad0b4009b2b2ae9173fffb41f0cfa7b35edcb303.tar.gz
coinos-ui-ad0b4009b2b2ae9173fffb41f0cfa7b35edcb303.zip
image upload
-rw-r--r--src/lib/messaging.ts1
-rw-r--r--src/lib/mls-relay.ts60
-rw-r--r--src/routes/(app)/messages/[id]/+page.svelte80
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>