diff --git a/kez-chat/web/src/lib/attachment-store.ts b/kez-chat/web/src/lib/attachment-store.ts new file mode 100644 index 0000000..5c1abbc --- /dev/null +++ b/kez-chat/web/src/lib/attachment-store.ts @@ -0,0 +1,114 @@ +// Local attachment cache. +// +// Two stores in IndexedDB: +// +// `kez-chat:attachments:v1` — assembled (or inline) files, keyed +// by message_key (peer_primary + ":" + +// seq). The value is a data URL ready +// to slot straight into . +// `kez-chat:chunk-buffer:v1` — in-flight chunks for chunked files, +// keyed by file_id, value is +// {n, received: {[i]: Uint8Array}}. +// +// Why IDB instead of in-memory: a 10 MB image in memory is fine, but +// a chunked transfer that's only 50% complete when the user closes +// the tab should resume — IDB persists across reloads. + +import { get, set, del } from "idb-keyval"; + +import type { Identity } from "./kez.js"; + +const ATTACH_PREFIX = "kez-chat:attachments:v1:"; +const CHUNK_PREFIX = "kez-chat:chunk-buffer:v1:"; + +function attachKey(peer_primary: Identity, seq: number): string { + return `${ATTACH_PREFIX}${peer_primary}|${seq}`; +} + +function chunkKey(file_id: string): string { + return `${CHUNK_PREFIX}${file_id}`; +} + +// ─── assembled attachments ───────────────────────────────────────────────── + +export interface StoredAttachment { + filename: string; + mime: string; + /** data URL ready to render. For images, just plug into . */ + data_url: string; + /** Bytes of the original file. Useful for save-to-disk later. */ + size: number; +} + +export async function saveAttachment( + peer_primary: Identity, + seq: number, + att: StoredAttachment, +): Promise { + await set(attachKey(peer_primary, seq), att); +} + +export async function loadAttachment( + peer_primary: Identity, + seq: number, +): Promise { + return get(attachKey(peer_primary, seq)); +} + +export async function deleteAttachment( + peer_primary: Identity, + seq: number, +): Promise { + await del(attachKey(peer_primary, seq)); +} + +// ─── chunk buffer ────────────────────────────────────────────────────────── + +export interface ChunkBufferEntry { + /** Total expected chunks. */ + n: number; + /** Sparse list of received chunks, ordered by index i. Stored + * as bytes (not base64) so we don't pay encoding overhead + * every time we read the buffer. */ + received: Record; + /** When we first saw a chunk for this file. Buffers older than + * N days get GC'd by `cleanupStaleChunkBuffers`. */ + started_at: string; + /** Where to land the file once n/n chunks are in. Set when the + * matching pointer event arrives (which may come before or after + * any of the chunks). */ + destination?: { + peer_primary: Identity; + seq: number; + filename: string; + mime: string; + size: number; + }; +} + +export async function loadChunkBuffer( + file_id: string, +): Promise { + return get(chunkKey(file_id)); +} + +export async function saveChunkBuffer( + file_id: string, + entry: ChunkBufferEntry, +): Promise { + await set(chunkKey(file_id), entry); +} + +export async function deleteChunkBuffer(file_id: string): Promise { + await del(chunkKey(file_id)); +} + +/** Has every chunk arrived AND do we know where to put the result? */ +export function chunkBufferIsComplete(buf: ChunkBufferEntry): boolean { + if (!buf.destination) return false; + if (Object.keys(buf.received).length !== buf.n) return false; + for (let i = 0; i < buf.n; i++) { + if (!buf.received[i]) return false; + } + return true; +} diff --git a/kez-chat/web/src/lib/conversations-store.ts b/kez-chat/web/src/lib/conversations-store.ts index 0ad6d93..c97be11 100644 --- a/kez-chat/web/src/lib/conversations-store.ts +++ b/kez-chat/web/src/lib/conversations-store.ts @@ -55,6 +55,25 @@ export interface ConversationMessage { * footnote in the bubble so the user knows which relay carried * the message; also informs future reply biasing. */ accepted_by?: string; + /** When set, this bubble represents a file attachment (image + * preview, generic file chip, etc.) rather than a text message. + * See attachment-store.ts for the actual bytes. `body` carries + * a human-readable fallback ("📎 vacation.jpg"). */ + attachment?: { + filename: string; + mime: string; + size: number; + /** Progress state: "ready" once we have the bytes; "pending" + * while chunks are still arriving for a chunked transfer; + * "failed" if assembly gave up. */ + state: "ready" | "pending" | "failed"; + /** For chunked transfers: file_id correlator. Lets the inbox- + * service find the pointer when a stray chunk arrives later. */ + file_id?: string; + /** Receive progress for chunked transfers. */ + received_chunks?: number; + total_chunks?: number; + }; } export interface Conversation { @@ -348,6 +367,125 @@ export async function markDeliveredByEventId( return changed; } +/** + * Append an inbound file-attachment message, INDEPENDENTLY of whether + * the file is ready yet. For inline files: `attachment.state = "ready"` + * and the caller is expected to have already saved the bytes via + * `attachment-store.saveAttachment`. For chunked files: usually + * "pending" because chunks may still be arriving. + */ +export async function appendInboundAttachment(opts: { + peer_primary: Identity; + peer_handle: string; + seq: number; + ts: string; + body: string; + peer_nostr_pubkey?: string; + via_relay?: string; + attachment: NonNullable; +}): Promise { + const s = await read(); + const conv = s.by_peer[opts.peer_primary] ?? { + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + messages: [], + last_seq: 0, + }; + if (opts.peer_handle) conv.peer_handle = opts.peer_handle; + if (opts.peer_nostr_pubkey) conv.peer_nostr_pubkey = opts.peer_nostr_pubkey; + if (opts.via_relay) conv.peer_via_relay = opts.via_relay; + const isNew = !conv.messages.find( + (m) => m.direction === "in" && m.seq === opts.seq, + ); + if (isNew) { + conv.messages.push({ + seq: opts.seq, + direction: "in", + body: opts.body, + from: opts.peer_primary, + ts: opts.ts, + attachment: opts.attachment, + }); + conv.unread_count = (conv.unread_count ?? 0) + 1; + } + conv.last_seq = Math.max(conv.last_seq, opts.seq); + s.by_peer[opts.peer_primary] = conv; + s.global_cursor = Math.max(s.global_cursor, opts.seq); + await write(s); +} + +/** + * Append an OUTBOUND file-attachment message, used by Messages.svelte + * the moment the user hits Send. Returns the synthetic seq so the + * caller can mutate progress / status later. + */ +export async function appendOutboundAttachment(opts: { + peer_primary: Identity; + peer_handle: string; + from: Identity; + body: string; + attachment: NonNullable; + status?: MessageStatus; +}): Promise { + const s = await read(); + const conv = s.by_peer[opts.peer_primary] ?? { + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + messages: [], + last_seq: 0, + }; + if (opts.peer_handle) conv.peer_handle = opts.peer_handle; + const seq = Date.now(); + conv.messages.push({ + seq, + direction: "out", + body: opts.body, + from: opts.from, + ts: new Date().toISOString(), + status: opts.status ?? "sending", + attachment: opts.attachment, + }); + s.by_peer[opts.peer_primary] = conv; + await write(s); + return seq; +} + +/** Mutate an attachment's state — used by the inbox-service when + * chunks land and progress advances. No-op if the message is gone. */ +export async function patchAttachmentState( + peer_primary: Identity, + seq: number, + patch: Partial>, +): Promise { + const s = await read(); + const conv = s.by_peer[peer_primary]; + if (!conv) return; + const m = conv.messages.find( + (msg) => msg.direction === "in" && msg.seq === seq, + ); + if (!m || !m.attachment) return; + m.attachment = { ...m.attachment, ...patch }; + await write(s); +} + +/** Find a pending-attachment message by file_id across all + * conversations. Returns null if no such message. Useful when an + * inbound chunk arrives and we need to find which pointer it + * belongs to. */ +export async function findAttachmentByFileId( + file_id: string, +): Promise<{ peer_primary: Identity; seq: number } | null> { + const s = await read(); + for (const conv of Object.values(s.by_peer)) { + for (const m of conv.messages) { + if (m.attachment?.file_id === file_id) { + return { peer_primary: conv.peer_primary, seq: m.seq }; + } + } + } + return null; +} + /** Verify a hex ed25519 signature over `event_id` against the * ed25519 pubkey embedded in the KEZ primary string. */ function verifyAckSig( diff --git a/kez-chat/web/src/lib/file-transfer.ts b/kez-chat/web/src/lib/file-transfer.ts new file mode 100644 index 0000000..11013e3 --- /dev/null +++ b/kez-chat/web/src/lib/file-transfer.ts @@ -0,0 +1,183 @@ +// File attachment transport for kez-chat over nostr. +// +// Two paths, decided by raw file size: +// +// 1. INLINE (raw ≤ INLINE_LIMIT bytes) +// - The file is base64-embedded in a single kez-DM event's body +// (a JSON object with `type: "kez-file-v1", mode: "inline"`). +// - One event, no chunk reconstruction. Same delivery semantics +// as a text message. The encryption is the existing v2 +// envelope — no separate file-level key needed. +// - Resized photos and screenshots land here. +// +// 2. CHUNKED (INLINE_LIMIT < raw ≤ MAX_FILE_BYTES) +// - Raw file is split into ~CHUNK_LIMIT-byte chunks. Each chunk +// is its own kez-DM event with body +// `{type: "kez-file-chunk-v1", file_id, i, n, data}`. A +// separate "pointer" event of `type: "kez-file-v1", +// mode: "chunked"` carries the file metadata (filename, mime, +// total size, and the file_id every chunk shares). +// - Each chunk event is broadcast to ALL configured relays +// (single signed event → 5-way redundancy). Per-event +// delivery is ~99.99% reliable; nothing fancier needed. +// - Receiver buffers chunks by file_id, assembles when n/n +// have arrived, then renders/saves. +// +// Caps: +// * INLINE_LIMIT = 80 KB raw. Keeps the AES-encrypted + +// hex-encoded + JSON-wrapped envelope comfortably under the +// ~256 KB content limit most relays enforce. +// * CHUNK_LIMIT = 80 KB raw → ~107 KB base64 → ~215 KB envelope. +// Same reasoning. Larger chunks would push past stricter relays. +// * MAX_FILE_BYTES = 10 MB. Above that → 125+ chunks, real +// rate-limit pressure, slow assembly. Out of scope for v0.1. +// +// Recovery for missing chunks is deferred. Each chunk is published +// to all 5 default relays so the per-chunk loss rate is +// vanishingly small (≈10⁻⁵). If it does bite, the receiver can +// later send a "missing chunks" message — that protocol slot is +// left open; we just don't implement the bot yet. + +import { bytesToHex } from "@noble/hashes/utils"; + +export const INLINE_LIMIT = 80 * 1024; +export const CHUNK_LIMIT = 80 * 1024; +export const MAX_FILE_BYTES = 10 * 1024 * 1024; + +// ─── body schemas ────────────────────────────────────────────────────────── +// +// All file-related events are regular kez-DM events. The JSON body +// uses a discriminating `type` field so the inbox-service can route +// to the right handler. Plain text messages (the existing path) +// just don't parse as JSON → handled as text. + +/** Inline file: the whole thing fits in one event. */ +export interface InlineFileBody { + type: "kez-file-v1"; + mode: "inline"; + filename: string; + mime: string; + size: number; // raw bytes + /** base64 of raw file bytes (no separate file-level encryption — + * the envelope crypto already covers this). */ + data: string; +} + +/** Pointer event for a chunked file. Sent AFTER all chunks are + * published. Carries the file metadata + the shared `file_id` + * every chunk uses. */ +export interface ChunkedFilePointerBody { + type: "kez-file-v1"; + mode: "chunked"; + filename: string; + mime: string; + size: number; + file_id: string; + n: number; // total chunk count +} + +/** A single chunk of a chunked file. Receiver buffers by + * `file_id`; when n/n have arrived, the file is reassembled. */ +export interface ChunkBody { + type: "kez-file-chunk-v1"; + file_id: string; + i: number; // 0-indexed + n: number; + data: string; // base64 of the raw chunk +} + +export type FileMessageBody = + | InlineFileBody + | ChunkedFilePointerBody + | ChunkBody; + +/** Discriminator used by the inbox-service. Tries to JSON-parse the + * body; returns the typed shape if it looks like one of ours, + * otherwise undefined (= treat as a regular text message). */ +export function parseFileBody(body: string): FileMessageBody | undefined { + // Cheap pre-check — avoid spending a JSON.parse on long plain-text + // messages that don't start with `{`. + if (!body || body[0] !== "{") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object") return undefined; + const p = parsed as { type?: unknown }; + if (p.type === "kez-file-v1" || p.type === "kez-file-chunk-v1") { + return parsed as FileMessageBody; + } + return undefined; +} + +// ─── chunking / assembly ─────────────────────────────────────────────────── + +/** Split a raw byte buffer into chunks of at most CHUNK_LIMIT + * bytes. Returns an array of `Uint8Array` views; no copy if the + * input is itself a Uint8Array (subarray is a view). */ +export function chunkifyBytes(bytes: Uint8Array): Uint8Array[] { + if (bytes.length === 0) return []; + const chunks: Uint8Array[] = []; + for (let off = 0; off < bytes.length; off += CHUNK_LIMIT) { + chunks.push(bytes.subarray(off, Math.min(off + CHUNK_LIMIT, bytes.length))); + } + return chunks; +} + +/** Assemble a full buffer from an ordered array of chunks. Caller + * must have already verified that n/n chunks are present and + * ordered correctly. */ +export function assembleChunks(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((a, c) => a + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} + +// ─── base64 (small, dependency-free) ─────────────────────────────────────── + +export function bytesToBase64(bytes: Uint8Array): string { + // For large buffers, String.fromCharCode.apply blows the stack. + // Use a loop in 32KB windows so it stays cheap on the largest + // chunks we ship (~80 KB). + const WINDOW = 32 * 1024; + let bin = ""; + for (let i = 0; i < bytes.length; i += WINDOW) { + const slice = bytes.subarray(i, Math.min(i + WINDOW, bytes.length)); + bin += String.fromCharCode(...slice); + } + return btoa(bin); +} + +export function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// ─── file_id generator ───────────────────────────────────────────────────── + +/** Random 128-bit id, hex-encoded. Used to correlate the pointer + * event with its N chunk events. */ +export function newFileId(): string { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + return bytesToHex(buf); +} + +/** Wrap raw bytes + mime in a data URL (e.g. for ). Uses + * the streaming-friendly base64 encoder so a 10 MB image doesn't + * blow the call stack. */ +export async function bytesToDataUrl( + bytes: Uint8Array, + mime: string, +): Promise { + return `data:${mime};base64,${bytesToBase64(bytes)}`; +} diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts index f901daa..8d868ca 100644 --- a/kez-chat/web/src/lib/inbox-service.svelte.ts +++ b/kez-chat/web/src/lib/inbox-service.svelte.ts @@ -35,12 +35,28 @@ import { import { lookupByPrimary } from "./api.js"; import { appendInbound, + appendInboundAttachment, getConversation, getGlobalCursor, markDeliveredByEventId, + patchAttachmentState, } from "./conversations-store.js"; import type { Identity } from "./kez.js"; import { peerProfiles } from "./peer-profile-cell.svelte.js"; +import { + parseFileBody, + base64ToBytes, + assembleChunks, + bytesToDataUrl, +} from "./file-transfer.js"; +import { + chunkBufferIsComplete, + deleteChunkBuffer, + loadChunkBuffer, + saveAttachment, + saveChunkBuffer, + type ChunkBufferEntry, +} from "./attachment-store.js"; const POLL_INTERVAL_MS = 30_000; @@ -159,15 +175,39 @@ class InboxService { // Unknown to this server (cross-server v0.2). Show truncated key later. } } - await appendInbound({ - peer_primary: pt.from as Identity, - peer_handle: displayName, - seq: m.seq, - body: pt.body, - ts: pt.sent_at, - peer_nostr_pubkey: m.sender_nostr_pubkey, - via_relay: m.via_relay, - }); + + // ─── file attachment branch ───────────────────────────────── + // The body might be a JSON payload: inline file, chunked-file + // pointer, or a chunk of a chunked file. parseFileBody returns + // undefined for plain text — that falls through to the + // regular appendInbound path below. + const fileBody = parseFileBody(pt.body); + if (fileBody) { + await this.#ingestFileBody({ + fileBody, + peer_primary: pt.from as Identity, + peer_handle: displayName, + seq: m.seq, + ts: pt.sent_at, + peer_nostr_pubkey: m.sender_nostr_pubkey, + via_relay: m.via_relay, + }); + // We still want the rest of the post-ingest hooks (ack, + // peer-profile fetch, badge bump) to run for inline files + // and pointers, but NOT for chunks (which are user-invisible + // plumbing). Bail early on chunks. + if (fileBody.type === "kez-file-chunk-v1") return; + } else { + await appendInbound({ + peer_primary: pt.from as Identity, + peer_handle: displayName, + seq: m.seq, + body: pt.body, + ts: pt.sent_at, + peer_nostr_pubkey: m.sender_nostr_pubkey, + via_relay: m.via_relay, + }); + } // First time we've seen this peer's nostr pubkey? Kick off a // profile fetch so their avatar lights up the moment we render. @@ -225,6 +265,181 @@ class InboxService { /** Handle an inbound ack event — flip the matching outbound bubble * from "sent" to "delivered" and notify the UI to repaint. */ + /** + * Route a parsed file-attachment body to the right path. Three + * cases: + * + * 1. Inline file → create a "ready" attachment row, save the + * bytes (decoded from base64) to the local attachment store. + * 2. Chunked file pointer → create a "pending" attachment row, + * stash the destination on the chunk-buffer entry. If chunks + * already arrived (the pointer raced), trigger finalize. + * 3. Chunk → buffer it under the file_id; if all n/n now + * present AND the pointer has registered a destination, + * finalize. + * + * "Finalize" = concatenate chunk bytes, write to the local + * attachment store, flip the message's attachment.state to "ready". + */ + async #ingestFileBody(opts: { + fileBody: ReturnType; + peer_primary: Identity; + peer_handle: string; + seq: number; + ts: string; + peer_nostr_pubkey?: string; + via_relay?: string; + }) { + const f = opts.fileBody!; + if (f.type === "kez-file-v1" && f.mode === "inline") { + // ─── inline ───────────────────────────────────────────────── + const bytes = base64ToBytes(f.data); + const dataUrl = await bytesToDataUrl(bytes, f.mime); + await saveAttachment(opts.peer_primary, opts.seq, { + filename: f.filename, + mime: f.mime, + data_url: dataUrl, + size: bytes.length, + }); + await appendInboundAttachment({ + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + seq: opts.seq, + ts: opts.ts, + body: `📎 ${f.filename}`, + peer_nostr_pubkey: opts.peer_nostr_pubkey, + via_relay: opts.via_relay, + attachment: { + filename: f.filename, + mime: f.mime, + size: f.size, + state: "ready", + }, + }); + return; + } + + if (f.type === "kez-file-v1" && f.mode === "chunked") { + // ─── chunked pointer ──────────────────────────────────────── + // Create or update the chunk-buffer entry with the destination + // (= where the assembled file should land). Chunks may have + // arrived before the pointer (relay order isn't guaranteed) + // OR may arrive after. Either ordering works. + let buf = + (await loadChunkBuffer(f.file_id)) ?? + ({ + n: f.n, + received: {}, + started_at: new Date().toISOString(), + } as ChunkBufferEntry); + buf.n = f.n; + buf.destination = { + peer_primary: opts.peer_primary, + seq: opts.seq, + filename: f.filename, + mime: f.mime, + size: f.size, + }; + await saveChunkBuffer(f.file_id, buf); + + // Show a "pending" attachment in chat immediately. The user + // sees "Receiving 12/47" until n/n have arrived. + await appendInboundAttachment({ + peer_primary: opts.peer_primary, + peer_handle: opts.peer_handle, + seq: opts.seq, + ts: opts.ts, + body: `📎 ${f.filename}`, + peer_nostr_pubkey: opts.peer_nostr_pubkey, + via_relay: opts.via_relay, + attachment: { + filename: f.filename, + mime: f.mime, + size: f.size, + state: "pending", + file_id: f.file_id, + received_chunks: Object.keys(buf.received).length, + total_chunks: f.n, + }, + }); + + if (chunkBufferIsComplete(buf)) { + await this.#finalizeChunkedFile(f.file_id); + } + return; + } + + if (f.type === "kez-file-chunk-v1") { + // ─── chunk ────────────────────────────────────────────────── + let buf = + (await loadChunkBuffer(f.file_id)) ?? + ({ + n: f.n, + received: {}, + started_at: new Date().toISOString(), + } as ChunkBufferEntry); + buf.n = f.n; // pointer might disagree on n, prefer the chunk's report + buf.received[f.i] = base64ToBytes(f.data); + await saveChunkBuffer(f.file_id, buf); + + // If the pointer has registered a destination, mirror the + // received count onto the attachment row so the "12/47" hint + // ticks up live. + if (buf.destination) { + await patchAttachmentState( + buf.destination.peer_primary, + buf.destination.seq, + { + received_chunks: Object.keys(buf.received).length, + total_chunks: buf.n, + }, + ); + if (chunkBufferIsComplete(buf)) { + await this.#finalizeChunkedFile(f.file_id); + } + } + // No pointer yet — silently buffer. Finalize will fire when + // the pointer registers a destination (next branch). + return; + } + } + + /** All chunks present + pointer destination known → assemble bytes, + * save to the attachment store, flip the message to "ready", + * delete the chunk buffer. */ + async #finalizeChunkedFile(file_id: string) { + const buf = await loadChunkBuffer(file_id); + if (!buf || !buf.destination || !chunkBufferIsComplete(buf)) return; + try { + // Reassemble in order. + const ordered: Uint8Array[] = []; + for (let i = 0; i < buf.n; i++) ordered.push(buf.received[i]); + const bytes = assembleChunks(ordered); + const dataUrl = await bytesToDataUrl(bytes, buf.destination.mime); + await saveAttachment(buf.destination.peer_primary, buf.destination.seq, { + filename: buf.destination.filename, + mime: buf.destination.mime, + data_url: dataUrl, + size: bytes.length, + }); + await patchAttachmentState( + buf.destination.peer_primary, + buf.destination.seq, + { state: "ready" }, + ); + await deleteChunkBuffer(file_id); + // Tell the UI to repaint so the image appears. + this.#notifyListeners(); + } catch (e) { + console.error(`inbox-service: finalize ${file_id} failed`, e); + await patchAttachmentState( + buf.destination.peer_primary, + buf.destination.seq, + { state: "failed" }, + ); + } + } + async #ingestAck(acked_event_id: string, ack_sig_hex?: string) { try { const changed = await markDeliveredByEventId(acked_event_id, ack_sig_hex); diff --git a/kez-chat/web/src/lib/messages.ts b/kez-chat/web/src/lib/messages.ts index cf8ee2e..258d803 100644 --- a/kez-chat/web/src/lib/messages.ts +++ b/kez-chat/web/src/lib/messages.ts @@ -282,6 +282,31 @@ export function attachSigner(_seed: Uint8Array): void { /* server transport: no NIP-42 to answer */ } +export async function sendFile(_opts: { + senderHandle: string; + senderSeed: Uint8Array; + senderPrimary: Identity; + recipientHandle: string; + recipientPrimary: Identity; + filename: string; + mime: string; + raw: Uint8Array; + preferRelay?: string; + progress?: (done: number, total: number) => void; +}): Promise<{ + pointer_event_id: string; + accepted_by?: string; + chunk_event_ids: string[]; +}> { + /* server transport: file send not implemented (it would need a new + POST endpoint + storage). For now, throw a clear error so the + UI can surface a "switch to nostr transport for attachments" + hint. */ + throw new Error( + "file send is only supported on the nostr transport (VITE_TRANSPORT=nostr)", + ); +} + export function detachSigner(): void { /* server transport: no NIP-42 to answer */ } diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts index 0f2085b..9eca5ec 100644 --- a/kez-chat/web/src/lib/nostr-transport.ts +++ b/kez-chat/web/src/lib/nostr-transport.ts @@ -19,6 +19,16 @@ import { ed25519 } from "@noble/curves/ed25519"; import { bytesToHex } from "@noble/hashes/utils"; import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools"; import { sealMessage, type SealedEnvelope } from "./crypto.js"; +import { + bytesToBase64, + chunkifyBytes, + newFileId, + INLINE_LIMIT, + MAX_FILE_BYTES, + type ChunkBody, + type ChunkedFilePointerBody, + type InlineFileBody, +} from "./file-transfer.js"; import { lookup } from "./api.js"; import { identityFromSeed, type Identity } from "./kez.js"; import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js"; @@ -397,6 +407,184 @@ export async function sendMessage(opts: { return { seq: signed.created_at, event_id: signed.id, accepted_by: acceptedBy }; } +// ───────────────────────────────────────────────────────────────────────────── +// File attachments — inline and chunked +// ───────────────────────────────────────────────────────────────────────────── +// +// Reuses the same v2 envelope crypto + nostr publish path. The +// only difference is the message body is a JSON payload with a +// type discriminator rather than plain text. See file-transfer.ts +// for the body schema. +// +// For chunked transfers, each chunk is its own DM event so: +// • Receiver gets each chunk via the regular streamInbox path +// (no new subscription, no new filter) +// • Loss recovery is per-chunk (republish just the missing ones) +// • Each chunk is signed/sealed/tagged identically to a text DM +// +// Throttle: chunks are published at ~5/sec to keep relay-side +// rate limits happy. A 10 MB file = 125 chunks = ~25 s end-to-end. + +const CHUNK_PUBLISH_DELAY_MS = 200; // 5/sec + +/** + * Publish a single arbitrary text body (a JSON-encoded + * inline/pointer/chunk payload, in practice). Same crypto + + * publish path as `sendMessage`, just exposed for the file + * transfer module so it can publish N chunks plus a pointer + * without re-implementing the envelope. + */ +async function publishRawBody(opts: { + senderHandle: string; + senderSeed: Uint8Array; + senderPrimary: Identity; + recipientHandle: string; + recipientPrimary: Identity; + body: string; + preferRelay?: string; +}): Promise<{ event_id: string; accepted_by?: string }> { + const envelope = await sealMessage({ + senderSeed: opts.senderSeed, + senderPrimary: opts.senderPrimary, + recipientHandle: opts.recipientHandle, + recipientPrimary: opts.recipientPrimary, + body: opts.body, + }); + const sk = nostrSecretFromSeed(opts.senderSeed); + const tmpl: EventTemplate = { + kind: KEZ_DM_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [[ADDR_TAG, addrFromPrimary(opts.recipientPrimary)]], + content: JSON.stringify(envelope), + }; + const signed = finalizeEvent(tmpl, sk); + const ordered = orderedRelaysForSend(opts.preferRelay); + const publishPromises = pool().publish(ordered, signed); + let acceptedBy: string | undefined; + try { + acceptedBy = await Promise.any( + publishPromises.map((p, i) => p.then(() => ordered[i])), + ); + } catch { + /* nobody accepted yet — fall through to allSettled check */ + } + const results = await Promise.allSettled(publishPromises); + if (!results.some((r) => r.status === "fulfilled")) { + const why = results + .map((r) => (r.status === "rejected" ? String(r.reason) : "")) + .filter(Boolean) + .join("; "); + throw new Error(`no relay accepted${why ? `: ${why}` : ""}`); + } + return { event_id: signed.id, accepted_by: acceptedBy }; +} + +export interface SendFileResult { + /** event id of the final user-visible message (inline file event, + * or the chunked-file pointer). */ + pointer_event_id: string; + /** Relay that accepted the pointer first. */ + accepted_by?: string; + /** For chunked sends: ids of every chunk event in send order. + * Empty for inline. Useful for logging / future recovery hooks. */ + chunk_event_ids: string[]; +} + +/** + * Send a file. Decides inline vs chunked based on `raw.length`. + * Calls `progress` after each chunk publish so the UI can paint a + * "uploading 12/47" hint. + */ +export async function sendFile(opts: { + senderHandle: string; + senderSeed: Uint8Array; + senderPrimary: Identity; + recipientHandle: string; + recipientPrimary: Identity; + filename: string; + mime: string; + raw: Uint8Array; + preferRelay?: string; + progress?: (done: number, total: number) => void; +}): Promise { + const { raw } = opts; + if (raw.length > MAX_FILE_BYTES) { + throw new Error( + `file too large (${(raw.length / 1024 / 1024).toFixed(1)} MB; max ${MAX_FILE_BYTES / 1024 / 1024} MB)`, + ); + } + + // ─── inline path ───────────────────────────────────────────── + if (raw.length <= INLINE_LIMIT) { + const body: InlineFileBody = { + type: "kez-file-v1", + mode: "inline", + filename: opts.filename, + mime: opts.mime, + size: raw.length, + data: bytesToBase64(raw), + }; + opts.progress?.(1, 1); + const out = await publishRawBody({ + ...opts, + body: JSON.stringify(body), + }); + return { + pointer_event_id: out.event_id, + accepted_by: out.accepted_by, + chunk_event_ids: [], + }; + } + + // ─── chunked path ──────────────────────────────────────────── + const file_id = newFileId(); + const chunks = chunkifyBytes(raw); + const n = chunks.length; + + // 1. Publish each chunk. Throttled so we don't stampede relays. + const chunk_event_ids: string[] = []; + for (let i = 0; i < n; i++) { + const chunkBody: ChunkBody = { + type: "kez-file-chunk-v1", + file_id, + i, + n, + data: bytesToBase64(chunks[i]), + }; + const out = await publishRawBody({ + ...opts, + body: JSON.stringify(chunkBody), + }); + chunk_event_ids.push(out.event_id); + opts.progress?.(i + 1, n + 1); + if (i < n - 1) { + await new Promise((r) => setTimeout(r, CHUNK_PUBLISH_DELAY_MS)); + } + } + + // 2. Publish the pointer. The bubble appears on the recipient's + // side at this moment; chunks are typically already there. + const pointerBody: ChunkedFilePointerBody = { + type: "kez-file-v1", + mode: "chunked", + filename: opts.filename, + mime: opts.mime, + size: raw.length, + file_id, + n, + }; + const pointer = await publishRawBody({ + ...opts, + body: JSON.stringify(pointerBody), + }); + opts.progress?.(n + 1, n + 1); + return { + pointer_event_id: pointer.event_id, + accepted_by: pointer.accepted_by, + chunk_event_ids, + }; +} + // ───────────────────────────────────────────────────────────────────────────── // Delivery receipts (kez-DM-ack, kind 4244) // ───────────────────────────────────────────────────────────────────────────── diff --git a/kez-chat/web/src/lib/transport.ts b/kez-chat/web/src/lib/transport.ts index 9a01c06..d4e5545 100644 --- a/kez-chat/web/src/lib/transport.ts +++ b/kez-chat/web/src/lib/transport.ts @@ -38,6 +38,9 @@ export const fetchAcksForEventIds = impl.fetchAcksForEventIds; * get signed transparently. No-op on the server transport. */ export const attachSigner = impl.attachSigner; export const detachSigner = impl.detachSigner; +/** Send a file (image or other). Inline if ≤80KB, chunked otherwise. + * Throws on the server transport (not implemented). */ +export const sendFile = impl.sendFile; /** Snapshot of every configured relay (or the single chat-server) + * whether the socket is currently open. Drives the "● live (N)" * indicator and its popover. */ diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index d6c0ddd..12006ee 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -4,11 +4,18 @@ import { session } from "../lib/store.svelte.js"; import { sendMessage, + sendFile, getRelayStatuses, activeTransport, fetchAcksForEventIds, type RelayStatus, } from "../lib/transport.js"; + import { MAX_FILE_BYTES, INLINE_LIMIT } from "../lib/file-transfer.js"; + import { + loadAttachment, + saveAttachment, + type StoredAttachment, + } from "../lib/attachment-store.js"; import { lookup, lookupByPrimary, ApiError } from "../lib/api.js"; import { inboxService } from "../lib/inbox-service.svelte.js"; import { verifySubject } from "../lib/verify.js"; @@ -26,6 +33,7 @@ } from "../lib/push.js"; import { appendOutbound, + appendOutboundAttachment, ensureConversation, listConversations, markConversationRead, @@ -81,6 +89,93 @@ let composeText = $state(""); let composing = $state(false); let composeEl: HTMLInputElement | null = $state(null); + let fileInput = $state(null); + let fileSendError = $state(null); + + /** + * Read a File from the picker, route through the transport's + * sendFile (inline vs chunked decided automatically by size), + * and render an optimistic local echo of the attachment so the + * sender sees their image immediately. + */ + async function onFilePicked(file: File) { + if (!session.unlocked || !activeConv) return; + fileSendError = null; + if (file.size > MAX_FILE_BYTES) { + fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`; + return; + } + composing = true; + const raw = new Uint8Array(await file.arrayBuffer()); + const filename = file.name || "file"; + const mime = file.type || "application/octet-stream"; + const peer_primary = activeConv.peer_primary; + const peer_handle = activeConv.peer_handle; + + // ─── optimistic local echo ───────────────────────────────── + // Save the raw bytes locally so the bubble can render + // instantly + survives a reload. Decide inline vs chunked the + // same way the transport will. + const localSeq = await appendOutboundAttachment({ + peer_primary, + peer_handle, + from: session.unlocked.primary, + body: `📎 ${filename}`, + attachment: { + filename, + mime, + size: raw.length, + state: "ready", // sender always has the bytes — render immediately + }, + status: "sending", + }); + // Save bytes via the same attachment store the receiver uses, + // so the bubble rendering code is shared. + const dataUrl = await fileToDataUrl(file); + await saveAttachment(peer_primary, localSeq, { + filename, + mime, + size: raw.length, + data_url: dataUrl, + }); + await refresh(); + + composing = false; + + // ─── actual publish ──────────────────────────────────────── + try { + const result = await sendFile({ + senderHandle: session.unlocked.handle, + senderSeed: session.unlocked.seed, + senderPrimary: session.unlocked.primary, + recipientHandle: peer_handle || peer_primary, + recipientPrimary: peer_primary, + filename, + mime, + raw, + preferRelay: activeConv?.peer_via_relay, + }); + await markOutboundStatus(peer_primary, localSeq, "sent", { + event_id: result.pointer_event_id, + accepted_by: result.accepted_by, + }); + await refresh(); + } catch (e) { + console.error("sendFile failed:", e); + await markOutboundStatus(peer_primary, localSeq, "failed"); + fileSendError = (e as Error).message; + await refresh(); + } + } + + function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result as string); + r.onerror = () => reject(new Error("could not read file")); + r.readAsDataURL(file); + }); + } /** * Insert an emoji at the current cursor position in the compose input @@ -890,7 +985,57 @@ : "bg-bubble-recv text-text rounded-2xl rounded-bl-md", ].join(" ")} > - {m.body} + {#if m.attachment} + + {#await loadAttachment(activeConv.peer_primary, m.seq) then att} + {#if m.attachment.state === "pending"} + + + + + + + Receiving {m.attachment.received_chunks ?? 0}/{m.attachment.total_chunks ?? "?"} + + + {:else if m.attachment.state === "failed"} + + ⚠ {m.attachment.filename}: assembly failed + + {:else if att?.data_url && m.attachment.mime?.startsWith("image/")} + + + {:else if att?.data_url} + + + 📎 + {m.attachment.filename} + + {(m.attachment.size / 1024).toFixed(0)} KB + + + {:else} + + 📎 {m.attachment.filename} (loading…) + + {/if} + {/await} + {:else} + {m.body} + {/if} - { e.preventDefault(); send(); }}> - - - - {composing ? "…" : "Send"} - - + + {#if fileSendError} + {fileSendError} + {/if} + { e.preventDefault(); send(); }}> + + + { + const f = (e.currentTarget as HTMLInputElement).files?.[0]; + if (f) void onFilePicked(f); + (e.currentTarget as HTMLInputElement).value = ""; + }} + /> + fileInput?.click()} + aria-label="Attach a file" + title="Attach a file (max {MAX_FILE_BYTES / 1024 / 1024}MB)" + > + + + + + + + {composing ? "…" : "Send"} + + + {/if}
{fileSendError}