feat(kez-chat): basic image attachments over nostr

Inline + chunked file transfer. No central host, no separate file
encryption key — the existing v2 envelope crypto + nostr publish
path covers it. Files up to 10 MB; larger refused with a friendly
error.

Inline path (≤80 KB raw)
  - File body is a JSON kez-file-v1 / inline payload with base64
    data, sealed by the normal envelope and published as a single
    kez-DM event. Same delivery semantics as text.

Chunked path (80 KB – 10 MB)
  - Raw bytes split into ~80 KB chunks (each fits comfortably under
    the ~256 KB envelope-content ceiling most relays enforce).
  - Each chunk is its own kez-DM event with body kez-file-chunk-v1
    (file_id + i + n + base64). One signed event broadcast to all
    5 default relays → ~99.999% per-chunk delivery.
  - Publish throttled to ~5 events/sec to keep stricter relays
    happy. A 5 MB image lands in ~12 seconds end-to-end.
  - Pointer event (kez-file-v1 / chunked mode) sent last — it's
    the user-visible message; chunks are silent plumbing.

Receive path
  - parseFileBody discriminates plain text vs inline vs pointer vs
    chunk on the existing inbox-service decrypt path. Plain text
    still routes through the regular appendInbound.
  - Pointer arrival: create a "pending" attachment row + record the
    destination on the chunk-buffer entry.
  - Chunk arrival: append to the chunk buffer keyed by file_id;
    once n/n are present AND a destination is known, finalize.
  - Finalize: assemble in-order, decode to a data URL, save to the
    local attachment store, flip the attachment.state to "ready".
  - Pointer-before-chunks and chunks-before-pointer both work — IDB
    chunk buffer survives reloads so a partial transfer resumes.

UI (Messages.svelte)
  - Paperclip button next to the emoji button. Hidden file input
    with accept=image/* (broader types easy to enable later).
  - Optimistic local echo on send: bubble + image preview appear
    instantly from the local-copy data URL. Status icon proceeds
    "sending" → "sent" → "delivered" exactly like text messages.
  - Bubble render branches on m.attachment:
      • image MIME + ready  → inline <img>
      • non-image MIME + ready → filename + size + download link
      • pending → spinner with "Receiving N/M…"
      • failed → red ⚠ chip

Storage (attachment-store.ts)
  - kez-chat:attachments:v1 — ready files keyed by
    peer_primary|seq, value = {filename, mime, data_url, size}.
  - kez-chat:chunk-buffer:v1 — in-flight chunks keyed by file_id,
    value = {n, received: {i: bytes}, destination?}.

Schema (conversations-store.ts)
  - ConversationMessage.attachment? = {filename, mime, size, state,
    file_id?, received_chunks?, total_chunks?}.
  - Helpers: appendInboundAttachment, appendOutboundAttachment,
    patchAttachmentState, findAttachmentByFileId.

Out of scope (intentional for v0.1)
  - Larger than 10 MB: refused with a friendly error.
  - Reed-Solomon erasure coding: not needed — single signed event
    broadcast to 5 relays delivers reliably enough. Recovery from
    the rare missing chunk via DM round-trip is a future hook.
  - Reverse channel ("still need chunks [3,7]"): protocol slot
    left open; not implemented.
  - HEIC → JPEG conversion: iOS Safari may hand us the original
    HEIC bytes; recipients on non-Apple devices can't render. Fix
    in the picker layer if it bites.
  - Server-transport stub: throws a clear "use VITE_TRANSPORT=nostr"
    error so the UI can't silently misroute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-06-08 14:51:58 -06:00
parent c8370ffdf0
commit 89cb9f11e0
8 changed files with 1072 additions and 29 deletions

View File

@ -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 <img src=>.
// `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 <img>. */
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<void> {
await set(attachKey(peer_primary, seq), att);
}
export async function loadAttachment(
peer_primary: Identity,
seq: number,
): Promise<StoredAttachment | undefined> {
return get<StoredAttachment>(attachKey(peer_primary, seq));
}
export async function deleteAttachment(
peer_primary: Identity,
seq: number,
): Promise<void> {
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<number, Uint8Array>;
/** 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<ChunkBufferEntry | undefined> {
return get<ChunkBufferEntry>(chunkKey(file_id));
}
export async function saveChunkBuffer(
file_id: string,
entry: ChunkBufferEntry,
): Promise<void> {
await set(chunkKey(file_id), entry);
}
export async function deleteChunkBuffer(file_id: string): Promise<void> {
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;
}

View File

@ -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<ConversationMessage["attachment"]>;
}): Promise<void> {
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<ConversationMessage["attachment"]>;
status?: MessageStatus;
}): Promise<number> {
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<NonNullable<ConversationMessage["attachment"]>>,
): Promise<void> {
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(

View File

@ -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 <img src=>). 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<string> {
return `data:${mime};base64,${bytesToBase64(bytes)}`;
}

View File

@ -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,6 +175,29 @@ class InboxService {
// Unknown to this server (cross-server v0.2). Show truncated key later.
}
}
// ─── 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,
@ -168,6 +207,7 @@ class InboxService {
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<typeof parseFileBody>;
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);

View File

@ -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 */
}

View File

@ -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<SendFileResult> {
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)
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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. */

View File

@ -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<HTMLInputElement | null>(null);
let fileSendError = $state<string | null>(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<string> {
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(" ")}
>
{#if m.attachment}
<!-- File attachment branch. Image MIMEs render an
inline preview when bytes are ready; chunked
files in flight show a "Receiving N/M…" hint
until assembly completes. -->
{#await loadAttachment(activeConv.peer_primary, m.seq) then att}
{#if m.attachment.state === "pending"}
<div class="flex items-center gap-2 py-1">
<svg class="animate-spin shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"/>
<path d="M22 12a10 10 0 0 0-10-10" stroke-linecap="round"/>
</svg>
<span class="text-xs opacity-90">
Receiving {m.attachment.received_chunks ?? 0}/{m.attachment.total_chunks ?? "?"}
</span>
</div>
{:else if m.attachment.state === "failed"}
<div class="flex items-center gap-2 py-1 text-danger">
<span class="text-xs">{m.attachment.filename}: assembly failed</span>
</div>
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
<!-- Image preview. Capped at 320px in the chat;
tap could open a full-screen viewer (TODO). -->
<img
src={att.data_url}
alt={m.attachment.filename}
class="max-w-full max-h-80 rounded-lg block"
style="margin: -2px -8px 4px -8px;"
/>
{:else if att?.data_url}
<!-- Non-image: filename + size + download link. -->
<a
href={att.data_url}
download={m.attachment.filename}
class="flex items-center gap-2 underline-offset-2 hover:underline"
>
<span class="text-base">📎</span>
<span class="break-all">{m.attachment.filename}</span>
<span class="opacity-70 text-xs">
{(m.attachment.size / 1024).toFixed(0)} KB
</span>
</a>
{:else}
<span class="text-xs opacity-70">
📎 {m.attachment.filename} (loading…)
</span>
{/if}
{/await}
{:else}
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
{/if}
<!--
Inline timestamp + delivery status. `float-right` +
a leading non-breaking space pulls the cluster onto
@ -952,8 +1097,39 @@
</div>
<!-- Compose -->
<form class="p-3 border-t border-border bg-surface flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
<div class="border-t border-border bg-surface">
{#if fileSendError}
<p class="px-3 pt-2 text-xs text-danger">{fileSendError}</p>
{/if}
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
<EmojiButton onpick={insertEmoji} />
<!-- Paperclip — image picker. Hidden file input driven
by a styled button. accept=image/* keeps the OS picker
showing photos only on mobile; if you want arbitrary
files later, drop the accept. -->
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (f) void onFilePicked(f);
(e.currentTarget as HTMLInputElement).value = "";
}}
/>
<button
type="button"
class="shrink-0 text-text-secondary hover:text-text disabled:opacity-50 p-1"
disabled={composing}
onclick={() => fileInput?.click()}
aria-label="Attach a file"
title="Attach a file (max {MAX_FILE_BYTES / 1024 / 1024}MB)"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<input
type="text"
bind:value={composeText}
@ -971,6 +1147,7 @@
{composing ? "…" : "Send"}
</button>
</form>
</div>
{/if}
</main>
</div>