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:
parent
c8370ffdf0
commit
89cb9f11e0
114
kez-chat/web/src/lib/attachment-store.ts
Normal file
114
kez-chat/web/src/lib/attachment-store.ts
Normal 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;
|
||||
}
|
||||
@ -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(
|
||||
|
||||
183
kez-chat/web/src/lib/file-transfer.ts
Normal file
183
kez-chat/web/src/lib/file-transfer.ts
Normal 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)}`;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user