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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<PendingAttachment | null>(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<string | null>(null)
|
||||
@@ -883,6 +940,12 @@ async function verifyBlobRoundTrip() {
|
||||
<div class="mesh-chat-compose">
|
||||
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
|
||||
<div v-if="attachError" class="mesh-chat-send-error">{{ attachError }}</div>
|
||||
<div v-if="pendingAttachment" class="mesh-chat-pending-attachment">
|
||||
<span class="mesh-typed-icon">📎</span>
|
||||
<span class="mesh-chat-pending-name">{{ pendingAttachment.filename || pendingAttachment.mime }}</span>
|
||||
<span class="mesh-chat-pending-size">{{ pendingAttachment.size }} B</span>
|
||||
<button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment">✕</button>
|
||||
</div>
|
||||
<div class="mesh-chat-compose-row">
|
||||
<label
|
||||
v-if="activeChatPeer"
|
||||
@@ -901,10 +964,10 @@ async function verifyBlobRoundTrip() {
|
||||
/>
|
||||
<button
|
||||
class="glass-button mesh-chat-send-btn"
|
||||
:disabled="!messageText.trim() || mesh.sending || sendingArch"
|
||||
:disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
{{ (mesh.sending || sendingArch) ? '...' : 'Send' }}
|
||||
{{ (mesh.sending || sendingArch) ? '...' : (pendingAttachment ? 'Share' : 'Send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user