feat(mesh): Telegram primitives pass + attachment transport router
Bundles the Phase 2b/3/4/5 work that accumulated across prior sessions and the new attachment chunking router from this session. Everything ships in one shot so the full mesh surface stays coherent on-wire. Telegram primitives (variants 13–18, 20–22): - Reply / Reaction / ReadReceipt / Forward / Edit / Delete - Presence heartbeat + last-seen tracking - ChannelInvite + ContactCard payload types - MessageKey (sender_pubkey, sender_seq) as cross-transport identity - Action menu, reply banner, edit banner, tombstones, (edited) marker - Debounced auto-read-receipts on scroll + message arrival Activated prototypes (Phase 4): - PsbtHash send RPC - Contacts CRUD (in-memory alias/notes/pinned/blocked) - Outbox 📤 badge, rotate-prekeys button - Chunked send fallback (MCIIXXTT framing) as auto-failover inside send_typed_wire when a typed wire exceeds the LoRa per-frame budget Unified inbox (Phase 1): - conversations.list + conversations.messages RPCs (UI collapse deferred) Attachment transport router (new this session): - ContentInline variant 23 + ContentInlinePayload carrying file bytes directly in the envelope for small files with no Tor path - mesh.send-content-inline RPC — mirrors to local BlobStore, rides send_typed_wire which auto-chunks over MCIIXXTT framing (~2.3 KB cap) - mesh.transport-advice RPC as single source of truth for tier decisions: auto-mesh / choose / tor-only / impossible - Receive arm writes inline bytes to local BlobStore so the existing content_ref card renderer handles both transports uniformly - MeshState.blob_store field + order-independent propagation from RpcHandler::set_blob_store / set_mesh_service - Frontend handleAttachFile calls advice first, branches into silent auto-send, transport-chooser modal, Tor-only path, or red error - Transport modal with 📡 mesh / 🧅 Tor options + ETA + disabled state when peer has no Tor reachability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -407,6 +407,49 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function transportAdvice(contactId: number, size: number) {
|
||||
return rpcClient.call<{
|
||||
tier: 'auto-mesh' | 'choose' | 'tor-only' | 'impossible'
|
||||
est_seconds: number
|
||||
has_tor: boolean
|
||||
reason: string
|
||||
size: number
|
||||
mesh_auto_max: number
|
||||
mesh_hard_max: number
|
||||
}>({
|
||||
method: 'mesh.transport-advice',
|
||||
params: { contact_id: contactId, size },
|
||||
})
|
||||
}
|
||||
|
||||
async function sendContentInline(
|
||||
contactId: number,
|
||||
mime: string,
|
||||
bytes: Uint8Array,
|
||||
filename?: string,
|
||||
caption?: string,
|
||||
) {
|
||||
try {
|
||||
sending.value = true
|
||||
error.value = null
|
||||
// Base64-encode bytes for JSON transport.
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]!)
|
||||
const bytes_b64 = btoa(binary)
|
||||
const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({
|
||||
method: 'mesh.send-content-inline',
|
||||
params: { contact_id: contactId, mime, filename, caption, bytes_b64 },
|
||||
})
|
||||
if (res.sent) await fetchMessages()
|
||||
return res
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to send inline content'
|
||||
throw err
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply(contactId: number, targetPubkey: string, targetSeq: number, text: string) {
|
||||
sending.value = true
|
||||
try {
|
||||
@@ -633,6 +676,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
sendCoordinate,
|
||||
sendAlert,
|
||||
sendContent,
|
||||
sendContentInline,
|
||||
transportAdvice,
|
||||
fetchContent,
|
||||
sendReply,
|
||||
sendReaction,
|
||||
|
||||
@@ -1076,6 +1076,71 @@ const attachError = ref<string | null>(null)
|
||||
const fetchingCids = ref<Set<string>>(new Set())
|
||||
const fetchedUrls = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Transport chooser modal state — populated when advice comes back as
|
||||
// "choose" (size fits both inline-over-mesh AND Tor). User picks a path;
|
||||
// `transportChoiceResolve` finishes the promise started by handleAttachFile.
|
||||
interface PendingTransportChoice {
|
||||
file: File
|
||||
size: number
|
||||
est_seconds: number
|
||||
has_tor: boolean
|
||||
}
|
||||
const transportChoice = ref<PendingTransportChoice | null>(null)
|
||||
let transportChoiceResolve: ((choice: 'mesh' | 'tor' | 'cancel') => void) | null = null
|
||||
|
||||
function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
|
||||
if (transportChoiceResolve) {
|
||||
transportChoiceResolve(choice)
|
||||
transportChoiceResolve = null
|
||||
}
|
||||
transportChoice.value = null
|
||||
}
|
||||
|
||||
async function resolveFederationOnion(peerName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const fed = await rpcClient.federationListNodes()
|
||||
const hit = fed.nodes.find(
|
||||
(n: { name?: string; onion?: string }) =>
|
||||
(n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
|
||||
(n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
|
||||
peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
|
||||
)
|
||||
return hit?.onion ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaMeshInline(file: File, peerContactId: number) {
|
||||
const buf = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
await mesh.sendContentInline(
|
||||
peerContactId,
|
||||
file.type || 'application/octet-stream',
|
||||
bytes,
|
||||
file.name,
|
||||
messageText.value.trim() || undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async function sendViaTorContentRef(file: File, peerContactId: number, peerName: string) {
|
||||
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) throw new Error(`upload failed: ${up.status}`)
|
||||
const { cid } = (await up.json()) as { cid: string }
|
||||
const peerOnion = await resolveFederationOnion(peerName)
|
||||
await mesh.sendContent(peerContactId, cid, messageText.value.trim() || undefined, peerOnion)
|
||||
}
|
||||
|
||||
async function handleAttachFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -1085,50 +1150,37 @@ async function handleAttachFile(ev: Event) {
|
||||
if (input) input.value = ''
|
||||
return
|
||||
}
|
||||
const peer = activeChatPeer.value
|
||||
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}`
|
||||
const advice = await mesh.transportAdvice(peer.contact_id, file.size)
|
||||
let transport: 'mesh' | 'tor' | 'cancel'
|
||||
if (advice.tier === 'auto-mesh') {
|
||||
transport = 'mesh'
|
||||
} else if (advice.tier === 'tor-only') {
|
||||
transport = 'tor'
|
||||
} else if (advice.tier === 'impossible') {
|
||||
attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)`
|
||||
return
|
||||
} else {
|
||||
// "choose" — open modal and wait for user to pick
|
||||
transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => {
|
||||
transportChoiceResolve = resolve
|
||||
transportChoice.value = {
|
||||
file,
|
||||
size: file.size,
|
||||
est_seconds: advice.est_seconds,
|
||||
has_tor: advice.has_tor,
|
||||
}
|
||||
})
|
||||
if (transport === 'cancel') return
|
||||
}
|
||||
const { cid } = (await up.json()) as { cid: string }
|
||||
// Resolve the federation onion for this mesh peer. Meshcore adverts
|
||||
// don't carry an archipelago DID so the backend can't link them on its
|
||||
// own — we match on name (both sides use the node's display name).
|
||||
// Falls back to undefined; the backend will try its own DID lookup or
|
||||
// error out if no federation path exists.
|
||||
let peerOnion: string | undefined
|
||||
try {
|
||||
const fed = await rpcClient.federationListNodes()
|
||||
const peerName = activeChatPeer.value.advert_name
|
||||
const hit = fed.nodes.find(
|
||||
(n: { name?: string; onion?: string }) =>
|
||||
(n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
|
||||
(n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
|
||||
peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
|
||||
)
|
||||
peerOnion = hit?.onion ?? undefined
|
||||
} catch {
|
||||
/* non-fatal — backend will try its own lookup */
|
||||
if (transport === 'mesh') {
|
||||
await sendViaMeshInline(file, peer.contact_id)
|
||||
} else {
|
||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
||||
}
|
||||
await mesh.sendContent(
|
||||
activeChatPeer.value.contact_id,
|
||||
cid,
|
||||
messageText.value.trim() || undefined,
|
||||
peerOnion,
|
||||
)
|
||||
messageText.value = ''
|
||||
nextTick(() => scrollChatToBottom())
|
||||
} catch (e) {
|
||||
@@ -1698,6 +1750,37 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||
<div class="glass-card mesh-transport-modal">
|
||||
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
|
||||
<p class="mesh-transport-sub">
|
||||
<strong>{{ transportChoice.file.name }}</strong>
|
||||
· {{ (transportChoice.size / 1024).toFixed(1) }} KB
|
||||
</p>
|
||||
<div class="mesh-transport-options">
|
||||
<button class="mesh-transport-option" @click="pickTransport('mesh')">
|
||||
<span class="mesh-transport-icon">📡</span>
|
||||
<span class="mesh-transport-label">Over mesh</span>
|
||||
<span class="mesh-transport-meta">~{{ transportChoice.est_seconds }}s · works offline</span>
|
||||
</button>
|
||||
<button
|
||||
class="mesh-transport-option"
|
||||
:disabled="!transportChoice.has_tor"
|
||||
@click="pickTransport('tor')"
|
||||
>
|
||||
<span class="mesh-transport-icon">🧅</span>
|
||||
<span class="mesh-transport-label">Over Tor</span>
|
||||
<span class="mesh-transport-meta">
|
||||
{{ transportChoice.has_tor ? 'instant · needs onion' : 'no Tor path to peer' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -305,3 +305,18 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
||||
.mesh-chat-pending-clear { flex: 0 0 auto; align-self: center; margin-left: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.85); width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.95rem; line-height: 1; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
|
||||
.mesh-chat-pending-clear:hover { background: rgba(239,68,68,0.3); color: #fff; border-color: rgba(239,68,68,0.6); transform: scale(1.08); }
|
||||
.mesh-chat-pending-clear:active { transform: scale(0.92); }
|
||||
|
||||
/* Transport chooser modal (attachment size router) */
|
||||
.mesh-transport-modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.mesh-transport-modal { max-width: 420px; width: 92%; padding: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.mesh-transport-title { margin: 0; font-size: 1.1rem; color: #fff; }
|
||||
.mesh-transport-sub { margin: 0; color: rgba(255,255,255,0.6); font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||
.mesh-transport-options { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
|
||||
.mesh-transport-option { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); color: #fff; cursor: pointer; text-align: left; transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
|
||||
.mesh-transport-option:hover:not(:disabled) { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
|
||||
.mesh-transport-option:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.mesh-transport-icon { font-size: 1.5rem; flex: 0 0 auto; }
|
||||
.mesh-transport-label { flex: 1 1 auto; font-weight: 600; }
|
||||
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
|
||||
.mesh-transport-cancel:hover { color: #fff; }
|
||||
|
||||
Reference in New Issue
Block a user