feat(mesh-ui): attach button + ContentRef card in chat
Compose row gains a 📎 attach button that uploads the file via /api/blob
and calls mesh.send-content for the selected peer. Received content_ref
bubbles render as a caption+filename card with either an inline image
preview or a Download button that calls mesh.fetch-content and swaps in
the returned local_url.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
285feccf8c
commit
7497fd8a0d
@ -47,6 +47,7 @@ export type MeshMessageTypeLabel =
|
|||||||
| 'tx_confirmation'
|
| 'tx_confirmation'
|
||||||
| 'lightning_relay'
|
| 'lightning_relay'
|
||||||
| 'lightning_relay_response'
|
| 'lightning_relay_response'
|
||||||
|
| 'content_ref'
|
||||||
|
|
||||||
export interface MeshMessage {
|
export interface MeshMessage {
|
||||||
id: number
|
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<SessionStatus> {
|
async function getSessionStatus(contactId: number): Promise<SessionStatus> {
|
||||||
return rpcClient.call<SessionStatus>({
|
return rpcClient.call<SessionStatus>({
|
||||||
method: 'mesh.session-status',
|
method: 'mesh.session-status',
|
||||||
@ -495,6 +528,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
sendInvoice,
|
sendInvoice,
|
||||||
sendCoordinate,
|
sendCoordinate,
|
||||||
sendAlert,
|
sendAlert,
|
||||||
|
sendContent,
|
||||||
|
fetchContent,
|
||||||
getSessionStatus,
|
getSessionStatus,
|
||||||
rotatePrekeys,
|
rotatePrekeys,
|
||||||
getNodePositions,
|
getNodePositions,
|
||||||
|
|||||||
@ -424,6 +424,86 @@ async function handleBlobUpload(ev: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ContentRef attach + fetch (Phase 3b) ──────────────────────────────────
|
||||||
|
const attaching = ref(false)
|
||||||
|
const attachError = ref<string | null>(null)
|
||||||
|
const fetchingCids = ref<Set<string>>(new Set())
|
||||||
|
const fetchedUrls = ref<Map<string, string>>(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() {
|
async function verifyBlobRoundTrip() {
|
||||||
if (!blobResult.value) return
|
if (!blobResult.value) return
|
||||||
blobVerifyStatus.value = 'fetching...'
|
blobVerifyStatus.value = 'fetching...'
|
||||||
@ -761,6 +841,35 @@ async function verifyBlobRoundTrip() {
|
|||||||
<div v-if="msg.typed_payload.preimage" class="mesh-typed-invoice-bolt11">preimage: {{ String(msg.typed_payload.preimage).substring(0, 20) }}…</div>
|
<div v-if="msg.typed_payload.preimage" class="mesh-typed-invoice-bolt11">preimage: {{ String(msg.typed_payload.preimage).substring(0, 20) }}…</div>
|
||||||
<div v-if="msg.typed_payload.error" class="mesh-typed-invoice-memo">{{ msg.typed_payload.error }}</div>
|
<div v-if="msg.typed_payload.error" class="mesh-typed-invoice-memo">{{ msg.typed_payload.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="msg.message_type === 'content_ref' && msg.typed_payload" class="mesh-typed-content">
|
||||||
|
<div class="mesh-typed-content-meta">
|
||||||
|
<span class="mesh-typed-icon">📎</span>
|
||||||
|
<span class="mesh-typed-label">{{ msg.typed_payload.filename || msg.typed_payload.mime }}</span>
|
||||||
|
<span class="mesh-typed-content-size">{{ msg.typed_payload.size }} B</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="msg.typed_payload.caption" class="mesh-typed-content-caption">{{ msg.typed_payload.caption }}</div>
|
||||||
|
<template v-if="fetchedUrls.get(msg.typed_payload.cid)">
|
||||||
|
<img
|
||||||
|
v-if="isImageMime(msg.typed_payload.mime)"
|
||||||
|
:src="fetchedUrls.get(msg.typed_payload.cid)"
|
||||||
|
class="mesh-typed-content-preview"
|
||||||
|
alt="attachment"
|
||||||
|
/>
|
||||||
|
<a v-else :href="fetchedUrls.get(msg.typed_payload.cid)" target="_blank" class="btn">Open</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="msg.direction === 'sent'">
|
||||||
|
<span class="mesh-typed-content-hint">(shared from this node)</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||||
|
@click="handleFetchContent(msg.typed_payload as any)"
|
||||||
|
>
|
||||||
|
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<!-- Default: plain text -->
|
<!-- Default: plain text -->
|
||||||
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
|
||||||
<div class="mesh-chat-bubble-meta">
|
<div class="mesh-chat-bubble-meta">
|
||||||
@ -773,11 +882,20 @@ async function verifyBlobRoundTrip() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="mesh-chat-compose">
|
<div class="mesh-chat-compose">
|
||||||
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
|
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
|
||||||
|
<div v-if="attachError" class="mesh-chat-send-error">{{ attachError }}</div>
|
||||||
<div class="mesh-chat-compose-row">
|
<div class="mesh-chat-compose-row">
|
||||||
|
<label
|
||||||
|
v-if="activeChatPeer"
|
||||||
|
class="glass-button mesh-chat-attach-btn"
|
||||||
|
:title="attaching ? 'uploading…' : 'Attach file'"
|
||||||
|
>
|
||||||
|
<input type="file" @change="handleAttachFile" style="display:none;" :disabled="attaching" />
|
||||||
|
{{ attaching ? '…' : '📎' }}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="messageText"
|
v-model="messageText"
|
||||||
class="mesh-chat-input"
|
class="mesh-chat-input"
|
||||||
placeholder="Type a message..."
|
:placeholder="activeChatPeer ? 'Type a message or pick a file…' : 'Type a message...'"
|
||||||
maxlength="160"
|
maxlength="160"
|
||||||
@keydown.enter.exact.prevent="handleSendMessage"
|
@keydown.enter.exact.prevent="handleSendMessage"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user