merge: companion-mobile-ux UX (loader/store-driven launch/icons + android webview) into main
# Conflicts: # Android/app/build.gradle.kts # Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt # neode-ui/src/views/apps/appsConfig.ts
This commit is contained in:
@@ -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(() => {})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppSession from '../AppSession.vue'
|
||||
|
||||
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(),
|
||||
mockPush: vi.fn(),
|
||||
mockReplace: vi.fn(() => Promise.resolve()),
|
||||
mockPush: vi.fn(() => Promise.resolve()),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockSuppress: vi.fn(),
|
||||
mockResume: vi.fn(),
|
||||
@@ -62,7 +62,7 @@ describe('AppSession mobile new-tab apps', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
|
||||
it('opens tab-only apps directly on mobile instead of showing an interstitial', async () => {
|
||||
const wrapper = mount(AppSession, {
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -75,9 +75,11 @@ describe('AppSession mobile new-tab apps', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('This app opens in a new tab')
|
||||
expect(wrapper.text()).toContain('Open in new tab')
|
||||
// Tab-only app (gitea) on mobile-web: open directly in a new browser tab
|
||||
// (no native bridge in the test) and dismiss the empty session — no
|
||||
// "this app opens in a tab" interstitial.
|
||||
expect(mockWindowOpen).toHaveBeenCalled()
|
||||
expect(mockReplace).toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('This app opens in a new tab')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden app-session-frame-safe">
|
||||
<Transition name="content-fade">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<AppLoadingScreen v-if="loading" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||
</Transition>
|
||||
|
||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||
@@ -116,13 +111,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
|
||||
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
appId: string
|
||||
appTitle: string
|
||||
appIcon: string
|
||||
loading: boolean
|
||||
iframeBlocked: boolean
|
||||
mustOpenNewTab: boolean
|
||||
@@ -144,6 +141,40 @@ const emit = defineEmits<{
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -239,6 +239,16 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
'archy-bitcoin-ui': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
'archy-lnd-ui': '/assets/img/app-icons/lnd.svg',
|
||||
'archy-electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
// ElectrumX ships under a few historical ids (the backend was renamed
|
||||
// electrs → electrumx). Without an explicit map, an `electrs`-keyed install
|
||||
// falls through to the default `/assets/img/app-icons/electrs.png`, which
|
||||
// doesn't exist → handleImageError swaps .png→.svg and lands on electrs.svg
|
||||
// (the "Electrs in Rust" logo) instead of the real ElectrumX icon. Pin the
|
||||
// whole family to the ElectrumX icon so My Apps shows the right logo no
|
||||
// matter which id the node has it installed under.
|
||||
'electrs': '/assets/img/app-icons/electrumx.png',
|
||||
'electrs-ui': '/assets/img/app-icons/electrumx.png',
|
||||
'electrumx': '/assets/img/app-icons/electrumx.png',
|
||||
}
|
||||
|
||||
// Parent-app icon by prefix, for stack members not listed explicitly above
|
||||
|
||||
@@ -143,9 +143,10 @@ const mobileTabBar = ref<HTMLElement | null>(null)
|
||||
const MOBILE_LAYOUT_MAX_WIDTH = 920
|
||||
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
|
||||
|
||||
// App sessions own their mobile controls. Normal mobile launches use the route
|
||||
// session; keeping this guard also protects any desktop-panel state on resize.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session')
|
||||
// App sessions own their mobile controls, so the nav hides while one is open.
|
||||
// Mobile launches now use the store-driven panel (no route change) to keep the
|
||||
// background tab intact, so treat an active panel the same as a routed session.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user