frontend: polish app launch and release experience
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getAppUsage, recordAppLaunch } from '../appUsage'
|
||||
|
||||
describe('appUsage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts empty when no usage has been recorded', () => {
|
||||
expect(getAppUsage()).toEqual({})
|
||||
})
|
||||
|
||||
it('records launch count and latest launch time', () => {
|
||||
recordAppLaunch('filebrowser', 1000)
|
||||
recordAppLaunch('filebrowser', 2000)
|
||||
|
||||
expect(getAppUsage()).toEqual({
|
||||
filebrowser: {
|
||||
count: 2,
|
||||
lastLaunchedAt: 2000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores corrupt stored usage', () => {
|
||||
localStorage.setItem('archipelago-app-usage', 'not-json')
|
||||
|
||||
expect(getAppUsage()).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { explainReceiveAddressFailure } from '../bitcoinReceive'
|
||||
|
||||
describe('explainReceiveAddressFailure', () => {
|
||||
it('explains locked wallet failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('wallet locked'))).toContain('wallet is locked')
|
||||
})
|
||||
|
||||
it('explains sync failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('chain backend is still syncing'))).toContain('still syncing')
|
||||
})
|
||||
|
||||
it('explains empty address responses', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND did not return a Bitcoin address'))).toContain('did not return an address')
|
||||
})
|
||||
|
||||
it('explains lnd transport failures', () => {
|
||||
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowIntroSplash } from '../introSplash'
|
||||
|
||||
describe('shouldShowIntroSplash', () => {
|
||||
it('skips intro on an already-onboarded node even without a browser intro flag', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: true,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('shows intro for a fresh root visit when onboarding is not complete', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/',
|
||||
fromBoot: false,
|
||||
onboardingComplete: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('does not interrupt direct routes', () => {
|
||||
expect(shouldShowIntroSplash({
|
||||
seenIntro: false,
|
||||
routePath: '/dashboard/web5',
|
||||
fromBoot: false,
|
||||
onboardingComplete: null,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
const STORAGE_KEY = 'archipelago-app-usage'
|
||||
|
||||
export interface AppUsageEntry {
|
||||
count: number
|
||||
lastLaunchedAt: number
|
||||
}
|
||||
|
||||
export type AppUsageMap = Record<string, AppUsageEntry>
|
||||
|
||||
function readUsage(): AppUsageMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as AppUsageMap
|
||||
if (!parsed || typeof parsed !== 'object') return {}
|
||||
return parsed
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsage(usage: AppUsageMap) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(usage))
|
||||
} catch {
|
||||
// Ignore unavailable or full localStorage.
|
||||
}
|
||||
}
|
||||
|
||||
export function recordAppLaunch(appId: string, now = Date.now()) {
|
||||
if (!appId) return
|
||||
const usage = readUsage()
|
||||
const current = usage[appId]
|
||||
usage[appId] = {
|
||||
count: (current?.count || 0) + 1,
|
||||
lastLaunchedAt: now,
|
||||
}
|
||||
writeUsage(usage)
|
||||
}
|
||||
|
||||
export function getAppUsage(): AppUsageMap {
|
||||
return readUsage()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function explainReceiveAddressFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
|
||||
return 'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.'
|
||||
}
|
||||
if (lower.includes('uninitialized') || lower.includes('not initialized') || lower.includes('initwallet')) {
|
||||
return 'Bitcoin address is not ready because the Lightning wallet has not been initialized yet.'
|
||||
}
|
||||
if (lower.includes('sync') || lower.includes('chain backend') || lower.includes('neutrino')) {
|
||||
return 'Bitcoin address is not ready while Bitcoin or LND is still syncing. Try again once sync has progressed.'
|
||||
}
|
||||
if (lower.includes('rest connection failed') || lower.includes('failed to parse newaddress response')) {
|
||||
return 'Bitcoin address is not ready because LND is not responding cleanly yet. Check that the Lightning app is healthy and retry.'
|
||||
}
|
||||
if (lower.includes('connection') || lower.includes('connect') || lower.includes('unavailable') || lower.includes('refused')) {
|
||||
return 'Bitcoin address is not ready because LND is not reachable yet. Check that the Lightning app is running.'
|
||||
}
|
||||
if (lower.includes('did not return') || lower.includes('empty address')) {
|
||||
return 'Bitcoin address is not ready because LND did not return an address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync.'
|
||||
}
|
||||
|
||||
return message || 'Bitcoin address is not ready yet. Check Bitcoin and LND status, then try again.'
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface IntroSplashDecisionInput {
|
||||
seenIntro: boolean
|
||||
routePath: string
|
||||
fromBoot: boolean
|
||||
devMode?: string
|
||||
onboardingComplete: boolean | null
|
||||
}
|
||||
|
||||
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||
if (input.seenIntro) return false
|
||||
if (input.onboardingComplete === true) return false
|
||||
|
||||
const isDirectRoute = input.routePath !== '/'
|
||||
if (input.fromBoot) return true
|
||||
if (input.devMode === 'boot') return false
|
||||
return !isDirectRoute
|
||||
}
|
||||
Reference in New Issue
Block a user