feat(ui): app sessions overlay the current page in every display mode
Launching an app (or opening a tx in mempool from Home/wallet/channels) used to router.push the app-session page in overlay/fullscreen modes, swapping the page underneath — confusing and lossy. All display modes are now store-driven: panel renders beside the page, overlay/fullscreen render above it (teleported to body), and the route never changes. Deep-link paths (/tx/<hash>) ride along via the launcher's panelPath instead of a route query; closing always returns exactly where the user was. The app-session route stays for direct links. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa37155ff3
commit
7d7a7d08af
@ -258,13 +258,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
defineProps<{ compact?: boolean }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Channel {
|
||||
chan_id: string
|
||||
remote_pubkey: string
|
||||
@ -321,7 +319,8 @@ function fundingTxid(ch: Channel): string {
|
||||
|
||||
function openInMempool(txid: string) {
|
||||
if (!txid) return
|
||||
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txid}` } })
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
|
||||
}
|
||||
|
||||
function capacityPercent(amount: number, capacity: number): number {
|
||||
|
||||
@ -75,9 +75,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
interface WalletTransaction {
|
||||
tx_hash: string
|
||||
@ -100,7 +100,6 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
@ -120,7 +119,8 @@ function kindLabel(tx: WalletTransaction): string {
|
||||
|
||||
function openInMempool(tx: WalletTransaction) {
|
||||
if (!isOnchain(tx)) return
|
||||
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${tx.tx_hash}` } })
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` })
|
||||
}
|
||||
|
||||
function formatTxTime(timestamp: number): string {
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab } from '@/utils/openExternal'
|
||||
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
@ -209,25 +208,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Active app in panel mode (store-based, no route change) */
|
||||
/** Active app in the store-driven session (no route change) */
|
||||
const panelAppId = ref<string | null>(null)
|
||||
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
|
||||
const panelPath = ref<string | null>(null)
|
||||
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function dashboardReturnPath(): string {
|
||||
const current = router.currentRoute?.value
|
||||
if (!current) return '/dashboard/apps'
|
||||
const fullPath = current.fullPath || '/dashboard/apps'
|
||||
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
|
||||
return fullPath
|
||||
}
|
||||
|
||||
function openSession(appId: string) {
|
||||
function openSession(appId: string, opts: { path?: string } = {}) {
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
|
||||
// Demo: apps backed by a real external site that blocks iframing
|
||||
// (mempool.space) open externally; everything else demoable renders in the
|
||||
// in-app session.
|
||||
// Demo: apps backed by a real external site that blocks iframing open
|
||||
// externally; everything else demoable renders in the in-app session.
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) {
|
||||
@ -239,7 +230,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
|
||||
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
|
||||
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
|
||||
if (NEW_TAB_APP_IDS.has(appId)) {
|
||||
// In the demo, demoable apps are served same-origin by the mock backend
|
||||
// (no framing headers), so they always render in the in-app session.
|
||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||
const launchUrl = directAppUrl(appId)
|
||||
if (launchUrl) {
|
||||
if (mobile) openInAppOrNewTab(launchUrl)
|
||||
@ -248,21 +241,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Iframeable apps. Mobile and desktop-panel mode both use the store-driven
|
||||
// panel so the underlying page/tab never changes (no background swap) and
|
||||
// closing returns the user to wherever they launched from. Only desktop
|
||||
// overlay/fullscreen modes use a routed session.
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mobile || mode === 'panel') {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
router.push({ name: 'app-session', params: { appId }, query: { returnTo: dashboardReturnPath() } })
|
||||
}
|
||||
// Iframeable apps always use the store-driven session so the underlying
|
||||
// page never changes: panel mode renders beside the page, overlay and
|
||||
// fullscreen modes render above it (AppSession styles per display mode).
|
||||
// Closing always returns the user exactly where they launched from.
|
||||
panelPath.value = opts.path ?? null
|
||||
panelAppId.value = appId
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelAppId.value = null
|
||||
panelPath.value = null
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
@ -299,9 +288,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Route to full-page session if we can resolve an app ID from the URL
|
||||
// Open the in-app session if we can resolve an app ID from the URL,
|
||||
// carrying through any deep-link path (e.g. /tx/<hash>).
|
||||
if (resolvedId) {
|
||||
openSession(resolvedId)
|
||||
let deepPath: string | undefined
|
||||
try {
|
||||
const u = new URL(launchUrl, window.location.origin)
|
||||
let p = `${u.pathname}${u.search}${u.hash}`
|
||||
// /app/<id>/-style URLs carry the mount prefix — strip it so only the
|
||||
// app-internal path is treated as the deep link.
|
||||
const prefix = `/app/${resolvedId}`
|
||||
if (p.startsWith(prefix)) p = p.slice(prefix.length) || '/'
|
||||
if (p && p !== '/') deepPath = p
|
||||
} catch { /* no deep link */ }
|
||||
openSession(resolvedId, { path: deepPath })
|
||||
return
|
||||
}
|
||||
|
||||
@ -517,6 +517,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
panelPath,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
approveConsent,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="app-session-root">
|
||||
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
|
||||
<Teleport to="body" :disabled="inlinePanelMode">
|
||||
<div
|
||||
:class="backdropClasses"
|
||||
@click.self="handleBackdropClick"
|
||||
@ -92,7 +92,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@ -110,10 +110,12 @@ import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal } from '@/composables/useDemoIntro'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
|
||||
pathProp?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -167,10 +169,12 @@ const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value ==
|
||||
// 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 }
|
||||
// In the demo, apps backed by a real external site that blocks iframing
|
||||
// (mempool.space) open in a new tab rather than the in-app session frame.
|
||||
// In the demo, apps backed by a real external site that blocks iframing open
|
||||
// in a new tab rather than the in-app session frame. Demoable apps are served
|
||||
// same-origin by the mock backend, so the prod new-tab list doesn't apply.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(appId.value))
|
||||
(NEW_TAB_APPS.has(appId.value) && !(IS_DEMO && isDemoApp(appId.value))) ||
|
||||
(IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
@ -200,7 +204,8 @@ const screensaverSuppressedApps = new Set([
|
||||
|
||||
const appUrl = computed(() => {
|
||||
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
|
||||
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
|
||||
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
@ -227,16 +232,8 @@ function setMode(mode: DisplayMode) {
|
||||
displayMode.value = mode
|
||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
||||
|
||||
// Switch from inline panel to route-based overlay/fullscreen
|
||||
if (isInlinePanel.value && mode !== 'panel') {
|
||||
const id = appId.value
|
||||
emit('close')
|
||||
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
|
||||
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
|
||||
return
|
||||
}
|
||||
|
||||
// Switch from route-based to inline panel
|
||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||
// the app keeps floating above the dashboard instead of owning the route.
|
||||
if (!isInlinePanel.value && mode === 'panel') {
|
||||
const id = appId.value
|
||||
const launcher = useAppLauncherStore()
|
||||
@ -250,22 +247,24 @@ function setMode(mode: DisplayMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Reactive classes based on display mode. The store-driven session honors the
|
||||
// selected display mode in place: panel renders inline beside the page,
|
||||
// overlay/fullscreen render above it (teleported to body) — the underlying
|
||||
// route never changes. Mobile always uses the full overlay.
|
||||
const inlinePanelMode = computed(() =>
|
||||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||||
)
|
||||
|
||||
const backdropClasses = computed(() => {
|
||||
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
|
||||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||||
return 'app-session-backdrop-overlay'
|
||||
})
|
||||
|
||||
const panelClasses = computed(() => {
|
||||
const base = 'app-session-panel glass-card'
|
||||
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
|
||||
if (inlinePanelMode.value) return `${base} app-session-inline`
|
||||
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
||||
return `${base} app-session-overlay`
|
||||
})
|
||||
@ -380,9 +379,14 @@ function onMessage(e: MessageEvent) {
|
||||
|
||||
// Enter fullscreen on mount if mode is fullscreen
|
||||
watch(displayMode, (mode) => {
|
||||
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
if (mode !== 'fullscreen') return
|
||||
// The panel may teleport to <body> on this mode change — request fullscreen
|
||||
// after the DOM settles so we grab the element at its new location.
|
||||
void nextTick(() => {
|
||||
if (displayMode.value === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
||||
sessionRef.value.requestFullscreen().catch(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -120,7 +120,11 @@
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<Transition name="panel-slide">
|
||||
<div v-if="appLauncher.panelAppId" class="app-panel-container">
|
||||
<AppSession :app-id-prop="appLauncher.panelAppId" @close="appLauncher.closePanel()" />
|
||||
<AppSession
|
||||
:app-id-prop="appLauncher.panelAppId"
|
||||
:path-prop="appLauncher.panelPath ?? undefined"
|
||||
@close="appLauncher.closePanel()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
@ -517,7 +517,8 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
|
||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
function openInMempool(txHash: string) { useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` }) }
|
||||
|
||||
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
|
||||
// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used
|
||||
|
||||
@ -82,7 +82,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
// mempool). Non-demoable apps fall through to a generic notice page.
|
||||
if (IS_DEMO) {
|
||||
const base = demoAppUrl(id)
|
||||
if (base) return routeQueryPath ? base + routeQueryPath : base
|
||||
if (base) {
|
||||
if (!routeQueryPath) return base
|
||||
// Join without a double slash (/app/mempool/ + /tx/x → /app/mempool/tx/x)
|
||||
return base.replace(/\/+$/, '') + (routeQueryPath.startsWith('/') ? routeQueryPath : '/' + routeQueryPath)
|
||||
}
|
||||
return `/app/${id}/`
|
||||
}
|
||||
|
||||
|
||||
@ -148,12 +148,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatTxTime } from './utils'
|
||||
import type { WalletTransaction } from './types'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showIncomingTxPanel = ref(false)
|
||||
@ -179,6 +178,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
function openInMempool(txHash: string) {
|
||||
router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } })
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
useAppLauncherStore().openSession('mempool', { path: `/tx/${txHash}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user