Files
archy/neode-ui/src/views/appSession/AppSessionFrame.vue
T
archipelagoandClaude Fable 5 d08c0d29c7
Demo images / Build & push demo images (push) Has been cancelled
fix(ui): ElectrumX sync screen shows the app's icon instead of a generic glyph
The pre-UI sync overlay used a hardcoded orange box-glyph SVG; it now renders
the same resolved app icon the loading screen uses (with the shared image-error
fallback), so the wait page reads as ElectrumX at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:29:46 -04:00

200 lines
8.9 KiB
Vue

<template>
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
<Transition name="content-fade">
<!-- Suppressed while the ElectrumX sync overlay below is showing both
conditions can be true at once during launch (generic loader fires
first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two
rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition>
<!-- ElectrumX sync screen shown before the real UI while the on-chain
index is still being built (the Electrum server can't serve clients
until then). Mirrors the Fedimint Guardian "wait page" design. -->
<Transition name="content-fade">
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
ElectrumX is building its index from the blockchain. The UI opens
automatically once it's ready — you can keep using the rest of Archipelago.
</p>
<div class="w-full h-2 rounded-full bg-white/10 overflow-hidden mb-2">
<div
class="h-full bg-orange-400/80 transition-all duration-700"
:style="{ width: `${Math.min(100, Math.max(2, electrsSync.progress_pct)).toFixed(1)}%` }"
></div>
</div>
<p class="text-white/70 text-sm font-medium mb-1">{{ electrsSync.progress_pct.toFixed(1) }}%</p>
<p class="text-white/40 text-xs">
Block {{ electrsSync.indexed_height.toLocaleString() }} of {{ electrsSync.network_height.toLocaleString() }}
<template v-if="electrsSync.index_size"> · {{ electrsSync.index_size }} indexed</template>
</p>
<p v-if="electrsSync.stale" class="text-yellow-400/70 text-xs mt-2">Reconnecting to ElectrumX…</p>
</div>
</div>
</Transition>
<div
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@focusin="focusIframe"
>
<iframe
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
tabindex="0"
@load="handleIframeLoad"
@error="$emit('iframeError')"
/>
</div>
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. -->
<Transition name="content-fade">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p>
<div class="flex flex-wrap items-center justify-center gap-3">
<button
v-if="!mustOpenNewTab"
@click="$emit('refresh')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Retry now
</button>
<button
@click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</div>
</Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = defineProps<{
appUrl: string
appId: string
appTitle: string
appIcon: string
loading: boolean
iframeBlocked: boolean
mustOpenNewTab: boolean
autoRetryCount: number
refreshKey: number
blockedReason?: string
blockedTitle?: string
// Non-null only for ElectrumX while its index is still building — shows the
// sync screen and gates the iframe until status flips to "synced".
electrsSync?: ElectrsSyncStatus | null
}>()
const emit = defineEmits<{
iframeLoad: []
iframeError: []
refresh: []
openNewTabAndBack: []
}>()
const iframeRef = ref<HTMLIFrameElement | null>(null)
// Faux load progress for the loading screen. Cross-origin iframes give no real
// progress events, so ease toward ~92% while loading and snap to 100% on load —
// far better UX than a black screen with a bare spinner.
const loadProgress = ref(0)
let progressTimer: ReturnType<typeof setInterval> | null = null
function stopProgress() {
if (progressTimer) { clearInterval(progressTimer); progressTimer = null }
}
function startProgress() {
stopProgress()
loadProgress.value = 8
progressTimer = setInterval(() => {
// Decelerate as it approaches the cap so it never visually "finishes" early.
const remaining = 92 - loadProgress.value
loadProgress.value += Math.max(0.4, remaining * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(() => props.loading, (isLoading) => {
if (isLoading) {
startProgress()
} else {
stopProgress()
loadProgress.value = 100
}
}, { immediate: true })
watch(() => props.refreshKey, () => { if (props.loading) startProgress() })
onBeforeUnmount(stopProgress)
function focusIframe() {
iframeRef.value?.focus({ preventScroll: true })
}
async function handleIframeLoad() {
emit('iframeLoad')
await nextTick()
requestAnimationFrame(focusIframe)
}
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
if (!props.appUrl || props.iframeBlocked) return
await nextTick()
requestAnimationFrame(focusIframe)
}, { immediate: true })
defineExpose({ iframeRef })
</script>