archy/neode-ui/src/views/AppSession.vue

464 lines
12 KiB
Vue

<template>
<div class="app-session-root">
<Teleport to="body" :disabled="isInlinePanel">
<div
:class="backdropClasses"
@click.self="handleBackdropClick"
>
<div
ref="sessionRef"
:class="panelClasses"
@click.stop
>
<AppSessionHeader
:app-title="appTitle"
:is-refreshing="isRefreshing"
:display-mode="displayMode"
@go-back="iframeGoBack"
@go-forward="iframeGoForward"
@refresh="refresh"
@open-new-tab="openNewTab"
@close="closeSession"
@set-mode="setMode"
/>
<AppSessionFrame
ref="frameRef"
:app-url="appUrl"
:app-id="appId"
:app-title="appTitle"
:loading="loading"
:iframe-blocked="iframeBlocked"
:must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount"
:refresh-key="refreshKey"
@iframe-load="onLoad"
@iframe-error="onError"
@refresh="refresh"
@open-new-tab-and-back="openNewTabAndBack"
/>
</div>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="identity.onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
const props = defineProps<{
appIdProp?: string
}>()
const emit = defineEmits<{
close: []
}>()
/** True when rendered inline via store (panel mode), false when route-based */
const isInlinePanel = computed(() => !!props.appIdProp)
const route = useRoute()
const router = useRouter()
const sessionRef = ref<HTMLElement | null>(null)
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
const loading = ref(true)
const isRefreshing = ref(false)
const iframeBlocked = ref(false)
const refreshKey = ref(0)
const showIdentityPicker = ref(false)
const autoRetryCount = ref(0)
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let autoRetryId: ReturnType<typeof setTimeout> | null = null
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
// Display mode -- persisted in localStorage
const displayMode = ref<DisplayMode>(
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
)
const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
return ''
}
return id
})
const appTitle = computed(() => resolveAppTitle(appId.value))
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
return resolveAppUrl(appId.value, route.query.path as string | undefined)
})
// --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
// --- Display mode ---
function setMode(mode: DisplayMode) {
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
document.exitFullscreen().catch(() => {})
}
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')
router.push({ name: 'app-session', params: { appId: id } })
return
}
// Switch from route-based to inline panel
if (!isInlinePanel.value && mode === 'panel') {
const id = appId.value
const launcher = useAppLauncherStore()
router.push({ name: 'apps' }).then(() => {
launcher.panelAppId = id
})
return
}
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
}
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (isInlinePanel.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`
return `${base} app-session-overlay`
})
// --- Lifecycle handlers ---
function onLoad() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
loading.value = false
isRefreshing.value = false
autoRetryCount.value = 0
// Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => {
try {
const iframe = frameRef.value?.iframeRef
const doc = iframe?.contentDocument
if (doc) {
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
}
} catch {
// Cross-origin -- can't check, assume OK
}
}, 1000)
identity.onIframeLoadIdentity()
}
function onError() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
iframeBlocked.value = true
// Auto-retry up to 6 times (60s total) for apps that are still starting
if (!mustOpenNewTab.value && autoRetryCount.value < 6) {
autoRetryId = setTimeout(() => {
autoRetryCount.value++
refresh()
}, 10000)
}
}
function refresh() {
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
isRefreshing.value = true
loading.value = true
iframeBlocked.value = false
refreshKey.value++
startLoadTimeout()
}
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
iframeBlocked.value = true
}
}, 12000)
}
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
closeSession()
}
function openNewTab() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
}
function iframeGoBack() {
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
}
function iframeGoForward() {
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
}
function handleBackdropClick() {
closeSession()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
if (isInlinePanel.value) emit('close')
else router.back()
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
}
}
function onFullscreenChange() {
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
displayMode.value = 'overlay'
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
}
}
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
}
// Enter fullscreen on mount if mode is fullscreen
watch(displayMode, (mode) => {
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
})
onMounted(() => {
// Apps that block iframes (X-Frame-Options) -- open in new tab, close session
if (mustOpenNewTab.value && appUrl.value) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else router.back()
return
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
})
}
})
onBeforeUnmount(() => {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
</script>
<style>
/* Unscoped so children can use these classes */
.app-session-root {
width: 100%;
height: 100%;
}
/* Inline panel mode -- fills content area, no blur, original layout */
.app-session-backdrop-inline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
padding: 0;
}
.app-session-inline {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
}
@media (min-width: 768px) {
.app-session-backdrop-inline {
padding: 1.5rem;
}
.app-session-inline {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
}
/* Overlay mode -- covers entire viewport including sidebar */
.app-session-backdrop-overlay {
position: fixed;
inset: 0;
z-index: 2400;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
}
@media (min-width: 768px) {
.app-session-backdrop-overlay {
padding: 2.5rem;
}
}
.app-session-overlay {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
}
@media (min-width: 768px) {
.app-session-overlay {
max-width: calc(100vw - 5rem);
max-height: calc(100vh - 5rem);
border-radius: 1rem;
}
}
/* Fullscreen mode */
.app-session-fullscreen {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0 !important;
max-width: none !important;
max-height: none !important;
}
/* Shared */
.app-session-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
flex-shrink: 0;
}
.app-session-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.app-session-btn:disabled {
opacity: 0.5;
}
/* Mode dropdown */
.mode-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
text-align: left;
}
.mode-option:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
}
.mode-option-active {
color: #fb923c;
background: rgba(251, 146, 60, 0.08);
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.2s ease;
}
.content-fade-enter-from,
.content-fade-leave-to {
opacity: 0;
}
</style>