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
+43 -7
View File
@@ -69,12 +69,12 @@
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<!-- Loading indicator -->
<Transition name="content-fade">
<div v-if="iframeLoading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-white/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<AppLoadingScreen
v-if="iframeLoading"
:icon="overlayIcon"
:title="store.title || 'App'"
:progress="loadProgress"
/>
</Transition>
<iframe
ref="iframeRef"
@@ -184,10 +184,12 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {
@@ -207,6 +209,39 @@ const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
// Best-guess icon for the loading screen — resolved from the /app/{id}/ path
// when present; AppLoadingScreen's <img> falls back to the default icon if the
// guessed asset 404s.
const overlayIcon = computed(() => {
const url = store.url
if (!url) return DEFAULT_APP_ICON
try {
const m = new URL(url, window.location.origin).pathname.match(/^\/app\/([a-z0-9._-]+)/i)
if (m?.[1]) return `/assets/img/app-icons/${m[1].toLowerCase()}.png`
} catch { /* not a parseable URL */ }
return DEFAULT_APP_ICON
})
// Faux load progress (cross-origin iframes give no real progress events): ease
// toward ~92% while loading, snap to 100% on load.
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(() => {
loadProgress.value += Math.max(0.4, (92 - loadProgress.value) * 0.08)
if (loadProgress.value >= 92) { loadProgress.value = 92; stopProgress() }
}, 180)
}
watch(iframeLoading, (loading) => {
if (loading) startProgress()
else { stopProgress(); loadProgress.value = 100 }
}, { immediate: true })
// Nostr identity picker state
const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
@@ -573,6 +608,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
})