frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
@@ -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)
})
})
+43
View File
@@ -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()
}
+25
View File
@@ -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.'
}
+17
View File
@@ -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
}