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>