feat(neode-ui): mobile app-launch UX — store-driven panel, loader, ElectrumX icon

- Mobile launches use the store-driven panel (no route push) so the background
  tab no longer changes and closing returns to where you launched from.
- Tab-only apps open directly (in-app WebView on companion / new tab on PWA) —
  no "this app opens in a tab" interstitial.
- Shared AppLoadingScreen (app icon + progress bar) on the app session and the
  legacy iframe overlay instead of a black screen.
- Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding
  under the bottom tab bar in mobile browsers (no-op in the companion WebView).
- ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps.
- isMobile made reactive so overlay/footer/teleport decisions track the viewport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-23 03:48:57 -04:00
co-authored by Claude Opus 4.8
parent db7d424bff
commit a7c7c44843
11 changed files with 387 additions and 56 deletions
+29 -13
View File
@@ -1,6 +1,6 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel">
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
<div
:class="backdropClasses"
@click.self="handleBackdropClick"
@@ -27,6 +27,7 @@
:app-url="appUrl"
:app-id="appId"
:app-title="appTitle"
:app-icon="appIcon"
:loading="loading"
:iframe-blocked="iframeBlocked"
:must-open-new-tab="mustOpenNewTab"
@@ -104,10 +105,10 @@ import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { launchBlockedReason } from './apps/appsConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl } from '@/utils/openExternal'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
import { useElectrsSync } from '@/composables/useElectrsSync'
const props = defineProps<{
@@ -154,9 +155,17 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appIcon = computed(() =>
packageEntry.value
? resolveAppIcon(appId.value, packageEntry.value)
: `/assets/img/app-icons/${appId.value}.png`
)
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// Reactive so the overlay/teleport/footer/animation decisions track the live
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
// ElectrumX shows a sync screen before its real UI (the Electrum server only
@@ -241,16 +250,18 @@ function setMode(mode: DisplayMode) {
}
}
// Reactive classes based on display mode
// Reactive classes based on display mode. On mobile the store-driven panel
// renders as a full-screen overlay (teleported to body) so it covers the nav
// and the underlying page never changes — desktop keeps the inline panel.
const backdropClasses = computed(() => {
if (isInlinePanel.value) return 'app-session-backdrop-inline'
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (isInlinePanel.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
return `${base} app-session-overlay`
})
@@ -370,10 +381,13 @@ watch(displayMode, (mode) => {
})
onMounted(() => {
// Apps that block iframes open externally on desktop. On mobile, keep the
// session surface visible so launcher taps do not bounce straight out.
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
// Open them directly instead of showing a "this app opens in a tab"
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
// or new tab (PWA). Then dismiss the (empty) session surface.
if (mustOpenNewTab.value && appUrl.value) {
if (isMobile.value) openInAppOrNewTab(appUrl.value)
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else closeRouteSession()
return
@@ -381,8 +395,9 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
window.addEventListener('resize', updateIsMobile)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
@@ -404,6 +419,7 @@ onBeforeUnmount(() => {
if (iframeCheckId) clearTimeout(iframeCheckId)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
window.removeEventListener('resize', updateIsMobile)
document.removeEventListener('fullscreenchange', onFullscreenChange)
screensaverStore.resume(screensaverReason.value)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})