Keep slow app sessions alive and defer automatic companion prompts
This commit is contained in:
@@ -153,6 +153,8 @@ import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{ deferAutomatic?: boolean }>()
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK on the release server's https
|
||||
@@ -200,6 +202,16 @@ const BASE_DELAY_MS = 5000
|
||||
const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
let baseDelay: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
let automaticDisplay = false
|
||||
|
||||
function clearAutomaticTimers() {
|
||||
if (baseDelay) clearTimeout(baseDelay)
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
baseDelay = null
|
||||
calmTicker = null
|
||||
}
|
||||
|
||||
// Running inside the companion app's own WebView (it injects the JS bridge —
|
||||
// detected with the canonical helper, not a raw window check, so the gate
|
||||
@@ -213,9 +225,10 @@ onMounted(async () => {
|
||||
// The prompt is remembered per APK build, not forever. A browser that saw
|
||||
// 0.5.28 should be told once when this node begins serving 0.5.32.
|
||||
await loadCompanionRelease()
|
||||
if (disposed) return
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== companionReleaseMarker(companionVersion.value)) {
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
baseDelay = setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
@@ -223,18 +236,21 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
disposed = true
|
||||
clearAutomaticTimers()
|
||||
})
|
||||
|
||||
function maybeShow() {
|
||||
if (disposed) return
|
||||
clearAutomaticTimers()
|
||||
// Show only after the scene has been CONTINUOUSLY calm (no reveal
|
||||
// cinematic) for the full grace window. The previous point-in-time check
|
||||
// raced a slow-starting reveal — on a cold cache the entrance video can
|
||||
// begin buffering after the 5s base delay, so the flag was still false
|
||||
// when sampled and the popup cut straight into the cinematic.
|
||||
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
|
||||
let calmSince = loginTransition.introCinematicPlaying || props.deferAutomatic ? null : Date.now()
|
||||
calmTicker = setInterval(() => {
|
||||
if (loginTransition.introCinematicPlaying) {
|
||||
if (loginTransition.introCinematicPlaying || props.deferAutomatic) {
|
||||
calmSince = null
|
||||
return
|
||||
}
|
||||
@@ -242,11 +258,21 @@ function maybeShow() {
|
||||
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
automaticDisplay = true
|
||||
visible.value = true
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// A route change can open an app after the automatic offer was displayed.
|
||||
// Defer that offer until the app closes; never cover its identity/consent UI.
|
||||
watch(() => props.deferAutomatic, active => {
|
||||
if (active && visible.value && automaticDisplay) {
|
||||
visible.value = false
|
||||
maybeShow()
|
||||
}
|
||||
})
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
|
||||
// and this watcher refuses to open there too, so no caller can ever pop the
|
||||
@@ -255,10 +281,8 @@ watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (IN_COMPANION_APP) return
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
}
|
||||
clearAutomaticTimers()
|
||||
automaticDisplay = false
|
||||
step.value = 'download'
|
||||
visible.value = true
|
||||
})
|
||||
@@ -449,6 +473,7 @@ function showDownloadScreen() {
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
clearAutomaticTimers()
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CompanionIntroOverlay from '../CompanionIntroOverlay.vue'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
vi.mock('qrcode', () => ({ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,fixture') }))
|
||||
vi.mock('@/utils/openExternal', () => ({ isCompanionApp: () => false }))
|
||||
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false, DEMO_PASSWORD: '' }))
|
||||
vi.mock('@/composables/useCompanionRelease', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { companionRelease: ref({ versionName: 'fixture', versionCode: 1 }), companionReleaseMarker: () => 'fixture', loadCompanionRelease: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
vi.mock('@/stores/loginTransition', () => ({ useLoginTransitionStore: () => ({ introCinematicPlaying: false }) }))
|
||||
vi.mock('@/stores/server', () => ({ useServerStore: () => ({}) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
describe('automatic companion offer during app use', () => {
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); companionIntroRequested.value = false })
|
||||
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
|
||||
function render(deferAutomatic = false) { wrapper = mount(CompanionIntroOverlay, { props: { deferAutomatic }, global: { stubs: { Teleport: true, Transition: false } } }) }
|
||||
it('waits until the app closes before showing its automatic offer', async () => {
|
||||
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(10000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
await wrapper.setProps({ deferAutomatic: false }); await vi.advanceTimersByTimeAsync(2500)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(true)
|
||||
await wrapper.setProps({ deferAutomatic: true }); await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
})
|
||||
it('honors manual opening while an app is active, without reopening after dismissal', async () => {
|
||||
render(true); await nextTick()
|
||||
companionIntroRequested.value = true; await nextTick()
|
||||
await wrapper.get('[aria-label="Close companion modal"]').trigger('click')
|
||||
await vi.advanceTimersByTimeAsync(12000)
|
||||
expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false)
|
||||
})
|
||||
it('clears deferred timers when the dashboard is unmounted', async () => {
|
||||
render(true); await nextTick(); await vi.advanceTimersByTimeAsync(1000)
|
||||
wrapper.unmount(); await vi.advanceTimersByTimeAsync(15000)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user