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
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+35 -4
View File
@@ -1,12 +1,10 @@
<template>
<!-- Spacer to prevent content from being hidden behind the player -->
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="audioPlayer.currentName.value"
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar"
ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
>
<!-- Progress bar (clickable) -->
<div
@@ -60,9 +58,38 @@
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const audioPlayer = useAudioPlayer()
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
// reserve space for it (the same mechanism the mobile tab bar uses). This is
// what pushes the rest of the site up instead of letting the fixed bar overlap
// and block the bottom controls — on desktop AND mobile, on every page.
function setPlayerHeightVar() {
if (typeof document === 'undefined') return
const h = barEl.value?.offsetHeight || 60
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
document.documentElement.classList.add('audio-active')
}
function clearPlayerHeightVar() {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--audio-player-height', '0px')
document.documentElement.classList.remove('audio-active')
}
watch(() => audioPlayer.currentName.value, (name) => {
if (name) {
nextTick(setPlayerHeightVar)
} else {
clearPlayerHeightVar()
}
}, { immediate: true })
onBeforeUnmount(clearPlayerHeightVar)
function togglePlay() {
if (audioPlayer.playing.value) {
@@ -90,6 +117,10 @@ function formatTime(seconds: number): string {
<style scoped>
.audio-player-bar {
/* Sit directly above the mobile tab bar (its height is published as
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
resolves to 0px and the bar docks flush to the bottom of the viewport. */
bottom: var(--mobile-tab-bar-height, 0px);
background: rgba(15, 15, 15, 0.55);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
+3
View File
@@ -471,6 +471,9 @@ onUnmounted(() => {
.mesh-map-toggle {
width: 36px;
height: 20px;
/* The global mobile rule forces buttons to min-height:44px, which stretches
this switch and pushes the knob off-centre. Pin it back to the pill size. */
min-height: 20px !important;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
@@ -47,7 +47,7 @@
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
</div>
@@ -119,7 +119,7 @@ async function receive() {
if (receiveMethod.value === 'lightning') {
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
const res = await rpcClient.call<{ payment_request: string }>({
method: 'lnd.addinvoice',
method: 'lnd.createinvoice',
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
})
invoiceResult.value = res.payment_request
@@ -133,11 +133,16 @@ async function receive() {
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else {
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
await rpcClient.call<{ amount_sats: number }>({
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
// redeemed at its mint, anything else is reissued as Fedimint notes.
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
method: 'wallet.ecash-receive',
params: { token: ecashToken.value.trim() },
})
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
ecashResult.value = res.received_sats != null
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
: t('receiveBitcoin.tokenReceivedSuccess')
emit('received')
}
} catch (err: unknown) {
@@ -152,11 +152,10 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
const aspectClass = computed(() => {
if (isImage.value || isVideo.value) return 'aspect-square'
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
return 'aspect-square'
})
// Uniform card cover ratio across every file type so folders, images, videos
// and documents all render at the same height in the grid (previously images/
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
const aspectClass = computed(() => 'aspect-[4/3]')
const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10'
+9 -3
View File
@@ -613,9 +613,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ─── Scroll Support ────────────────────────────────────────
function handleWheel(e: WheelEvent) {
const active = document.activeElement as HTMLElement | null
if (!active) return
let p = active.parentElement
// Scroll the container UNDER THE POINTER, not the focused element. Real
// wheel events always target the element beneath the cursor, so walking up
// from e.target matches native behaviour. Using document.activeElement here
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
// peer list, still focused after a click) instead of the panel actually
// being hovered — producing a double-scroll where both moved at once.
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
if (!start) return
let p: HTMLElement | null = start
while (p) {
const style = getComputedStyle(p)
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
+8
View File
@@ -127,6 +127,13 @@ export interface BlockHeader {
announced_by: string
}
export interface DeniedAsker {
contact_id: number
name: string
pubkey_hex: string | null
at: string
}
export interface AssistantStatus {
enabled: boolean
model: string | null
@@ -137,6 +144,7 @@ export interface AssistantStatus {
ollama_detected: boolean
claude_available: boolean
models: string[]
denied_askers?: DeniedAsker[]
}
export interface ScheduledMessage {
+38 -10
View File
@@ -130,19 +130,45 @@ select:focus-visible {
}
}
/* Scroll container bottom padding — desktop breathing room */
/* Height of the global audio player bar — 0 unless it is visible. Set on
<html> by GlobalAudioPlayer.vue. Scroll containers add it to their bottom
padding so the fixed player pushes content up instead of covering it. */
:root {
--audio-player-height: 0px;
}
/* Scroll container bottom padding — desktop breathing room. (On desktop the
audio player instead shrinks the whole #main-content area — see the
html.audio-active rule below — so no player offset is added here.) */
.mobile-scroll-pad,
.mobile-scroll-pad-back {
padding-bottom: 6rem;
}
/* Audio player docked: shrink the whole interface into the space above it so
the entire view scales up (like the AIUI iframe) instead of just gaining
scroll padding. On desktop the player spans full width and BOTH the sidebar
and the main content scale into the reduced height above it. Mobile keeps the
tab-bar + player handled via .mobile-scroll-pad padding. */
@media (min-width: 768px) {
html.audio-active .dashboard-view {
height: calc(100dvh - var(--audio-player-height, 0px));
min-height: 0;
overflow: hidden;
}
/* Sidebar uses h-screen (100vh) — pin it to the reduced container height. */
html.audio-active .dashboard-view [data-controller-zone="sidebar"] {
height: 100%;
}
}
/* Mobile: override with tab bar clearance */
@media (max-width: 767px) {
.mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
}
.mobile-scroll-pad-back {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
}
/* Safe area top padding for all mobile content views.
@@ -174,11 +200,11 @@ select:focus-visible {
}
.mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
}
.mobile-scroll-pad-back {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
}
.mobile-safe-top {
@@ -525,6 +551,7 @@ input[type="radio"]:active + * {
position: relative;
}
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
content extending behind browser chrome (address bar / toolbar). */
@media (max-width: 767px) {
@@ -555,8 +582,8 @@ input[type="radio"]:active + * {
context) stay above the tab bar instead of sliding underneath it. */
@media (max-width: 767px) {
.chat-iframe-mobile {
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
flex: none;
}
}
@@ -1926,18 +1953,19 @@ html.modal-scroll-locked .dashboard-scroll-panel {
background: rgba(0, 0, 0, 0.4);
}
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */
/* ── Mobile floating back/close button (always 8px above tab bar — and above
the audio player too when it is showing, so it never hides behind it) ── */
.mobile-back-btn {
position: fixed;
left: 1rem;
right: 1rem;
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px);
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
z-index: 40;
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
.mobile-filter-btn {
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
}
+59 -21
View File
@@ -95,9 +95,11 @@
/>
<!-- Re-key on the current folder path so the depth/zoom animation replays
at every level (folder subfolder ), not just on first entry.
Only the file content zooms; the header + breadcrumb nav above stay
fixed in place. -->
<Transition name="cloud-zoom" mode="out-in">
The transition name flips with navigation direction so descending
zooms forward and going back up zooms in reverse matching the
cloud folder route transition. Only the file content zooms; the
header + breadcrumb nav above stay fixed in place. -->
<Transition :name="folderTransition" mode="out-in">
<FileGrid
:key="cloudStore.currentPath"
:items="cloudStore.sortedItems"
@@ -177,6 +179,19 @@ const cloudStore = useCloudStore()
const viewMode = ref<'list' | 'grid'>('grid')
const audioPlayer = useAudioPlayer()
// Direction-aware folder zoom: descending into a subfolder plays the same
// "depth-forward" feel as the cloud folder route transition (new arrives from
// depth, current zooms out toward the viewer); navigating back up plays its
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
let prevFolderDepth = -1
watch(() => cloudStore.currentPath, (path) => {
const depth = path.split('/').filter(Boolean).length
// First render (prevFolderDepth === -1) defaults to forward.
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
prevFolderDepth = depth
})
const iframeLoaded = ref(false)
const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string)
@@ -395,37 +410,60 @@ function goBack() {
</script>
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
element, which lives outside this component's style scope. Matches the
`depth-forward` route transition feel (zoom in from depth + blur). -->
element, which lives outside this component's style scope. Mirrors the
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
blur) so descending into a folder and going back up feel identical to the
cloud folder route change. -->
<style>
.cloud-zoom-enter-active,
.cloud-zoom-leave-active {
.cloud-zoom-forward-enter-active,
.cloud-zoom-forward-leave-active,
.cloud-zoom-back-enter-active,
.cloud-zoom-back-leave-active {
transition:
opacity 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94);
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
will-change: opacity, transform, filter;
}
/* New folder zooms in from depth */
.cloud-zoom-enter-from {
/* Forward (into a deeper folder): new folder arrives from depth while the
current one zooms out toward the viewer matches depth-forward. */
.cloud-zoom-forward-enter-from {
opacity: 0;
transform: scale(0.82);
transform: scale(0.75);
filter: blur(4px);
}
/* Previous folder recedes forward as it leaves */
.cloud-zoom-leave-to {
.cloud-zoom-forward-leave-to {
opacity: 0;
transform: scale(1.12);
filter: blur(6px);
transform: scale(1.2);
filter: blur(8px);
}
/* Back (up to a parent folder): the mirror new folder shrinks in from the
front while the current one recedes into depth matches depth-back. */
.cloud-zoom-back-enter-from {
opacity: 0;
transform: scale(1.2);
filter: blur(8px);
}
.cloud-zoom-back-leave-to {
opacity: 0;
transform: scale(0.75);
filter: blur(4px);
}
@media (prefers-reduced-motion: reduce) {
.cloud-zoom-enter-active,
.cloud-zoom-leave-active {
.cloud-zoom-forward-enter-active,
.cloud-zoom-forward-leave-active,
.cloud-zoom-back-enter-active,
.cloud-zoom-back-leave-active {
transition: opacity 0.2s ease;
}
.cloud-zoom-enter-from,
.cloud-zoom-leave-to {
.cloud-zoom-forward-enter-from,
.cloud-zoom-forward-leave-to,
.cloud-zoom-back-enter-from,
.cloud-zoom-back-leave-to {
transform: none;
filter: none;
}
+68
View File
@@ -70,6 +70,8 @@
tabindex="-1"
@pointerenter="activateMainScroll"
@wheel.capture="activateMainScroll"
@touchstart.passive="onContentTouchStart"
@touchend.passive="onContentTouchEnd"
>
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
<!-- Controller zone entry point - no switcher -->
@@ -253,6 +255,72 @@ function activateMainScroll() {
}
}
// Swipe left/right to move between the mobile top tabs
// Apps screen: My Apps App Store Services
// Web5 screen: Web5 Cloud Network Mesh
// Only active while the matching tab strip is actually showing (mobile).
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
const APPS_TABS: TabTarget[] = [
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
]
const NET_TABS: TabTarget[] = [
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
]
function activeAppsKey(): string {
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
return ''
}
function activeNetKey(): string {
const p = route.path
if (p.startsWith('/dashboard/web5')) return 'web5'
if (p.startsWith('/dashboard/cloud')) return 'cloud'
if (p.startsWith('/dashboard/server')) return 'server'
if (p.startsWith('/dashboard/mesh')) return 'mesh'
return ''
}
let touchStartX = 0
let touchStartY = 0
let touchStartTime = 0
function onContentTouchStart(e: TouchEvent) {
const t = e.touches[0]
if (!t) return
touchStartX = t.clientX
touchStartY = t.clientY
touchStartTime = e.timeStamp
}
function onContentTouchEnd(e: TouchEvent) {
const t = e.changedTouches[0]
if (!t) return
const dx = t.clientX - touchStartX
const dy = t.clientY - touchStartY
const dt = e.timeStamp - touchStartTime
// Clear horizontal flick: far enough, mostly sideways, and quick.
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
const nav = mobileNavRef.value
if (!nav) return
let tabs: TabTarget[] | null = null
let activeKey = ''
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
if (!tabs) return
const idx = tabs.findIndex(tb => tb.key === activeKey)
if (idx < 0) return
const next = idx + (dx < 0 ? 1 : -1) // swipe left next tab, right previous
if (next < 0 || next >= tabs.length) return
router.push(tabs[next].to).catch(() => {})
}
watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value
+83 -27
View File
@@ -38,6 +38,8 @@ const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
// Device status panel starts collapsed on mobile (expandable via its header).
const deviceExpanded = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
let wsUnsub: (() => void) | null = null
@@ -261,6 +263,15 @@ const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('cha
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
// 'chat' shows the peers list / active conversation; the rest swap the pane to
// that tool. Selecting a tool leaves any open conversation.
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
mobileTab.value = tab
if (tab !== 'chat') mobileShowChat.value = false
}
// Panel visibility computeds
const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
@@ -268,28 +279,29 @@ const showChatPanel = computed(() =>
const showBitcoinPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
if (isMobile.value) return mobileTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
if (isMobile.value) return mobileTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showAssistantPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'assistant'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant'
if (isMobile.value) return mobileTab.value === 'assistant'
return activeTab.value === 'assistant'
})
const showMapPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map'
})
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
// Mobile: tool pane shows whenever a non-chat tab is active.
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
// Fetch session status when active peer changes
@@ -313,10 +325,25 @@ async function handleToggleOffGrid() {
} finally { togglingOffGrid.value = false }
}
// Track the on-screen keyboard height (mobile) so the conversation pane + back
// button can sit just above it fixed elements ignore the keyboard otherwise
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
function updateKeyboardInset() {
const vv = window.visualViewport
if (!vv) return
const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleDocClickForMenu)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateKeyboardInset)
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
updateKeyboardInset()
}
loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
refreshOutboxCount()
@@ -355,6 +382,11 @@ onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleDocClickForMenu)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
}
document.documentElement.style.removeProperty('--keyboard-inset')
if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
if (wsUnsub) { wsUnsub(); wsUnsub = null }
@@ -885,10 +917,11 @@ function scrollChatToBottom() {
}
// Wheel over the chat must scroll ONLY the chat never leak to the contacts
// list or the page. CSS overscroll-behavior wasn't enough (the leak happens
// even when the chat doesn't overflow), so consume the wheel and apply it to
// the chat container directly. Used with `@wheel.prevent` so the default
// (page/contacts) scroll never fires.
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
// from reaching the global controller-nav wheel handler (which would otherwise
// also scroll whatever container is focused, e.g. the peer list after a click),
// and `.prevent` stops the native page scroll. We then apply the delta to the
// chat container directly.
function onChatWheel(e: WheelEvent) {
const el = chatScrollEl.value
if (!el) return
@@ -1354,12 +1387,15 @@ function isImageMime(mime?: string): boolean {
<!-- Responsive column layout -->
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
<!-- Device Status -->
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
<div class="mesh-status-header">
<div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
<div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
<h2 class="mesh-section-title">Device</h2>
<svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>
@@ -1543,7 +1579,7 @@ function isImageMime(mime?: string): boolean {
</div>
<!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }">
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
<!-- Tab bar (medium desktop only) -->
<div v-if="showTabBar" class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
@@ -1561,15 +1597,29 @@ function isImageMime(mime?: string): boolean {
</div>
<!-- Chat Panel -->
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
<div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div>
<p>Select a peer or channel to chat</p>
<p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
</div>
<template v-else>
<!-- Mobile: floating back button (shared glass pill style), pinned
above the tab bar replaces the in-header arrow so the back
control is no longer crammed inside the chat container. -->
<Teleport to="body">
<button
type="button"
class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
@click="closeChat"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back</span>
</button>
</Teleport>
<div class="mesh-chat-header">
<button class="mesh-chat-back" @click="closeChat">&larr;</button>
<div class="mesh-chat-header-info">
<div class="mesh-chat-header-name">
<template v-if="renamingActive">
@@ -1604,7 +1654,7 @@ function isImageMime(mime?: string): boolean {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div>
</div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.prevent="onChatWheel">
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello!
</div>
@@ -1851,17 +1901,6 @@ function isImageMime(mime?: string): boolean {
<!-- Mobile tools: show under peers list on first view -->
<div v-if="showMobileTools" class="mesh-mobile-tools">
<div class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
AI
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
</div>
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" />
@@ -1869,6 +1908,23 @@ function isImageMime(mime?: string): boolean {
</div>
</div>
<!-- Mobile: floating tab strip pinned above the global tab bar (same
placement as the mobile back button). Switches the whole pane between
the chat and each tool. Hidden while an individual conversation is open
(the back button takes over there). -->
<Teleport to="body">
<div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
<button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
</div>
</Teleport>
<!-- 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')">
+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?).'
+1 -1
View File
@@ -136,7 +136,7 @@
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">FIPS Mesh</span>
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
</div>
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
</div>
+1 -3
View File
@@ -122,9 +122,7 @@
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
+50 -8
View File
@@ -88,6 +88,22 @@ function addPubkey() {
apply({ allowed_contacts: allowedContacts.value })
}
// Radio/peers who recently tried `!ai` and were turned away by the policy.
// Surfaced so the operator can one-click allow them instead of digging through
// the journal for the firmware key. Hide any we've since allowed.
const deniedAskers = computed(() =>
(status.value?.denied_askers ?? []).filter(
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
),
)
function allowDenied(pubkey: string | null) {
if (!pubkey) return
if (!isAllowed(pubkey)) {
allowedContacts.value = [...allowedContacts.value, pubkey]
apply({ allowed_contacts: allowedContacts.value })
}
}
onMounted(() => {
mesh.fetchAssistantStatus()
})
@@ -138,7 +154,6 @@ function onPolicy() {
<template>
<div class="glass-card mesh-assistant-panel">
<h3 class="mesh-panel-title">AI Assistant</h3>
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
<!-- Backend chooser -->
<div class="mesh-assistant-field">
@@ -206,10 +221,8 @@ function onPolicy() {
<option value="trusted">Trusted nodes only</option>
<option value="anyone">Anyone on the mesh</option>
</select>
<p class="text-xs text-white/40 mt-1">
{{ policy === 'anyone'
? 'Any peer can spend this node\'s AI budget + airtime.'
: 'Only federation-trusted peers may ask.' }}
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
Any peer can spend this node's AI budget + airtime.
</p>
</div>
@@ -217,9 +230,6 @@ function onPolicy() {
policy is "trusted only" and they aren't federation-trusted. -->
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Always allow these contacts</label>
<p class="text-xs text-white/40 mb-2">
Listed contacts can use <code>!ai</code> regardless of the policy above.
</p>
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
No contacts yet they appear here once you have mesh/federation contacts.
</div>
@@ -256,6 +266,38 @@ function onPolicy() {
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
</div>
<!-- Recently denied askers: someone tried !ai but the policy turned them
away. Show who, and offer a one-click Allow when we know their key. -->
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Recently denied</label>
<p class="text-xs text-white/40 mb-2">
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
</p>
<div class="mesh-assistant-allowlist">
<div
v-for="d in deniedAskers"
:key="d.contact_id + (d.pubkey_hex || '')"
class="mesh-assistant-allow-row"
>
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
{{ d.name || ('#' + d.contact_id) }}
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}</span>
</span>
<button
v-if="d.pubkey_hex"
type="button"
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
@click="allowDenied(d.pubkey_hex)"
>
Allow
</button>
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
no key
</span>
</div>
</div>
</div>
<p class="text-xs text-white/50 mt-2">
Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel.
</p>
+116 -9
View File
@@ -62,6 +62,10 @@
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
/* Collapse chevron only used on mobile (hidden on desktop, where the Device
panel is always expanded). Kept small and pushed to the far right. */
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
@@ -90,7 +94,7 @@
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; }
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
.mesh-peer-reach.is-reachable { background: #34d399; }
@@ -120,7 +124,9 @@
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); }
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; }
/* Floating mobile back button (Teleported to body). Hidden by default; only
shown in the single-column mobile mesh layout (see media query below). */
.mesh-chat-mobile-back { display: none; }
.mesh-chat-header-info { flex: 1; min-width: 0; }
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
@@ -155,22 +161,115 @@
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
.mesh-mobile-back-btn { display: none; }
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
1279px block flips it to flex and the placement mirrors the mobile back
button (pinned above the global tab bar + audio player). */
.mesh-mobile-tabbar {
display: none;
position: fixed;
left: 12px;
right: 12px;
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
z-index: 40;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.mesh-mtab {
flex: 1 1 0;
min-width: 0;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
@media (max-width: 1279px) {
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
.mesh-columns { flex-direction: column; overflow: visible; }
.mesh-left { width: 100%; overflow: visible; }
.mesh-right { min-height: auto; overflow: visible; }
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
/* Single-column mobile mesh: one fixed, internally-scrolling pane that
fills the space between the top tab strip and the floating mesh tab bar.
The page itself never scrolls; each pane scrolls inside its own bounds.
Fixed positioning is relative to the full-height perspective container, so
the offsets line up with the body-teleported tab bar / back button. */
.mesh-left,
.mesh-mobile-tools,
.mesh-chat-card.mesh-chat-card-active {
position: fixed;
left: 12px;
right: 12px;
/* width:auto so left+right govern the box .mesh-left otherwise carries a
fixed 380px width that ignores `right` and overflows the screen. */
width: auto;
box-sizing: border-box;
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
height: auto;
min-height: 0;
max-height: none;
overflow-y: auto;
overscroll-behavior: contain;
z-index: 30;
}
/* Active conversation: the floating tabs are hidden here and the back button
takes the standard spot above the tab bar, so the chat window fills down to
just above the back pill (back pill 44px at +8, plus a 16px gap). When the
keyboard is up it covers the tab bar, so anchor to whichever is taller the
bottom controls or the keyboard so the window sits right above both. */
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
overflow: hidden; /* the messages list inside does the scrolling */
}
.mesh-tools-wrapper { display: none !important; }
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
.mesh-mobile-tools :deep(.mesh-assistant-panel),
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
bottom margin); the panel itself scrolls if its content is taller. */
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
.mesh-mobile-tools .mesh-bitcoin-panel,
.mesh-mobile-tools .mesh-assistant-panel,
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
.mesh-chat-back { display: block; }
/* In a conversation the tabs are hidden, so the back pill sits just above the
tab bar or above the keyboard when it's up, whichever is taller. */
.mesh-chat-mobile-back { display: flex; }
.mesh-chat-mobile-back.mobile-back-btn {
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
}
/* Floating mesh tab strip — same placement logic as the mobile back button. */
.mesh-mobile-tabbar { display: flex; }
.mobile-hidden { display: none !important; }
/* Device panel is a collapsible/expandable accordion on mobile (starts
collapsed). Show the chevron, make the header tappable, and hide the body
when collapsed. */
.mesh-status-chevron { display: block; }
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
:deep(.mesh-bitcoin-panel),
:deep(.mesh-assistant-panel),
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
@@ -181,6 +280,14 @@
.mesh-view {
padding: 24px;
}
/* In this range the desktop sidebar (256px) is still shown. The in-pane
fixed elements are positioned relative to the main content area (their
perspective containing block), so they already clear the sidebar but the
body-teleported floating bars are viewport-relative, so nudge them right. */
.mesh-mobile-tabbar,
.mesh-chat-mobile-back.mobile-back-btn {
left: 268px;
}
}
@media (max-width: 920px) {
@@ -8,7 +8,7 @@
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>