diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 634904c0..df52f548 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -47,6 +47,7 @@ export type MeshMessageTypeLabel = | 'tx_confirmation' | 'lightning_relay' | 'lightning_relay_response' + | 'content_ref' export interface MeshMessage { id: number @@ -375,6 +376,38 @@ export const useMeshStore = defineStore('mesh', () => { } } + async function sendContent(contactId: number, cid: string, caption?: string) { + try { + sending.value = true + error.value = null + const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({ + method: 'mesh.send-content', + params: { contact_id: contactId, cid, caption }, + }) + if (res.sent) await fetchMessages() + return res + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : 'Failed to send content' + throw err + } finally { + sending.value = false + } + } + + async function fetchContent(params: { + cid: string + sender_onion: string + cap_token: string + cap_exp: number + mime?: string + filename?: string + }) { + return rpcClient.call<{ fetched: boolean; cached: boolean; cid: string; size?: number; mime?: string }>({ + method: 'mesh.fetch-content', + params, + }) + } + async function getSessionStatus(contactId: number): Promise { return rpcClient.call({ method: 'mesh.session-status', @@ -495,6 +528,8 @@ export const useMeshStore = defineStore('mesh', () => { sendInvoice, sendCoordinate, sendAlert, + sendContent, + fetchContent, getSessionStatus, rotatePrekeys, getNodePositions, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index a97073bf..64b544f7 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -424,6 +424,86 @@ async function handleBlobUpload(ev: Event) { } } +// ── ContentRef attach + fetch (Phase 3b) ────────────────────────────────── +const attaching = ref(false) +const attachError = ref(null) +const fetchingCids = ref>(new Set()) +const fetchedUrls = ref>(new Map()) + +async function handleAttachFile(ev: Event) { + const input = ev.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + if (!activeChatPeer.value) { + attachError.value = 'Pick a peer first' + if (input) input.value = '' + return + } + attaching.value = true + attachError.value = null + try { + const buf = await file.arrayBuffer() + const up = await fetch('/api/blob', { + method: 'POST', + headers: { + 'X-Blob-Mime': file.type || 'application/octet-stream', + 'X-Blob-Filename': file.name, + 'Content-Type': 'application/octet-stream', + }, + credentials: 'include', + body: buf, + }) + if (!up.ok) { + attachError.value = `upload failed: ${up.status}` + return + } + const { cid } = (await up.json()) as { cid: string } + await mesh.sendContent(activeChatPeer.value.contact_id, cid, messageText.value.trim() || undefined) + messageText.value = '' + nextTick(() => scrollChatToBottom()) + } catch (e) { + attachError.value = e instanceof Error ? e.message : 'attach failed' + } finally { + attaching.value = false + if (input) input.value = '' + } +} + +async function handleFetchContent(payload: { + cid: string + sender_onion: string + cap_token: string + cap_exp: number + mime?: string + filename?: string | null +}) { + if (fetchingCids.value.has(payload.cid)) return + fetchingCids.value.add(payload.cid) + try { + const res = await mesh.fetchContent({ + cid: payload.cid, + sender_onion: payload.sender_onion, + cap_token: payload.cap_token, + cap_exp: payload.cap_exp, + mime: payload.mime, + filename: payload.filename ?? undefined, + }) + const r = res as { local_url?: string } + if (r.local_url) { + fetchedUrls.value.set(payload.cid, r.local_url) + fetchedUrls.value = new Map(fetchedUrls.value) + } + } catch (e) { + console.error('fetch-content failed', e) + } finally { + fetchingCids.value.delete(payload.cid) + } +} + +function isImageMime(mime?: string): boolean { + return !!mime && mime.startsWith('image/') +} + async function verifyBlobRoundTrip() { if (!blobResult.value) return blobVerifyStatus.value = 'fetching...' @@ -761,6 +841,35 @@ async function verifyBlobRoundTrip() {
preimage: {{ String(msg.typed_payload.preimage).substring(0, 20) }}…
{{ msg.typed_payload.error }}
+
+ +
{{ msg.typed_payload.caption }}
+ + + +
{{ msg.plaintext }}
@@ -773,11 +882,20 @@ async function verifyBlobRoundTrip() {
{{ sendError }}
+
{{ attachError }}
+