From ab927afbaa9b725514d4321e36ea5a2be4ce6b6c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 13 Apr 2026 12:58:04 -0400 Subject: [PATCH] feat(mesh-ui): receive share-to-mesh postMessage + pending attachment App.vue listens for postMessage({type:'share-to-mesh',cid,...}) from marketplace app iframes, stashes the payload in sessionStorage, and routes to /mesh. Mesh.vue reads the stash on mount (and on a synthetic 'archipelago:share-to-mesh' event when already on the view), showing a pending-attachment banner in the compose area. Send becomes Share and flushes the CID via mesh.send-content with the input text as caption. Co-Authored-By: Claude Opus 4.6 (1M context) --- neode-ui/src/App.vue | 24 +++++++++++++ neode-ui/src/views/Mesh.vue | 67 +++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 24f949f0..685189a2 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -212,6 +212,7 @@ onMounted(async () => { window.addEventListener('mousedown', onUserActivity) window.addEventListener('keydown', onUserActivity) window.addEventListener('touchstart', onUserActivity) + window.addEventListener('message', onShareToMeshMessage) const seenIntro = localStorage.getItem('neode_intro_seen') === '1' const isDirectRoute = route.path !== '/' const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1' @@ -241,8 +242,31 @@ onBeforeUnmount(() => { window.removeEventListener('mousedown', onUserActivity) window.removeEventListener('keydown', onUserActivity) window.removeEventListener('touchstart', onUserActivity) + window.removeEventListener('message', onShareToMeshMessage) }) +/** + * Phase 3c: marketplace app iframes share files into mesh chats by POSTing + * to /api/share-to-mesh then postMessaging the CID back to this parent + * window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the + * stash on mount and stages it as a pending attachment. + */ +function onShareToMeshMessage(ev: MessageEvent) { + const data = ev.data as { type?: string; cid?: string } | null + if (!data || data.type !== 'share-to-mesh' || !data.cid) return + try { + sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data)) + } catch { + /* quota — fall through */ + } + if (route.path !== '/mesh') { + router.push('/mesh') + } else { + // Already on /mesh — dispatch a synthetic event so the view picks it up. + window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh')) + } +} + /** * Handle splash screen completion * Routes user directly to appropriate screen based on onboarding status (from backend) diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 64b544f7..0ddf7591 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -177,6 +177,8 @@ async function handleToggleOffGrid() { onMounted(async () => { window.addEventListener('resize', handleResize) + window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession) + loadPendingFromSession() await Promise.all([mesh.refreshAll(), transport.fetchStatus()]) // Start background polling for Archipelago (Tor) messages so unread count works loadArchMessages() @@ -194,6 +196,7 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('resize', handleResize) + window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession) if (pollInterval) clearInterval(pollInterval) if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null } }) @@ -307,6 +310,23 @@ async function handleSendMessage() { nextTick(() => scrollChatToBottom()) return } + // Pending share-to-mesh attachment: Send flushes the CID as a ContentRef + // rather than a plain text message. Any text in the input becomes the + // caption. Only valid for direct peers (channel broadcast of content_ref + // isn't in scope for Phase 3c). + if (pendingAttachment.value && activeChatPeer.value) { + sendError.value = '' + try { + const caption = messageText.value.trim() || undefined + await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption) + messageText.value = '' + pendingAttachment.value = null + nextTick(() => scrollChatToBottom()) + } catch (err: unknown) { + sendError.value = err instanceof Error ? err.message : 'Share failed' + } + return + } if (!messageText.value.trim()) return sendError.value = '' try { @@ -424,6 +444,43 @@ async function handleBlobUpload(ev: Event) { } } +// ── share-to-mesh iframe intent (Phase 3c) ──────────────────────────────── +// Marketplace app iframes POST a file to `/api/share-to-mesh` then call +// `window.parent.postMessage({type:'share-to-mesh', cid, ...})`. We park the +// CID as a pending attachment; next time the user picks a peer and hits Send +// (with optional caption text), we call mesh.send-content on that CID. +interface PendingAttachment { + cid: string + size: number + mime: string + filename: string | null + self_url?: string +} +const pendingAttachment = ref(null) + +function loadPendingFromSession() { + try { + const raw = sessionStorage.getItem('archipelago_share_to_mesh') + if (!raw) return + sessionStorage.removeItem('archipelago_share_to_mesh') + const data = JSON.parse(raw) as { cid?: string; size?: number; mime?: string; filename?: string | null; self_url?: string } + if (!data.cid) return + pendingAttachment.value = { + cid: data.cid, + size: data.size ?? 0, + mime: data.mime ?? 'application/octet-stream', + filename: data.filename ?? null, + self_url: data.self_url, + } + } catch { + /* ignore */ + } +} + +function clearPendingAttachment() { + pendingAttachment.value = null +} + // ── ContentRef attach + fetch (Phase 3b) ────────────────────────────────── const attaching = ref(false) const attachError = ref(null) @@ -883,6 +940,12 @@ async function verifyBlobRoundTrip() {
{{ sendError }}
{{ attachError }}
+
+ 📎 + {{ pendingAttachment.filename || pendingAttachment.mime }} + {{ pendingAttachment.size }} B + +