feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes

UI (this session):
- Global audio player now scales the whole interface into the space above it
  on desktop (sidebar + main) and docks directly above the tab bar on mobile;
  it stays visible while navigating.
- Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip
  with a single fixed, internally-scrolling pane (page no longer scrolls);
  tabs hide while a conversation is open; floating back button; collapsible
  Device panel (starts collapsed); keyboard-aware conversation sizing via
  VisualViewport so the chat sits just above the keyboard.
- Cloud file grid: uniform 4/3 card heights (folders + images match).
- Swipe left/right switches tabs on the Apps and Web5 screens.
- Map tool fills its pane (no bottom gap); fix skewed Share Location toggle
  on mobile (global min-height rule was deforming the switch).
- Trim redundant helper copy from the mesh AI tab.

Also bundles pre-existing in-progress work that was already in the tree:
mesh listener/session + wallet + container + bitcoin-status backend changes,
docker UI updates, and assorted other UI tweaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-19 09:52:26 -04:00
co-authored by Claude Opus 4.8
parent c4855526fe
commit 1bce694ebb
37 changed files with 1260 additions and 208 deletions
+184 -32
View File
@@ -316,14 +316,14 @@
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="payWithInvoice"
@click="openQrPay"
>
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
<span>
<span class="block text-base text-white">Pay from another wallet (QR)</span>
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
<span class="block text-sm text-white/50">Scan an on-chain or Lightning QR with any wallet</span>
</span>
</button>
@@ -344,40 +344,84 @@
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
</div>
<!-- Step 2: Lightning invoice -->
<div v-else class="text-center">
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<span class="text-sm text-white/70">Requesting invoice from seller</span>
<!-- Step 2: pay from another wallet tabbed QR (on-chain default) -->
<div v-else>
<!-- Method tabs, styled like the wallet Send/Receive modal -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning'] as const)"
:key="m"
@click="selectQrTab(m)"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="qrTab === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? 'On-chain' : 'Lightning' }}</button>
</div>
<div v-else-if="invoiceData">
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<!-- On-chain QR -->
<div v-if="qrTab === 'onchain'" class="text-center">
<div v-if="onchainWaiting && !onchainData" class="py-10 flex flex-col items-center gap-3">
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
</button>
<span class="text-sm text-white/70">Requesting an address from the seller</span>
</div>
<div v-else-if="onchainData">
<div v-if="onchainQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="onchainQr" alt="On-chain payment QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ onchainData.amount_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ onchainData.address }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyOnchain">
{{ onchainCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-[10px] text-white/40 mt-2">Needs 1 confirmation before the file unlocks.</p>
</div>
<p v-if="onchainError" class="text-sm text-red-400 mt-3">{{ onchainError }}</p>
</div>
<!-- Lightning invoice QR -->
<div v-else class="text-center">
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<span class="text-sm text-white/70">Requesting invoice from seller</span>
</div>
<div v-else-if="invoiceData">
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
</div>
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
<button
v-if="invoiceError"
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
class="glass-button px-4 py-2 rounded-lg text-sm mt-4 w-full"
@click="payMode = 'choose'"
>
Back
@@ -453,12 +497,21 @@ const audioPlayer = useAudioPlayer()
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
// they can pay from any external wallet by scanning a QR.
const payItem = ref<CatalogItem | null>(null)
const payMode = ref<'choose' | 'invoice'>('choose')
const payMode = ref<'choose' | 'qr'>('choose')
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
// on-chain first (the default).
const qrTab = ref<'onchain' | 'lightning'>('onchain')
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
const invoiceQr = ref('')
const invoiceWaiting = ref(false)
const invoiceError = ref('')
const invoiceCopied = ref(false)
// On-chain QR (pay the seller's address from any external wallet).
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
const onchainQr = ref('')
const onchainWaiting = ref(false)
const onchainError = ref('')
const onchainCopied = ref(false)
const lnPaying = ref(false)
const lnError = ref('')
const onchainPaying = ref(false)
@@ -660,11 +713,17 @@ async function downloadFile(item: CatalogItem) {
function openPayModal(item: CatalogItem) {
payItem.value = item
payMode.value = 'choose'
qrTab.value = 'onchain'
invoiceData.value = null
invoiceQr.value = ''
invoiceWaiting.value = false
invoiceError.value = ''
invoiceCopied.value = false
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = false
onchainError.value = ''
onchainCopied.value = false
lnPaying.value = false
lnError.value = ''
onchainPaying.value = false
@@ -675,9 +734,102 @@ function closePayModal() {
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
payItem.value = null
invoiceWaiting.value = false
onchainWaiting.value = false
onchainPaying.value = false
}
/**
* Open the "pay from another wallet" view: tabbed QR like the wallet's
* Send/Receive modal, defaulting to the on-chain tab (so a QR is shown
* immediately for any external wallet).
*/
function openQrPay() {
payMode.value = 'qr'
qrTab.value = 'onchain'
invoiceData.value = null
invoiceQr.value = ''
invoiceError.value = ''
invoiceWaiting.value = false
onchainData.value = null
onchainQr.value = ''
onchainError.value = ''
loadOnchainQr()
}
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
* resuming its payment poll if it was already loaded (so switching back and
* forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return
qrTab.value = tab
if (tab === 'onchain') {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
if (!onchainData.value && !onchainWaiting.value) {
loadOnchainQr()
} else if (onchainData.value && !onchainPaying.value) {
onchainPaying.value = true
pollOnchain(onchainData.value.address)
}
} else {
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
onchainPaying.value = false
if (!invoiceData.value && !invoiceWaiting.value) {
payWithInvoice()
} else if (invoiceData.value) {
scheduleInvoicePoll()
}
}
}
/**
* On-chain QR: ask the seller for a fresh address + amount, render a
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
* lands, then release the file (the address is the gate token).
*/
async function loadOnchainQr() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
onchainError.value = ''
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = true
try {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
method: 'content.request-onchain',
params: { onion, content_id: item.id },
timeout: 45000,
})
if (!req?.address || !req?.amount_sats) {
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
onchainWaiting.value = false
return
}
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
const btc = (req.amount_sats / 1e8).toFixed(8)
try {
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
} catch {
onchainQr.value = '' // fall back to showing the raw address
}
onchainWaiting.value = false
onchainPaying.value = true // "waiting for payment" — drives the poll loop
pollOnchain(req.address)
} catch (e: unknown) {
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
onchainWaiting.value = false
}
}
async function copyOnchain() {
if (!onchainData.value) return
try {
await navigator.clipboard.writeText(onchainData.value.address)
onchainCopied.value = true
setTimeout(() => { onchainCopied.value = false }, 1500)
} catch { /* clipboard denied */ }
}
/**
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
@@ -695,7 +847,7 @@ async function payOnchain() {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
method: 'content.request-onchain',
params: { onion, content_id: item.id },
timeout: 60000,
timeout: 45000,
})
if (!req?.address || !req?.amount_sats) {
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
@@ -805,14 +957,14 @@ async function payWithInvoice() {
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
payMode.value = 'invoice'
payMode.value = 'qr'
invoiceError.value = ''
invoiceWaiting.value = true
try {
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 60000,
timeout: 45000,
})
if (!res?.bolt11 || !res?.payment_hash) {
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
@@ -849,7 +1001,7 @@ async function payWithLightning() {
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 60000,
timeout: 45000,
})
if (!inv?.bolt11 || !inv?.payment_hash) {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'