Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
adaptContentItems,
|
||||
adaptToFilm,
|
||||
adaptToPodcast,
|
||||
adaptLibraryTracks,
|
||||
adaptLibraryAlbums,
|
||||
classifyByMime,
|
||||
sortDeterministic,
|
||||
type ArchyContentItem,
|
||||
type ArchyLibraryTrack,
|
||||
} from '../archyContentAdapter'
|
||||
|
||||
function item(overrides: Partial<ArchyContentItem>): ArchyContentItem {
|
||||
return {
|
||||
id: 'id-1',
|
||||
filename: 'file.bin',
|
||||
mime_type: 'application/octet-stream',
|
||||
size_bytes: 1024,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyByMime', () => {
|
||||
it('classifies a video mime as video', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'video/mp4', filename: 'movie.mp4' }))).toBe('video')
|
||||
})
|
||||
|
||||
it('classifies an audio mime as audio', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mpeg', filename: 'song.mp3' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('classifies images as image, and still excludes documents', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/pdf', filename: 'doc.pdf' }))).toBe('excluded')
|
||||
})
|
||||
|
||||
it('classifies images by extension when the mime is generic', () => {
|
||||
// A node whose catalog is mostly photos shared with an unidentified
|
||||
// mime must not present as empty.
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.webp' }))).toBe('image')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.heic' }))).toBe('image')
|
||||
})
|
||||
|
||||
it('classifies m4a, aac, opus and wma as audio via extension fallback (ShareModal blind spot)', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
|
||||
it('also classifies the correct audio/* mime for those four extensions', () => {
|
||||
expect(classifyByMime(item({ mime_type: 'audio/mp4', filename: 'track.m4a' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/aac', filename: 'track.aac' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/opus', filename: 'track.opus' }))).toBe('audio')
|
||||
expect(classifyByMime(item({ mime_type: 'audio/x-ms-wma', filename: 'track.wma' }))).toBe('audio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptContentItems', () => {
|
||||
it('maps a video-mime item to a Film with id carried through and one source entry', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'film-1', filename: 'The Movie.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(1)
|
||||
expect(bundle.films[0]!.id).toBe('film-1')
|
||||
expect(bundle.films[0]!.title).toBe('The Movie')
|
||||
expect(bundle.films[0]!.sources).toHaveLength(1)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('maps an audio-mime item to a Song', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'song-1', filename: 'Track.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.songs).toHaveLength(1)
|
||||
expect(bundle.songs[0]!.id).toBe('song-1')
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes images to the images bucket and still excludes documents', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'img-1', filename: 'photo.jpg', mime_type: 'image/jpeg' }),
|
||||
item({ id: 'doc-1', filename: 'report.pdf', mime_type: 'application/pdf' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(0)
|
||||
expect(bundle.songs).toHaveLength(0)
|
||||
expect(bundle.podcasts).toHaveLength(0)
|
||||
// The photo renders; the PDF has no grid, so it stays out of every bucket.
|
||||
expect(bundle.images).toHaveLength(1)
|
||||
expect(bundle.images[0]!.id).toBe('img-1')
|
||||
expect(bundle.images[0]!.url).toBe('/content/img-1')
|
||||
})
|
||||
|
||||
it('never locks an OWN paid image: the node serves the authenticated owner (owner-bypass), price stays as a badge', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(false)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('/content/p-1')
|
||||
})
|
||||
|
||||
it('locks a PEER paid image: price carried, no URL to fetch bytes the user has not bought', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
|
||||
{ source: 'peer', peerOnion: 'seller.onion' },
|
||||
)
|
||||
expect(bundle.images[0]!.locked).toBe(true)
|
||||
expect(bundle.images[0]!.priceSats).toBe(100)
|
||||
expect(bundle.images[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('maps an access:Paid item with a price and a locked flag, and no playable source URL', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({
|
||||
id: 'paid-1',
|
||||
filename: 'premium.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
access: { paid: { price_sats: 5000 } },
|
||||
}),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'abc123.onion' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
expect(film.locked).toBe(true)
|
||||
expect(film.priceSats).toBe(5000)
|
||||
expect(film.sources[0]!.url).toBe('')
|
||||
})
|
||||
|
||||
it('gives two items with identical filename and size but different id two distinct cards (adjacency)', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'peer-a', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'peer-b', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
expect(bundle.films).toHaveLength(2)
|
||||
const ids = bundle.films.map((f) => f.id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect(ids).toContain('peer-a')
|
||||
expect(ids).toContain('peer-b')
|
||||
})
|
||||
|
||||
it('an item present both in own library and a peer share appears once per source (adjacency, cross-source)', () => {
|
||||
const own = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peer = adaptContentItems(
|
||||
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
|
||||
{ source: 'peer', peerOnion: 'peer.onion' },
|
||||
)
|
||||
// Each source's bundle carries its own single card for the id — the
|
||||
// broker (Task 2) is responsible for not silently merging bundles from
|
||||
// different sources into one deduplicated list.
|
||||
expect(own.films).toHaveLength(1)
|
||||
expect(peer.films).toHaveLength(1)
|
||||
expect(own.films[0]!.sources[0]!.type).not.toBe(peer.films[0]!.sources[0]!.type)
|
||||
})
|
||||
|
||||
it('an empty input array produces empty films/songs/podcasts arrays, not undefined or an error', () => {
|
||||
const bundle = adaptContentItems([], { source: 'own' })
|
||||
expect(bundle).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('handles null/undefined input the same as an empty array', () => {
|
||||
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
|
||||
})
|
||||
|
||||
it('maps a null/absent description to an empty string, never the literal "null"', () => {
|
||||
const withNull = adaptContentItems(
|
||||
[item({ id: 'f1', filename: 'a.mp4', mime_type: 'video/mp4', description: null })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const withAbsent = adaptContentItems(
|
||||
[item({ id: 'f2', filename: 'b.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(withNull.films[0]!.synopsis).toBe('')
|
||||
expect(withAbsent.films[0]!.synopsis).toBe('')
|
||||
})
|
||||
|
||||
it('sorts added_at descending with id ascending as the deterministic tiebreak', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'z', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'm', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
// Newest added_at first (m), then the 2026-01-01 pair tie-broken by id ascending (a, z).
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['m', 'a', 'z'])
|
||||
})
|
||||
|
||||
it('a null added_at sorts last rather than crashing the comparator', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'has-date', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'no-date', filename: 'b.mp4', mime_type: 'video/mp4', added_at: null }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(bundle.films.map((f) => f.id)).toEqual(['has-date', 'no-date'])
|
||||
})
|
||||
|
||||
it('produces identical output order regardless of input array order (repeat-call stability)', () => {
|
||||
const items = [
|
||||
item({ id: 'a', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'b', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
item({ id: 'c', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-01-15T00:00:00Z' }),
|
||||
]
|
||||
const first = adaptContentItems(items, { source: 'own' })
|
||||
const second = adaptContentItems([...items].reverse(), { source: 'own' })
|
||||
expect(first.films.map((f) => f.id)).toEqual(second.films.map((f) => f.id))
|
||||
})
|
||||
|
||||
it('never produces a URL carrying a credential as a query parameter', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'own-1', filename: 'a.mp4', mime_type: 'video/mp4' }),
|
||||
item({ id: 'song-1', filename: 'b.mp3', mime_type: 'audio/mpeg' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const peerBundle = adaptContentItems(
|
||||
[item({ id: 'peer-1', filename: 'c.mp4', mime_type: 'video/mp4' })],
|
||||
{ source: 'peer', peerOnion: 'somepeer.onion' },
|
||||
)
|
||||
const allUrls = [
|
||||
...bundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
...bundle.songs.flatMap((s) => (s.sources ?? []).map((src) => src.url)),
|
||||
...peerBundle.films.flatMap((f) => f.sources.map((s) => s.url)),
|
||||
]
|
||||
for (const url of allUrls) {
|
||||
expect(url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
})
|
||||
|
||||
it('shape-pins every field FilmGrid.vue and SongGrid.vue read', () => {
|
||||
const bundle = adaptContentItems(
|
||||
[
|
||||
item({ id: 'film-shape', filename: 'Shape Test.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'song-shape', filename: 'Shape Song.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' }),
|
||||
],
|
||||
{ source: 'own' },
|
||||
)
|
||||
const film = bundle.films[0]!
|
||||
// FilmGrid.vue reads: id, title, year, director, cast (search/aria-label),
|
||||
// rating, sources[].type (badges), coverSrc()/fallbackSrc() consume
|
||||
// posterUrl/backdropUrl/title/year, genres (topGenres filter).
|
||||
expect(typeof film.id).toBe('string')
|
||||
expect(typeof film.title).toBe('string')
|
||||
expect(typeof film.year).toBe('number')
|
||||
expect(typeof film.director).toBe('string')
|
||||
expect(Array.isArray(film.cast)).toBe(true)
|
||||
expect(typeof film.rating).toBe('number')
|
||||
expect(Array.isArray(film.genres)).toBe(true)
|
||||
expect(Array.isArray(film.sources)).toBe(true)
|
||||
expect(film.sources.length).toBeGreaterThan(0)
|
||||
expect(typeof film.sources[0]!.type).toBe('string')
|
||||
|
||||
const song = bundle.songs[0]!
|
||||
// SongGrid.vue reads: id, title, artist (search/aria-label), album
|
||||
// (search), genres (topGenres), coverUrl, sources[].type (badges).
|
||||
expect(typeof song.id).toBe('string')
|
||||
expect(typeof song.title).toBe('string')
|
||||
expect(typeof song.artist).toBe('string')
|
||||
expect(Array.isArray(song.sources)).toBe(true)
|
||||
expect((song.sources ?? []).length).toBeGreaterThan(0)
|
||||
expect(typeof song.sources![0]!.type).toBe('string')
|
||||
})
|
||||
|
||||
it('pins the three source-badge literal values for own/peer/indeehub films', () => {
|
||||
const own = adaptToFilm(item({ id: 'x', filename: 'x.mp4', mime_type: 'video/mp4' }), { source: 'own' })
|
||||
const peer = adaptToFilm(item({ id: 'y', filename: 'y.mp4', mime_type: 'video/mp4' }), { source: 'peer', peerOnion: 'p.onion' })
|
||||
const indeehub = adaptToFilm(item({ id: 'z', filename: 'z.mp4', mime_type: 'video/mp4' }), { source: 'indeehub' })
|
||||
expect(own.sources[0]!.type).toBe('nextcloud')
|
||||
expect(peer.sources[0]!.type).toBe('plex')
|
||||
expect(indeehub.sources[0]!.type).toBe('indeehub')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptToPodcast', () => {
|
||||
it('maps a ContentItem to a Podcast shape (exported for completeness; not reachable via adaptContentItems today)', () => {
|
||||
const podcast = adaptToPodcast(
|
||||
item({ id: 'pod-1', filename: 'Episode One.mp3', mime_type: 'audio/mpeg', description: 'A description' }),
|
||||
{ source: 'own' },
|
||||
)
|
||||
expect(podcast.id).toBe('pod-1')
|
||||
expect(podcast.title).toBe('Episode One')
|
||||
expect(podcast.description).toBe('A description')
|
||||
expect(Array.isArray(podcast.sources)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortDeterministic', () => {
|
||||
it('is a pure function that does not mutate its input', () => {
|
||||
const items = [
|
||||
item({ id: 'b', filename: 'b.mp4', added_at: '2026-01-01T00:00:00Z' }),
|
||||
item({ id: 'a', filename: 'a.mp4', added_at: '2026-02-01T00:00:00Z' }),
|
||||
]
|
||||
const copy = [...items]
|
||||
sortDeterministic(items)
|
||||
expect(items).toEqual(copy)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Library mapping (13-11) ────────────────────────────────────────────
|
||||
|
||||
function track(overrides: Partial<ArchyLibraryTrack>): ArchyLibraryTrack {
|
||||
return {
|
||||
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Album/01 Song.flac' },
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
album_artist: 'Artist',
|
||||
track_number: 1,
|
||||
disc_number: 1,
|
||||
year: 2024,
|
||||
duration_secs: 210,
|
||||
has_tags: true,
|
||||
content_hash: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('adaptLibraryTracks', () => {
|
||||
it('maps a music.list-tracks record to a Song with title/artist/album/duration carried through from tags', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ title: 'Night Drive', artist: 'The Synths', album: 'Neon', duration_secs: 187 }),
|
||||
])
|
||||
expect(song!.title).toBe('Night Drive')
|
||||
expect(song!.artist).toBe('The Synths')
|
||||
expect(song!.album).toBe('Neon')
|
||||
expect(song!.duration).toBe(187)
|
||||
})
|
||||
|
||||
it('falls back to album_artist when artist is absent, and to an empty string when both are absent — never null/undefined', () => {
|
||||
const [withAlbumArtist] = adaptLibraryTracks([track({ artist: null, album_artist: 'Various' })])
|
||||
expect(withAlbumArtist!.artist).toBe('Various')
|
||||
|
||||
const [withNeither] = adaptLibraryTracks([track({ artist: null, album_artist: null })])
|
||||
expect(withNeither!.artist).toBe('')
|
||||
expect(withNeither!.artist).not.toBe('null')
|
||||
expect(withNeither!.artist).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the index\'s own deterministic order — calling the adapter twice on the same input yields the same order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/a' }, title: 'A' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/b' }, title: 'B' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/c' }, title: 'C' }),
|
||||
]
|
||||
const first = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
const second = adaptLibraryTracks(tracks).map((s) => s.title)
|
||||
expect(first).toEqual(['A', 'B', 'C'])
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('a track with no cover art maps with an absent coverUrl, not a broken-image URL', () => {
|
||||
const [song] = adaptLibraryTracks([track({})])
|
||||
expect(song!.coverUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a peer-sourced track carries a source entry distinguishing it from an own-library track (same three pinned literals)', () => {
|
||||
const [own] = adaptLibraryTracks([track({ id: { source: 'OwnLibrary', path: '/x' } })])
|
||||
const [peer] = adaptLibraryTracks([
|
||||
track({ id: { source: { Peer: { onion: 'abc123.onion' } }, path: '/var/lib/archipelago/purchased-content/abc123.onion/content-1' } }),
|
||||
])
|
||||
expect(own!.sources![0]!.type).toBe('funkwhale')
|
||||
expect(peer!.sources![0]!.type).toBe('plex')
|
||||
expect(own!.sources![0]!.type).not.toBe(peer!.sources![0]!.type)
|
||||
})
|
||||
|
||||
it('never produces a playback URL carrying a credential as a query parameter', () => {
|
||||
const songs = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/a.flac' } }),
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'peer.onion' } }, path: '/var/lib/archipelago/purchased-content/peer.onion/content-9' },
|
||||
}),
|
||||
])
|
||||
for (const song of songs) {
|
||||
for (const source of song.sources ?? []) {
|
||||
expect(source.url).not.toMatch(/[?&](auth|token)=/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('an own-library track resolves through the existing FileBrowser raw-file route with no query string', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' } }),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/app/filebrowser/api/raw/Music/Artist/Song.flac')
|
||||
})
|
||||
|
||||
it('a peer track resolves through the existing peer Range-streaming proxy', () => {
|
||||
const [song] = adaptLibraryTracks([
|
||||
track({
|
||||
id: { source: { Peer: { onion: 'xyz.onion' } }, path: '/var/lib/archipelago/purchased-content/xyz.onion/content-42' },
|
||||
}),
|
||||
])
|
||||
expect(song!.sources![0]!.url).toBe('/api/peer-content/xyz.onion/content-42')
|
||||
})
|
||||
|
||||
it('an empty library produces an empty songs array, not undefined', () => {
|
||||
expect(adaptLibraryTracks([])).toEqual([])
|
||||
expect(adaptLibraryTracks(null)).toEqual([])
|
||||
expect(adaptLibraryTracks(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptLibraryAlbums', () => {
|
||||
it('groups tracks by (album_artist, album), preserving first-seen order', () => {
|
||||
const tracks = [
|
||||
track({ id: { source: 'OwnLibrary', path: '/1' }, title: 'T1', album: 'Beta', album_artist: 'X' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/2' }, title: 'T2', album: 'Alpha', album_artist: 'Y' }),
|
||||
track({ id: { source: 'OwnLibrary', path: '/3' }, title: 'T3', album: 'Beta', album_artist: 'X' }),
|
||||
]
|
||||
const albums = adaptLibraryAlbums(tracks)
|
||||
expect(albums.map((a) => a.album)).toEqual(['Beta', 'Alpha'])
|
||||
expect(albums[0]!.tracks.map((t) => t.title)).toEqual(['T1', 'T3'])
|
||||
expect(albums[1]!.tracks.map((t) => t.title)).toEqual(['T2'])
|
||||
})
|
||||
|
||||
it('a track with no album tag forms no album bucket', () => {
|
||||
const albums = adaptLibraryAlbums([track({ album: null })])
|
||||
expect(albums).toEqual([])
|
||||
})
|
||||
|
||||
it('is stable across repeat calls on the same input', () => {
|
||||
const tracks = [track({ id: { source: 'OwnLibrary', path: '/1' }, album: 'A' })]
|
||||
const first = adaptLibraryAlbums(tracks)
|
||||
const second = adaptLibraryAlbums(tracks)
|
||||
expect(first).toEqual(second)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAudioPlayer } from '../useAudioPlayer'
|
||||
|
||||
// Mock HTMLAudioElement
|
||||
let lastMockAudio: MockAudio | undefined
|
||||
|
||||
class MockAudio {
|
||||
src = ''
|
||||
currentTime = 0
|
||||
duration = 120
|
||||
paused = true
|
||||
private listeners: Record<string, Array<() => void>> = {}
|
||||
|
||||
constructor() {
|
||||
lastMockAudio = this
|
||||
}
|
||||
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
// no-op for tests
|
||||
}
|
||||
|
||||
shouldRejectPlay = false
|
||||
|
||||
play() {
|
||||
if (this.shouldRejectPlay) {
|
||||
return Promise.reject(new DOMException('no supported source', 'NotSupportedError'))
|
||||
}
|
||||
this.paused = false
|
||||
this.emit('play')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.paused = true
|
||||
this.emit('pause')
|
||||
}
|
||||
|
||||
private emit(event: string) {
|
||||
const handlers = this.listeners[event] || []
|
||||
handlers.forEach(h => h())
|
||||
}
|
||||
|
||||
// Helper to simulate events in tests
|
||||
simulateEvent(event: string) {
|
||||
this.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
describe('useAudioPlayer', () => {
|
||||
beforeEach(() => {
|
||||
// Reset singleton state by stopping any active playback
|
||||
const player = useAudioPlayer()
|
||||
player.stop()
|
||||
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
|
||||
})
|
||||
|
||||
it('returns all expected properties', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.play).toBeTypeOf('function')
|
||||
expect(player.pause).toBeTypeOf('function')
|
||||
expect(player.seek).toBeTypeOf('function')
|
||||
expect(player.stop).toBeTypeOf('function')
|
||||
expect(player.playing).toBeDefined()
|
||||
expect(player.currentName).toBeDefined()
|
||||
expect(player.currentTime).toBeDefined()
|
||||
expect(player.duration).toBeDefined()
|
||||
expect(player.progress).toBeDefined()
|
||||
expect(player.currentSrc).toBeDefined()
|
||||
expect(player.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts in stopped state', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('play sets playing state and current source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test Track')
|
||||
expect(player.playing.value).toBe(true)
|
||||
expect(player.currentSrc.value).toBe('/audio/test.mp3')
|
||||
expect(player.currentName.value).toBe('Test Track')
|
||||
})
|
||||
|
||||
it('play toggles pause when same source is playing', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(true)
|
||||
// Play same source again — should pause
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('play switches to new source', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/first.mp3', 'First')
|
||||
player.play('/audio/second.mp3', 'Second')
|
||||
expect(player.currentSrc.value).toBe('/audio/second.mp3')
|
||||
expect(player.currentName.value).toBe('Second')
|
||||
})
|
||||
|
||||
it('pause pauses playback', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.pause()
|
||||
expect(player.playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stop resets all state', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.play('/audio/test.mp3', 'Test')
|
||||
player.stop()
|
||||
expect(player.playing.value).toBe(false)
|
||||
expect(player.currentSrc.value).toBeNull()
|
||||
expect(player.currentName.value).toBe('')
|
||||
})
|
||||
|
||||
it('progress computes correctly', () => {
|
||||
const player = useAudioPlayer()
|
||||
expect(player.progress.value).toBe(0) // duration is 0
|
||||
|
||||
player.currentTime.value = 30
|
||||
player.duration.value = 120
|
||||
expect(player.progress.value).toBe(25) // 30/120 * 100
|
||||
})
|
||||
|
||||
it('progress is 0 when duration is 0', () => {
|
||||
const player = useAudioPlayer()
|
||||
player.duration.value = 0
|
||||
player.currentTime.value = 10
|
||||
expect(player.progress.value).toBe(0)
|
||||
})
|
||||
|
||||
it('play() rejection is caught, not left as an unhandled promise rejection', async () => {
|
||||
// Regression: play() rejects independently of the 'error' event (e.g. a
|
||||
// peer-content 404 with no decodable source) — this used to be an
|
||||
// unhandled rejection in the browser console even though the 'error'
|
||||
// listener already set a friendly message (2026-07-01).
|
||||
const player = useAudioPlayer()
|
||||
// Initialize the singleton Audio element first (a no-op play call).
|
||||
player.play('/audio/warmup.mp3', 'Warmup')
|
||||
player.stop()
|
||||
|
||||
lastMockAudio!.shouldRejectPlay = true
|
||||
// Calling play() must not throw synchronously nor leave a rejected
|
||||
// promise unhandled — if useAudioPlayer's play() didn't .catch() the
|
||||
// rejection, `loading` would never flip back to false, since nothing
|
||||
// else resets it on this path (that's the real regression signal).
|
||||
expect(() => player.play('/audio/broken.mp3', 'Broken')).not.toThrow()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(player.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shared state across multiple useAudioPlayer calls', () => {
|
||||
const p1 = useAudioPlayer()
|
||||
const p2 = useAudioPlayer()
|
||||
p1.play('/audio/shared.mp3', 'Shared')
|
||||
expect(p2.currentSrc.value).toBe('/audio/shared.mp3')
|
||||
expect(p2.playing.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useCachedResource } from '../useCachedResource'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('useCachedResource', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not refetch on reactivation within the TTL, and refetches exactly once after the TTL lapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('v1')
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({
|
||||
key: 'test.reactivation-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
})
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate then reactivate inside the TTL — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — exactly one more fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('mounts and fetches without throwing outside any KeepAlive boundary', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('bare')
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
const resource = useCachedResource<string>({ key: 'test.bare-key', fetcher, persist: false })
|
||||
return () => h('div', resource.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Consumer)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toBe('bare')
|
||||
})
|
||||
|
||||
it('keeps last-known data and sets error on a rejected refresh, moving loadState ready -> refreshing (not loading)', async () => {
|
||||
const first = deferred<string>()
|
||||
const fetcher = vi.fn().mockReturnValueOnce(first.promise)
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({ key: 'test.error-key', fetcher, ttlMs: 1000, persist: false })
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
mount(Consumer)
|
||||
await Promise.resolve()
|
||||
first.resolve('v1')
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1')
|
||||
expect(resource!.loadState.value).toBe('ready')
|
||||
|
||||
const second = deferred<string>()
|
||||
fetcher.mockReturnValueOnce(second.promise)
|
||||
const refreshCall = resource!.refresh()
|
||||
await Promise.resolve()
|
||||
// Sticky-ready: a refresh on already-'ready' data moves to 'refreshing',
|
||||
// never back to 'loading' — content stays on screen while it runs.
|
||||
expect(resource!.loadState.value).toBe('refreshing')
|
||||
|
||||
second.reject(new Error('offline'))
|
||||
await refreshCall
|
||||
await flushPromises()
|
||||
|
||||
expect(resource!.data.value).toBe('v1') // keep-last-known-value
|
||||
expect(resource!.error.value).toBe('offline')
|
||||
})
|
||||
|
||||
// 02-04: found while auditing Cloud.vue/Server.vue's lazy (`immediate:
|
||||
// false`) resources ahead of adding their routes to KEEP_ALIVE_PATHS.
|
||||
// Without this guard, onActivated's refreshIfStale() would treat a
|
||||
// never-fetched entry as stale and eagerly fire the "fetch on first use"
|
||||
// resource the moment the tab is first activated, even though the caller
|
||||
// never explicitly requested it (e.g. a tab-gated Paid Files fetch that
|
||||
// should wait until that sub-tab is opened).
|
||||
it('does not eagerly fetch an immediate:false resource on activation before it has been explicitly requested, but does revalidate it once it has', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 0))
|
||||
const fetcher = vi.fn().mockResolvedValue('lazy-v1')
|
||||
let resource: ReturnType<typeof useCachedResource<string>> | null = null
|
||||
|
||||
const Consumer = defineComponent({
|
||||
setup() {
|
||||
resource = useCachedResource<string>({
|
||||
key: 'test.lazy-key',
|
||||
fetcher,
|
||||
ttlMs: 1000,
|
||||
persist: false,
|
||||
immediate: false,
|
||||
})
|
||||
return () => h('div', resource!.data.value ?? '')
|
||||
},
|
||||
})
|
||||
const Other = defineComponent({ render: () => h('div', 'other') })
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const show = ref(true)
|
||||
return { show }
|
||||
},
|
||||
render() {
|
||||
return h(KeepAlive, null, () =>
|
||||
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Host)
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled() // immediate: false — not fetched on mount
|
||||
|
||||
// Deactivate then reactivate — still never explicitly requested, so
|
||||
// activation must not be the thing that fetches it.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
|
||||
// The caller explicitly requests it now (e.g. the user opened the tab).
|
||||
await resource!.refresh()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate within the TTL, reactivate — no additional fetch.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Deactivate, advance past the TTL, reactivate — now it revalidates,
|
||||
// because it has been fetched before.
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = false
|
||||
await wrapper.vm.$nextTick()
|
||||
vi.setSystemTime(new Date(2030, 0, 1, 0, 0, 2)) // +2s, past the 1s TTL
|
||||
;(wrapper.vm as unknown as { show: boolean }).show = true
|
||||
await wrapper.vm.$nextTick()
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useContainersScanTimeout } from '../useContainersScanTimeout'
|
||||
|
||||
describe('useContainersScanTimeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflects the real scanned flag when it arrives before the timeout', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
scanned.value = true
|
||||
await nextTick()
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
expect(scanTimedOut.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start the timeout until initial data has loaded', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(false)
|
||||
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
|
||||
loaded.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(20_000)
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through after the timeout even if the flag never arrives', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(19_999)
|
||||
expect(effectiveContainersScanned.value).toBe(false)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(effectiveContainersScanned.value).toBe(true)
|
||||
expect(scanTimedOut.value).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the escape hatch when the real flag arrives', async () => {
|
||||
const scanned = ref(false)
|
||||
const loaded = ref(true)
|
||||
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
scanned.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(scanTimedOut.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Tests for useControllerNav — validates against GAMEPAD-NAV-MAP.md
|
||||
*
|
||||
* Tests the navigation logic (element queries, spatial nav, zone detection)
|
||||
* without mounting the composable (which needs Vue lifecycle).
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
// ─── Mocks ─────────────────────────────────────────────────────
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/dashboard' }),
|
||||
useRouter: () => ({ push: vi.fn().mockResolvedValue(undefined) }),
|
||||
}))
|
||||
vi.mock('@/stores/controller', () => ({ useControllerStore: () => ({ setActive: vi.fn(), setGamepadCount: vi.fn() }) }))
|
||||
vi.mock('@/stores/spotlight', () => ({ useSpotlightStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/stores/cli', () => ({ useCLIStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ isOpen: false, close: vi.fn() }) }))
|
||||
vi.mock('@/composables/useNavSounds', () => ({ playNavSound: vi.fn() }))
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||
'select:not([disabled])', 'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])', '[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function queryFocusable(root: HTMLElement | Document = document): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
el => !el.hasAttribute('data-controller-ignore') && !el.closest('[data-controller-ignore]')
|
||||
)
|
||||
}
|
||||
|
||||
function queryContainers(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (!zone) return []
|
||||
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]'))
|
||||
}
|
||||
|
||||
function queryNavBarItems(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (!zone) return []
|
||||
return queryFocusable(zone as HTMLElement).filter(el =>
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
}
|
||||
|
||||
function querySidebar(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
return zone ? queryFocusable(zone as HTMLElement) : []
|
||||
}
|
||||
|
||||
// ─── Module Export ──────────────────────────────────────────────
|
||||
|
||||
describe('module', () => {
|
||||
it('exports useControllerNav', async () => {
|
||||
const mod = await import('../useControllerNav')
|
||||
expect(typeof mod.useControllerNav).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SIDEBAR: Up/Down wrap, Right→container, Left→nothing ──────
|
||||
|
||||
describe('sidebar navigation (NAV-MAP: Sidebar)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('finds all sidebar nav items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/dashboard">Home</a>
|
||||
<a href="/dashboard/apps">Apps</a>
|
||||
<a href="/dashboard/cloud">Cloud</a>
|
||||
<button>AIUI</button>
|
||||
<button>Logout</button>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Card</div>
|
||||
</div>
|
||||
`
|
||||
expect(querySidebar().length).toBe(5)
|
||||
})
|
||||
|
||||
it('wraps down: Logout → Home', () => {
|
||||
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||
const lastIdx = items.length - 1
|
||||
expect((lastIdx + 1) % items.length).toBe(0) // wraps to Home
|
||||
})
|
||||
|
||||
it('wraps up: Home → Logout', () => {
|
||||
const items = ['Home', 'Apps', 'Cloud', 'Logout']
|
||||
expect((0 - 1 + items.length) % items.length).toBe(items.length - 1) // wraps to Logout
|
||||
})
|
||||
|
||||
it('right from sidebar targets first container, not nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn" id="tab">Tab</button>
|
||||
<div data-controller-container tabindex="0" id="card1">Card</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers[0]?.id).toBe('card1')
|
||||
})
|
||||
|
||||
it('left from sidebar does nothing (no target exists)', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar"><a href="/">Home</a></div>
|
||||
`
|
||||
const sidebar = querySidebar()
|
||||
const el = sidebar[0]!
|
||||
// Nothing to the left of sidebar
|
||||
expect(el.closest('[data-controller-zone="sidebar"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── HOME: 2-col grid + nav bar ────────────────────────────────
|
||||
|
||||
describe('HOME grid (NAV-MAP: HOME /dashboard)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has Dashboard and Setup nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div role="tablist">
|
||||
<button role="tab" class="mode-switcher-btn" id="dashTab">Dashboard</button>
|
||||
<button role="tab" class="mode-switcher-btn" id="setupTab">Setup</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
expect(navItems[0]?.id).toBe('dashTab')
|
||||
expect(navItems[1]?.id).toBe('setupTab')
|
||||
})
|
||||
|
||||
it('containers exclude nav bar items', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn">Dashboard</button>
|
||||
<button class="mode-switcher-btn">Setup</button>
|
||||
<div data-controller-container tabindex="0" id="myApps">My Apps</div>
|
||||
<div data-controller-container tabindex="0" id="cloud">Cloud</div>
|
||||
<div data-controller-container tabindex="0" id="network">Network</div>
|
||||
<div data-controller-container tabindex="0" id="wallet">Wallet</div>
|
||||
<div data-controller-container tabindex="0" id="system">System</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(5)
|
||||
expect(containers.map(c => c.id)).toEqual(['myApps', 'cloud', 'network', 'wallet', 'system'])
|
||||
// Nav bar items are separate
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
})
|
||||
|
||||
it('inner controls are not in the container grid', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="myApps">
|
||||
<a href="/dashboard/apps">Go</a>
|
||||
<button id="browseStore">Browse Store</button>
|
||||
<button id="manageApps">Manage Apps</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
// Only 1 container in grid
|
||||
expect(queryContainers().length).toBe(1)
|
||||
// Nav bar is empty (all focusables are inside the container)
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── APPS: 3-col grid + nav bar with tabs/filters/search ───────
|
||||
|
||||
describe('APPS grid (NAV-MAP: APPS /dashboard/apps)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('nav bar has tabs, filters, and search', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-switcher-btn" id="myAppsTab">My Apps</button>
|
||||
<a href="/dashboard/discover" class="mode-switcher-btn" id="storeTab">App Store</a>
|
||||
<button class="mode-switcher-btn" id="servicesTab">Services</button>
|
||||
</div>
|
||||
<div class="mode-switcher">
|
||||
<button class="mode-switcher-btn" id="allFilter">All</button>
|
||||
<button class="mode-switcher-btn" id="btcFilter">Bitcoin</button>
|
||||
</div>
|
||||
<input type="text" id="search" />
|
||||
<div data-controller-container tabindex="0" id="app1">App1</div>
|
||||
<div data-controller-container tabindex="0" id="app2">App2</div>
|
||||
<div data-controller-container tabindex="0" id="app3">App3</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
// 3 tabs + 2 filters + 1 search = 6 nav bar items
|
||||
expect(navItems.length).toBe(6)
|
||||
expect(navItems.map(el => el.id)).toEqual(['myAppsTab', 'storeTab', 'servicesTab', 'allFilter', 'btcFilter', 'search'])
|
||||
|
||||
// 3 containers
|
||||
expect(queryContainers().length).toBe(3)
|
||||
})
|
||||
|
||||
it('app cards with launch attribute are containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container data-controller-launch tabindex="0" id="app1">
|
||||
<button data-controller-launch-btn>Launch</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(1)
|
||||
expect(containers[0]?.hasAttribute('data-controller-launch')).toBe(true)
|
||||
const launchBtn = containers[0]?.querySelector('[data-controller-launch-btn]')
|
||||
expect(launchBtn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── CLOUD: 3-col, no nav bar ──────────────────────────────────
|
||||
|
||||
describe('CLOUD grid (NAV-MAP: CLOUD /dashboard/cloud)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has section cards as containers, no nav bar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="photos">Photos</div>
|
||||
<div data-controller-container tabindex="0" id="music">Music</div>
|
||||
<div data-controller-container tabindex="0" id="docs">Documents</div>
|
||||
<div data-controller-container tabindex="0" id="files">Files</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(4)
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── NETWORK: 2-col ────────────────────────────────────────────
|
||||
|
||||
describe('NETWORK grid (NAV-MAP: NETWORK /dashboard/server)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has Local Network and Web3 containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="localNet">Local Network</div>
|
||||
<div data-controller-container tabindex="0" id="web3">Web3</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(2)
|
||||
expect(containers[0]?.id).toBe('localNet')
|
||||
expect(containers[1]?.id).toBe('web3')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SETTINGS: vertical stack ──────────────────────────────────
|
||||
|
||||
describe('SETTINGS grid (NAV-MAP: SETTINGS /dashboard/settings)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has stacked section containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="account">Account Info</div>
|
||||
<div data-controller-container tabindex="0" id="password">Change Password</div>
|
||||
<div data-controller-container tabindex="0" id="twofa">Two-Factor</div>
|
||||
<div data-controller-container tabindex="0" id="system">System Info</div>
|
||||
<div data-controller-container tabindex="0" id="danger">Danger Zone</div>
|
||||
</div>
|
||||
`
|
||||
const containers = queryContainers()
|
||||
expect(containers.length).toBe(5)
|
||||
// No nav bar
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ENTER behavior ────────────────────────────────────────────
|
||||
|
||||
describe('enter key behavior (NAV-MAP: Rules 5)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('container with primary link: Enter should navigate', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0">
|
||||
<a href="/dashboard/apps" id="link">Go</a>
|
||||
<button>Browse</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
const link = container.querySelector('a[href]')
|
||||
expect(link).toBeTruthy()
|
||||
expect(link?.getAttribute('href')).toBe('/dashboard/apps')
|
||||
})
|
||||
|
||||
it('container without link: Enter drills into inner [Y] controls', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0">
|
||||
<button id="btn1">Open Shop</button>
|
||||
<button id="btn2">Accept Payments</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.querySelector('a[href]')).toBeNull()
|
||||
const inner = Array.from(container.querySelectorAll('button'))
|
||||
expect(inner.length).toBe(2)
|
||||
})
|
||||
|
||||
it('install container: Enter clicks install button', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container data-controller-install tabindex="0">
|
||||
<button data-controller-install-btn>Install</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.hasAttribute('data-controller-install')).toBe(true)
|
||||
expect(container.querySelector('[data-controller-install-btn]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('launch container: Enter clicks launch button', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container data-controller-launch tabindex="0">
|
||||
<button data-controller-launch-btn>Launch</button>
|
||||
</div>
|
||||
`
|
||||
const container = document.querySelector('[data-controller-container]')!
|
||||
expect(container.hasAttribute('data-controller-launch')).toBe(true)
|
||||
expect(container.querySelector('[data-controller-launch-btn]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── INSIDE CONTAINER [Y] ──────────────────────────────────────
|
||||
|
||||
describe('inside container navigation (NAV-MAP: Rules 6)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('inner controls are isolated from other containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card1">
|
||||
<button id="stop">Stop</button>
|
||||
<button id="restart">Restart</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="card2">
|
||||
<button id="other">Other</button>
|
||||
</div>
|
||||
`
|
||||
const card1 = document.getElementById('card1')!
|
||||
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
|
||||
expect(inner.length).toBe(2)
|
||||
expect(inner.map(el => el.id)).toEqual(['stop', 'restart'])
|
||||
// "other" is NOT in card1's inner controls
|
||||
expect(inner.find(el => el.id === 'other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('escape from inner control returns to container', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inner">Action</button>
|
||||
</div>
|
||||
`
|
||||
const inner = document.getElementById('inner')!
|
||||
const container = inner.closest('[data-controller-container]')
|
||||
expect(container).toBeTruthy()
|
||||
expect(container?.id).toBe('card')
|
||||
expect(container?.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
|
||||
it('isInsideContainer is true for nested, false for container itself', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inside">In</button>
|
||||
</div>
|
||||
<button id="outside">Out</button>
|
||||
`
|
||||
const inside = document.getElementById('inside')!
|
||||
const outside = document.getElementById('outside')!
|
||||
const card = document.getElementById('card')!
|
||||
|
||||
// inside: has container ancestor that isn't itself
|
||||
const insideContainer = inside.closest('[data-controller-container]')
|
||||
expect(insideContainer && insideContainer !== inside).toBe(true)
|
||||
// card: IS the container
|
||||
expect(card.hasAttribute('data-controller-container')).toBe(true)
|
||||
// outside: no container ancestor
|
||||
expect(outside.closest('[data-controller-container]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── TEXT INPUT handling ───────────────────────────────────────
|
||||
|
||||
describe('text input handling (NAV-MAP: text inputs)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('up/down exits input, left/right stays', () => {
|
||||
const exitKeys = ['ArrowUp', 'ArrowDown']
|
||||
const stayKeys = ['ArrowLeft', 'ArrowRight']
|
||||
exitKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(true))
|
||||
stayKeys.forEach(k => expect(['ArrowUp', 'ArrowDown'].includes(k)).toBe(false))
|
||||
})
|
||||
|
||||
it('enter on password clicks next button (submit)', () => {
|
||||
document.body.innerHTML = `
|
||||
<input id="pass" type="password" />
|
||||
<button id="login">Login</button>
|
||||
`
|
||||
const all = queryFocusable()
|
||||
const passIdx = all.findIndex(el => el.id === 'pass')
|
||||
const next = all[passIdx + 1]
|
||||
expect(next?.tagName).toBe('BUTTON')
|
||||
expect(next?.id).toBe('login')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── FOCUS MEMORY ──────────────────────────────────────────────
|
||||
|
||||
describe('focus memory (NAV-MAP: zone transitions)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('remembers and recalls elements', () => {
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
const btn = document.getElementById('btn')!
|
||||
memory.set('main', btn)
|
||||
expect(memory.get('main')).toBe(btn)
|
||||
expect(document.contains(btn)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects stale (removed) elements', () => {
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
const btn = document.getElementById('btn')!
|
||||
memory.set('main', btn)
|
||||
btn.remove()
|
||||
expect(document.contains(memory.get('main')!)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears on route change', () => {
|
||||
const memory = new Map<string, HTMLElement>()
|
||||
document.body.innerHTML = `<button id="btn">Test</button>`
|
||||
memory.set('main', document.getElementById('btn')!)
|
||||
memory.delete('main')
|
||||
expect(memory.get('main')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── SPATIAL NAVIGATION ────────────────────────────────────────
|
||||
|
||||
describe('spatial navigation', () => {
|
||||
it('overlap scoring: aligned > offset', () => {
|
||||
const from = { top: 50, bottom: 200, left: 0, right: 150 }
|
||||
const aligned = { top: 50, bottom: 200, left: 200, right: 350 }
|
||||
const offset = { top: 160, bottom: 310, left: 200, right: 350 }
|
||||
const alignedOv = Math.max(0, Math.min(from.bottom, aligned.bottom) - Math.max(from.top, aligned.top))
|
||||
const offsetOv = Math.max(0, Math.min(from.bottom, offset.bottom) - Math.max(from.top, offset.top))
|
||||
expect(alignedOv).toBe(150)
|
||||
expect(offsetOv).toBe(40)
|
||||
expect(alignedOv).toBeGreaterThan(offsetOv)
|
||||
})
|
||||
|
||||
it('tiebreaker: up/down prefers leftmost', () => {
|
||||
// Two elements below, same distance, same overlap
|
||||
const a = { left: 0 }
|
||||
const b = { left: 200 }
|
||||
// Sort: leftmost wins
|
||||
expect(a.left - b.left).toBeLessThan(0) // a is leftmost
|
||||
})
|
||||
|
||||
it('no wrap in 2D grid (NAV-MAP: Rules 2)', () => {
|
||||
// At rightmost column, pressing right should find nothing
|
||||
const from = { left: 400, right: 600, top: 0, bottom: 200 }
|
||||
const threshold = 50
|
||||
// No element to the right
|
||||
const candidate = { left: 0, right: 150 } // far left
|
||||
expect(candidate.left >= from.right - threshold).toBe(false) // NOT to the right
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GAMEPAD DETECTION ─────────────────────────────────────────
|
||||
|
||||
describe('gamepad detection', () => {
|
||||
it('counts connected gamepads', () => {
|
||||
const gp = [{ connected: true }, null, { connected: true }, null] as (Gamepad | null)[]
|
||||
expect(gp.filter(g => g?.connected).length).toBe(2)
|
||||
})
|
||||
it('handles null list', () => {
|
||||
const count = (gp: (Gamepad | null)[] | null) => gp ? gp.filter(g => g?.connected).length : 0
|
||||
expect(count(null)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DATA-CONTROLLER-IGNORE ────────────────────────────────────
|
||||
|
||||
describe('data-controller-ignore', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('excluded elements are filtered out', () => {
|
||||
document.body.innerHTML = `
|
||||
<button data-controller-ignore>Skip</button>
|
||||
<div data-controller-ignore><button>Nested ignored</button></div>
|
||||
<button id="real">Real</button>
|
||||
`
|
||||
const all = queryFocusable()
|
||||
expect(all.length).toBe(1)
|
||||
expect(all[0]?.id).toBe('real')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── NAV BAR [N] DETECTION ─────────────────────────────────────
|
||||
|
||||
describe('nav bar detection', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('nav bar items are in main zone but not inside containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<button class="mode-switcher-btn" id="tab1">Dashboard</button>
|
||||
<button class="mode-switcher-btn" id="tab2">Setup</button>
|
||||
<div data-controller-container tabindex="0" id="card">
|
||||
<button id="inner">Inner</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const navItems = queryNavBarItems()
|
||||
expect(navItems.length).toBe(2)
|
||||
expect(navItems[0]?.id).toBe('tab1')
|
||||
expect(navItems[1]?.id).toBe('tab2')
|
||||
// Inner button is NOT a nav bar item
|
||||
expect(navItems.find(el => el.id === 'inner')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pages without nav bar return empty', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Card</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryNavBarItems().length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DISCOVER: featured + grid ─────────────────────────────────
|
||||
|
||||
describe('DISCOVER grid (NAV-MAP: DISCOVER /dashboard/discover)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('has nav bar + featured + app grid', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<a href="/dashboard/apps" class="mode-switcher-btn" id="myApps">My Apps</a>
|
||||
<a href="/dashboard/discover" class="mode-switcher-btn" id="appStore">App Store</a>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="feat1">Featured 1</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="feat2">Featured 2</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="app1">App 1</div>
|
||||
<div data-controller-container data-controller-install tabindex="0" id="app2">App 2</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryNavBarItems().length).toBe(2)
|
||||
expect(queryContainers().length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── MESH / FLEET / SETTINGS containers exist ──────────────────
|
||||
|
||||
describe('pages have containers (NAV-MAP: all pages)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('mesh has panel containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="device">Device Status</div>
|
||||
<div data-controller-container tabindex="0" id="chat">Chat Panel</div>
|
||||
<div data-controller-container tabindex="0" id="peers">Peers</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(3)
|
||||
})
|
||||
|
||||
it('fleet has stat + node containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0">Nodes</div>
|
||||
<div data-controller-container tabindex="0">Online</div>
|
||||
<div data-controller-container tabindex="0">Offline</div>
|
||||
<div data-controller-container tabindex="0">Health</div>
|
||||
<div data-controller-container tabindex="0">Node 1</div>
|
||||
</div>
|
||||
`
|
||||
expect(queryContainers().length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── FULL FLOW: sidebar → container → inner → back ─────────────
|
||||
|
||||
describe('full navigation flow (NAV-MAP: Rules 1-8)', () => {
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('complete roundtrip: sidebar → container → inner → escape → sidebar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/dashboard" class="nav-tab-active" id="sideHome">Home</a>
|
||||
<a href="/dashboard/apps" id="sideApps">Apps</a>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="card1">
|
||||
<button id="inner1">Browse</button>
|
||||
<button id="inner2">Manage</button>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" id="card2">
|
||||
<a href="/dashboard/cloud">Go</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
// Step 1: Sidebar exists, has active tab
|
||||
const sidebar = querySidebar()
|
||||
expect(sidebar.length).toBe(2)
|
||||
const activeTab = document.querySelector('.nav-tab-active') as HTMLElement
|
||||
expect(activeTab?.id).toBe('sideHome')
|
||||
|
||||
// Step 2: Right from sidebar → first container
|
||||
const containers = queryContainers()
|
||||
expect(containers[0]?.id).toBe('card1')
|
||||
|
||||
// Step 3: Enter on card1 (no primary link) → drill into inner controls
|
||||
const card1 = document.getElementById('card1')!
|
||||
const inner = queryFocusable(card1).filter(el => el !== card1 && !el.hasAttribute('data-controller-container'))
|
||||
expect(inner.length).toBe(2)
|
||||
expect(inner[0]?.id).toBe('inner1')
|
||||
|
||||
// Step 4: Escape from inner → back to card1
|
||||
const innerEl = document.getElementById('inner1')!
|
||||
const parentContainer = innerEl.closest('[data-controller-container]')
|
||||
expect(parentContainer?.id).toBe('card1')
|
||||
|
||||
// Step 5: Escape from card1 → sidebar active tab
|
||||
expect(activeTab?.id).toBe('sideHome')
|
||||
|
||||
// Step 6: card2 has primary link → Enter navigates
|
||||
const card2 = document.getElementById('card2')!
|
||||
const primaryLink = card2.querySelector('a[href]')
|
||||
expect(primaryLink?.getAttribute('href')).toBe('/dashboard/cloud')
|
||||
})
|
||||
|
||||
it('no dead ends: every container can reach sidebar', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-controller-zone="sidebar">
|
||||
<a href="/" class="nav-tab-active">Home</a>
|
||||
</div>
|
||||
<div data-controller-zone="main">
|
||||
<div data-controller-container tabindex="0" id="c1">C1</div>
|
||||
<div data-controller-container tabindex="0" id="c2">C2</div>
|
||||
</div>
|
||||
`
|
||||
// Every container is in main zone
|
||||
const containers = queryContainers()
|
||||
containers.forEach(c => {
|
||||
expect(c.closest('[data-controller-zone="main"]')).toBeTruthy()
|
||||
})
|
||||
// Sidebar has at least one item
|
||||
expect(querySidebar().length).toBeGreaterThan(0)
|
||||
// Active tab exists for Left → sidebar
|
||||
expect(document.querySelector('.nav-tab-active')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { getFileCategory, useFileType, formatSize, formatDate } from '../useFileType'
|
||||
|
||||
describe('getFileCategory', () => {
|
||||
it('returns folder for directories', () => {
|
||||
expect(getFileCategory('', true)).toBe('folder')
|
||||
expect(getFileCategory('jpg', true)).toBe('folder')
|
||||
})
|
||||
|
||||
it('identifies image extensions', () => {
|
||||
expect(getFileCategory('jpg', false)).toBe('image')
|
||||
expect(getFileCategory('jpeg', false)).toBe('image')
|
||||
expect(getFileCategory('png', false)).toBe('image')
|
||||
expect(getFileCategory('gif', false)).toBe('image')
|
||||
expect(getFileCategory('webp', false)).toBe('image')
|
||||
expect(getFileCategory('svg', false)).toBe('image')
|
||||
expect(getFileCategory('bmp', false)).toBe('image')
|
||||
expect(getFileCategory('ico', false)).toBe('image')
|
||||
})
|
||||
|
||||
it('identifies audio extensions', () => {
|
||||
expect(getFileCategory('mp3', false)).toBe('audio')
|
||||
expect(getFileCategory('flac', false)).toBe('audio')
|
||||
expect(getFileCategory('wav', false)).toBe('audio')
|
||||
expect(getFileCategory('ogg', false)).toBe('audio')
|
||||
expect(getFileCategory('aac', false)).toBe('audio')
|
||||
expect(getFileCategory('m4a', false)).toBe('audio')
|
||||
})
|
||||
|
||||
it('identifies video extensions', () => {
|
||||
expect(getFileCategory('mp4', false)).toBe('video')
|
||||
expect(getFileCategory('mkv', false)).toBe('video')
|
||||
expect(getFileCategory('avi', false)).toBe('video')
|
||||
expect(getFileCategory('mov', false)).toBe('video')
|
||||
expect(getFileCategory('webm', false)).toBe('video')
|
||||
})
|
||||
|
||||
it('identifies document extensions', () => {
|
||||
expect(getFileCategory('pdf', false)).toBe('document')
|
||||
expect(getFileCategory('doc', false)).toBe('document')
|
||||
expect(getFileCategory('docx', false)).toBe('document')
|
||||
expect(getFileCategory('txt', false)).toBe('document')
|
||||
expect(getFileCategory('md', false)).toBe('document')
|
||||
})
|
||||
|
||||
it('identifies spreadsheet extensions', () => {
|
||||
expect(getFileCategory('xls', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('xlsx', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('csv', false)).toBe('spreadsheet')
|
||||
expect(getFileCategory('ods', false)).toBe('spreadsheet')
|
||||
})
|
||||
|
||||
it('identifies archive extensions', () => {
|
||||
expect(getFileCategory('zip', false)).toBe('archive')
|
||||
expect(getFileCategory('tar', false)).toBe('archive')
|
||||
expect(getFileCategory('gz', false)).toBe('archive')
|
||||
expect(getFileCategory('rar', false)).toBe('archive')
|
||||
expect(getFileCategory('7z', false)).toBe('archive')
|
||||
})
|
||||
|
||||
it('returns file for unknown extensions', () => {
|
||||
expect(getFileCategory('xyz', false)).toBe('file')
|
||||
expect(getFileCategory('', false)).toBe('file')
|
||||
expect(getFileCategory('bin', false)).toBe('file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileType', () => {
|
||||
it('returns correct category and computed values for an image', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
expect(result.isImage.value).toBe(true)
|
||||
expect(result.isAudio.value).toBe(false)
|
||||
expect(result.isVideo.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-blue-400')
|
||||
expect(result.badgeLabel.value).toBe('Image')
|
||||
})
|
||||
|
||||
it('returns correct values for audio', () => {
|
||||
const ext = ref('mp3')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-orange-400')
|
||||
expect(result.badgeLabel.value).toBe('Audio')
|
||||
})
|
||||
|
||||
it('returns correct values for video', () => {
|
||||
const ext = ref('mp4')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('video')
|
||||
expect(result.isVideo.value).toBe(true)
|
||||
expect(result.iconColor.value).toBe('text-purple-400')
|
||||
})
|
||||
|
||||
it('returns folder when isDir is true', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(true)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('folder')
|
||||
expect(result.isImage.value).toBe(false)
|
||||
expect(result.iconColor.value).toBe('text-amber-400')
|
||||
expect(result.badgeLabel.value).toBe('Folder')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const ext = ref('jpg')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.category.value).toBe('image')
|
||||
|
||||
ext.value = 'mp3'
|
||||
expect(result.category.value).toBe('audio')
|
||||
expect(result.isAudio.value).toBe(true)
|
||||
expect(result.isImage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('provides icon paths for each category', () => {
|
||||
const ext = ref('pdf')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.iconPaths.value).toBeDefined()
|
||||
expect(result.iconPaths.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('provides badge class for each category', () => {
|
||||
const ext = ref('zip')
|
||||
const isDir = ref(false)
|
||||
const result = useFileType(ext, isDir)
|
||||
|
||||
expect(result.badgeClass.value).toContain('bg-yellow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('formats 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B')
|
||||
})
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatSize(500)).toBe('500 B')
|
||||
})
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB')
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1.0 MB')
|
||||
})
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.0 GB')
|
||||
})
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(formatSize(1099511627776)).toBe('1.0 TB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns "Just now" for very recent dates', () => {
|
||||
const now = new Date().toISOString()
|
||||
expect(formatDate(now)).toBe('Just now')
|
||||
})
|
||||
|
||||
it('returns minutes ago for recent dates', () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
expect(formatDate(fiveMinAgo)).toBe('5m ago')
|
||||
})
|
||||
|
||||
it('returns hours ago for dates within 24h', () => {
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(threeHoursAgo)).toBe('3h ago')
|
||||
})
|
||||
|
||||
it('returns days ago for dates within a week', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
|
||||
expect(formatDate(twoDaysAgo)).toBe('2d ago')
|
||||
})
|
||||
|
||||
it('returns formatted date for older dates', () => {
|
||||
const oldDate = new Date('2025-01-15').toISOString()
|
||||
const result = formatDate(oldDate)
|
||||
// Should be a locale date string, not a relative time
|
||||
expect(result).toMatch(/\d/)
|
||||
expect(result).not.toContain('ago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useLightningRequired } from '../useLightningRequired'
|
||||
|
||||
// The gate reads install state off the app store's package list. Stub the
|
||||
// store rather than the RPC layer so the test pins the decision, not the
|
||||
// transport.
|
||||
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
get packages() {
|
||||
return packages.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useLightningRequired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
packages.value = {}
|
||||
// Module-scope `show` is shared by design (one global modal), so reset it
|
||||
// between cases or the first opener leaks into the next test.
|
||||
useLightningRequired().close()
|
||||
})
|
||||
|
||||
it('lets the action through when a Lightning node is running', () => {
|
||||
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('running')
|
||||
expect(lightning.requireLightningNode()).toBe(true)
|
||||
expect(lightning.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the node is present but NOT running, and says so', () => {
|
||||
// The bug this closes: `id in packages` is not "usable". A node with an
|
||||
// lnd entry in a non-running state produced a raw connection-refused
|
||||
// error ("Operation failed. Check server logs for details.").
|
||||
packages.value = { lnd: { state: 'stopped' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('stopped')
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('stopped')
|
||||
})
|
||||
|
||||
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
||||
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
expect(lightning.lightningStatus()).toBe('absent')
|
||||
expect(lightning.hasLightningNode()).toBe(false)
|
||||
// Returns false so the caller bails WITHOUT surfacing an error string —
|
||||
// that was the whole defect: a missing prerequisite rendered as a failure.
|
||||
expect(lightning.requireLightningNode()).toBe(false)
|
||||
expect(lightning.show.value).toBe(true)
|
||||
expect(lightning.status.value).toBe('absent')
|
||||
})
|
||||
|
||||
it('shares one modal state across call sites', () => {
|
||||
packages.value = {}
|
||||
const a = useLightningRequired()
|
||||
const b = useLightningRequired()
|
||||
|
||||
a.requireLightningNode()
|
||||
expect(b.show.value).toBe(true)
|
||||
b.close()
|
||||
expect(a.show.value).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty package list as absent', () => {
|
||||
packages.value = {}
|
||||
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
loop = false
|
||||
currentTime = 0
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock fetch for playLoopStart
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
}))
|
||||
|
||||
// Mock AudioContext
|
||||
const mockBufferSource = {
|
||||
buffer: null as AudioBuffer | null,
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
|
||||
const mockMediaElementSource = {
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockGainNode = {
|
||||
gain: {
|
||||
value: 1,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
|
||||
const mockAudioContext = {
|
||||
state: 'running' as AudioContextState,
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
createOscillator: vi.fn().mockReturnValue({
|
||||
type: 'sine',
|
||||
frequency: { value: 440, setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
createGain: vi.fn().mockReturnValue({ ...mockGainNode, gain: { ...mockGainNode.gain } }),
|
||||
createBufferSource: vi.fn().mockReturnValue({ ...mockBufferSource }),
|
||||
createMediaElementSource: vi.fn().mockReturnValue({ ...mockMediaElementSource }),
|
||||
decodeAudioData: vi.fn().mockResolvedValue({} as AudioBuffer),
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => ({ ...mockAudioContext })))
|
||||
|
||||
import {
|
||||
playPop,
|
||||
playLoginSuccessWhoosh,
|
||||
playTypingSound,
|
||||
playIntroTyping,
|
||||
stopIntroTyping,
|
||||
playWelcomeNoderunnerSpeech,
|
||||
playTypingTick,
|
||||
resumeAudioContext,
|
||||
startSynthwave,
|
||||
stopSynthwave,
|
||||
playLoopStart,
|
||||
playKeyboardTypingSound,
|
||||
playDashboardLoadOomph,
|
||||
} from '../useLoginSounds'
|
||||
|
||||
describe('useLoginSounds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('playPop', () => {
|
||||
it('creates Audio with pop.mp3 and plays it', () => {
|
||||
playPop()
|
||||
// Audio constructor was called (via MockAudio)
|
||||
expect(MockAudio.prototype.constructor).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not throw', () => {
|
||||
expect(() => playPop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoginSuccessWhoosh', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playLoginSuccessWhoosh()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingSound', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playIntroTyping', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('creates a looping audio element', () => {
|
||||
playIntroTyping()
|
||||
// Does not throw, creates audio
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopIntroTyping', () => {
|
||||
it('does not throw when no audio playing', () => {
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops audio that was started', () => {
|
||||
playIntroTyping()
|
||||
expect(() => stopIntroTyping()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playWelcomeNoderunnerSpeech', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playWelcomeNoderunnerSpeech()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playTypingTick', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
})
|
||||
|
||||
it('can be called multiple times (pool rotation)', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(() => playTypingTick()).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeAudioContext', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => resumeAudioContext()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSynthwave', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
// Without calling resumeAudioContext first, context might be null
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopSynthwave', () => {
|
||||
it('does not throw when nothing is playing', () => {
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playLoopStart', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playKeyboardTypingSound', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('playDashboardLoadOomph', () => {
|
||||
it('does not throw when no audio context', () => {
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio context lifecycle', () => {
|
||||
it('resumeAudioContext then startSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => startSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then stopSynthwave does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => stopSynthwave()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playKeyboardTypingSound does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playKeyboardTypingSound()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playDashboardLoadOomph does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playDashboardLoadOomph()).not.toThrow()
|
||||
})
|
||||
|
||||
it('resumeAudioContext then playLoopStart does not throw', () => {
|
||||
resumeAudioContext()
|
||||
expect(() => playLoopStart()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useMarketplaceApp } from '../useMarketplaceApp'
|
||||
|
||||
describe('useMarketplaceApp', () => {
|
||||
beforeEach(() => {
|
||||
const { clearCurrentApp } = useMarketplaceApp()
|
||||
clearCurrentApp()
|
||||
})
|
||||
|
||||
it('getCurrentApp returns null initially', () => {
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('setCurrentApp stores a full app', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
version: '25.0',
|
||||
icon: '/icons/btc.png',
|
||||
category: 'Finance',
|
||||
description: 'Bitcoin node',
|
||||
author: 'Satoshi',
|
||||
source: 'github',
|
||||
manifestUrl: 'https://example.com/manifest',
|
||||
url: 'https://example.com',
|
||||
repoUrl: 'https://github.com/bitcoin/bitcoin',
|
||||
s9pkUrl: '',
|
||||
dockerImage: 'bitcoin:25.0',
|
||||
})
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('bitcoin')
|
||||
expect(app!.title).toBe('Bitcoin Core')
|
||||
expect(app!.version).toBe('25.0')
|
||||
})
|
||||
|
||||
it('setCurrentApp with partial app fills defaults', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'lnd' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app).not.toBeNull()
|
||||
expect(app!.id).toBe('lnd')
|
||||
expect(app!.title).toBe('')
|
||||
expect(app!.version).toBe('')
|
||||
expect(app!.icon).toBe('')
|
||||
expect(app!.dockerImage).toBe('')
|
||||
})
|
||||
|
||||
it('manifestUrl falls back to s9pkUrl then url', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', s9pkUrl: 'https://s9pk.example.com/app.s9pk' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.manifestUrl).toBe('https://s9pk.example.com/app.s9pk')
|
||||
expect(app!.url).toBe('https://s9pk.example.com/app.s9pk')
|
||||
})
|
||||
|
||||
it('url falls back to s9pkUrl then manifestUrl', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'test', manifestUrl: 'https://manifest.example.com' })
|
||||
|
||||
const app = getCurrentApp()
|
||||
expect(app!.url).toBe('https://manifest.example.com')
|
||||
})
|
||||
|
||||
it('clearCurrentApp sets app to null', () => {
|
||||
const { setCurrentApp, clearCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({ id: 'bitcoin' })
|
||||
expect(getCurrentApp()).not.toBeNull()
|
||||
clearCurrentApp()
|
||||
expect(getCurrentApp()).toBeNull()
|
||||
})
|
||||
|
||||
it('shared state across multiple useMarketplaceApp calls', () => {
|
||||
const instance1 = useMarketplaceApp()
|
||||
const instance2 = useMarketplaceApp()
|
||||
|
||||
instance1.setCurrentApp({ id: 'mempool', title: 'Mempool' })
|
||||
const app = instance2.getCurrentApp()
|
||||
expect(app!.id).toBe('mempool')
|
||||
expect(app!.title).toBe('Mempool')
|
||||
})
|
||||
|
||||
it('handles description as object', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
description: { short: 'Short desc', long: 'Long description' },
|
||||
})
|
||||
const app = getCurrentApp()
|
||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||
})
|
||||
|
||||
it('preserves real screenshot metadata', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
screenshots: [
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(getCurrentApp()!.screenshots).toEqual([
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
getReceivedMessages: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useMessageToast } from '../useMessageToast'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useMessageToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
// Reset shared singleton state
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const toast = useMessageToast()
|
||||
toast.stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const toast = useMessageToast()
|
||||
expect(toast.receivedMessages.value).toEqual([])
|
||||
expect(toast.lastMessageCount.value).toBe(0)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('loadReceivedMessages fetches and stores messages', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
|
||||
],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.receivedMessages.value.length).toBe(1)
|
||||
expect(toast.lastMessageCount.value).toBe(1)
|
||||
expect(toast.loadingMessages.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show toast on initial load', async () => {
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
|
||||
})
|
||||
const toast = useMessageToast()
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
})
|
||||
|
||||
it('shows toast when new messages arrive after initial load', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// New message arrives
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('Second')
|
||||
})
|
||||
|
||||
it('shows count for multiple new messages', async () => {
|
||||
const toast = useMessageToast()
|
||||
|
||||
// Initial load
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
// Multiple new messages
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
|
||||
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
|
||||
],
|
||||
})
|
||||
await toast.loadReceivedMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(true)
|
||||
expect(toast.toastMessage.value.text).toBe('2 new messages')
|
||||
})
|
||||
|
||||
it('unreadCount reflects difference', async () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 1
|
||||
expect(toast.unreadCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('unreadCount is never negative', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 5
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('markAsRead syncs lastMessageCount', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.receivedMessages.value = [
|
||||
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
||||
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
||||
]
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.markAsRead()
|
||||
expect(toast.lastMessageCount.value).toBe(2)
|
||||
expect(toast.unreadCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
|
||||
})
|
||||
|
||||
it('stops polling on 401 error', async () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
toast.startPolling()
|
||||
|
||||
// Wait for initial load triggered by startPolling
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Polling should have stopped, so advancing time should NOT call again
|
||||
vi.clearAllMocks()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('startPolling does not create duplicate timers', () => {
|
||||
const toast = useMessageToast()
|
||||
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
toast.startPolling()
|
||||
// Should only have one timer — verify by stopping and checking no more calls
|
||||
toast.stopPolling()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { useMobileBackButton } from '../useMobileBackButton'
|
||||
|
||||
// Helper component that uses the composable
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
return useMobileBackButton()
|
||||
},
|
||||
template: '<div>{{ bottomPosition }}</div>',
|
||||
})
|
||||
|
||||
describe('useMobileBackButton', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns bottomPosition, bottomClass, and tabBarHeight', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
bottomClass: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
|
||||
expect(typeof vm.bottomPosition).toBe('string')
|
||||
expect(typeof vm.bottomClass).toBe('string')
|
||||
expect(typeof vm.tabBarHeight).toBe('number')
|
||||
})
|
||||
|
||||
it('defaults tabBarHeight to 72', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('computes bottomPosition as tabBarHeight + 8', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
bottomPosition: string
|
||||
tabBarHeight: number
|
||||
}
|
||||
expect(vm.bottomPosition).toBe('80px') // 72 + 8
|
||||
})
|
||||
|
||||
it('computes bottomClass with Tailwind arbitrary value', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const vm = wrapper.vm as unknown as { bottomClass: string }
|
||||
expect(vm.bottomClass).toBe('bottom-[80px]')
|
||||
})
|
||||
|
||||
it('reads tabBar element if present', async () => {
|
||||
// Create mock tab bar element
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', { value: 56 })
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(56)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
|
||||
it('falls back to CSS variable when no tab bar element', async () => {
|
||||
document.documentElement.style.setProperty('--mobile-tab-bar-height', '64')
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(64)
|
||||
|
||||
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
|
||||
})
|
||||
|
||||
it('keeps default when no tab bar or CSS var', async () => {
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
// Should keep the default of 72
|
||||
expect(vm.tabBarHeight).toBe(72)
|
||||
})
|
||||
|
||||
it('cleans up observers on unmount', () => {
|
||||
wrapper = mount(TestComponent)
|
||||
const removeEventSpy = vi.spyOn(window, 'removeEventListener')
|
||||
wrapper.unmount()
|
||||
expect(removeEventSpy).toHaveBeenCalled()
|
||||
removeEventSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updates on window resize', async () => {
|
||||
const tabBar = document.createElement('div')
|
||||
tabBar.setAttribute('data-mobile-tab-bar', '')
|
||||
Object.defineProperty(tabBar, 'offsetHeight', {
|
||||
value: 48,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
document.body.appendChild(tabBar)
|
||||
|
||||
wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Trigger resize
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await nextTick()
|
||||
|
||||
const vm = wrapper.vm as unknown as { tabBarHeight: number }
|
||||
expect(vm.tabBarHeight).toBe(48)
|
||||
|
||||
document.body.removeChild(tabBar)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useModalKeyboard } from '../useModalKeyboard'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
// We need to test the composable inside a component
|
||||
function createTestComponent(onCloseFn: () => void) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useModalKeyboard(containerRef, isOpen, onCloseFn, {
|
||||
restoreFocusRef,
|
||||
})
|
||||
|
||||
return { containerRef, isOpen, restoreFocusRef }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger">Trigger</button>
|
||||
<div v-if="isOpen" ref="containerRef">
|
||||
<button id="btn1">One</button>
|
||||
<button id="btn2">Two</button>
|
||||
<button id="btn3">Three</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('useModalKeyboard', () => {
|
||||
let closeFn: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
closeFn = vi.fn()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed and modal is open', async () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.vm.isOpen = true
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).toHaveBeenCalledOnce()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not call onClose when modal is closed', () => {
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
|
||||
expect(closeFn).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cleans up listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const Comp = createTestComponent(closeFn)
|
||||
const wrapper = mount(Comp, { attachTo: document.body })
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
removeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock Audio globally
|
||||
class MockAudio {
|
||||
src = ''
|
||||
volume = 1
|
||||
play = vi.fn().mockResolvedValue(undefined)
|
||||
pause = vi.fn()
|
||||
currentTime = 0
|
||||
addEventListener = vi.fn()
|
||||
}
|
||||
|
||||
vi.stubGlobal('Audio', MockAudio)
|
||||
|
||||
// Mock AudioContext
|
||||
const mockOscillator = {
|
||||
type: 'sine',
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
const mockGain = {
|
||||
gain: {
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
},
|
||||
connect: vi.fn(),
|
||||
}
|
||||
const mockAudioContext = {
|
||||
createOscillator: vi.fn().mockReturnValue(mockOscillator),
|
||||
createGain: vi.fn().mockReturnValue(mockGain),
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
}
|
||||
|
||||
vi.stubGlobal('AudioContext', vi.fn().mockImplementation(() => mockAudioContext))
|
||||
|
||||
import { playNavSound } from '../useNavSounds'
|
||||
|
||||
describe('playNavSound', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a function', () => {
|
||||
expect(playNavSound).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('plays move sound (default)', () => {
|
||||
playNavSound()
|
||||
// Should try to play a sound
|
||||
})
|
||||
|
||||
it('plays move sound explicitly', () => {
|
||||
playNavSound('move')
|
||||
})
|
||||
|
||||
it('plays select sound', () => {
|
||||
playNavSound('select')
|
||||
})
|
||||
|
||||
it('plays action sound', () => {
|
||||
playNavSound('action')
|
||||
})
|
||||
|
||||
it('plays back sound using AudioContext', () => {
|
||||
playNavSound('back')
|
||||
// Back uses Web Audio API synthesis
|
||||
})
|
||||
|
||||
it('does not throw for any sound type', () => {
|
||||
expect(() => playNavSound('move')).not.toThrow()
|
||||
expect(() => playNavSound('select')).not.toThrow()
|
||||
expect(() => playNavSound('action')).not.toThrow()
|
||||
expect(() => playNavSound('back')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
isOnboardingComplete: vi.fn(),
|
||||
completeOnboarding: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { isOnboardingComplete, completeOnboarding } from '../useOnboarding'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('isOnboardingComplete', () => {
|
||||
it('returns true when RPC says complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(true)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when RPC says not complete', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockResolvedValue(false)
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage when RPC fails with non-retryable error', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false from localStorage fallback when not set', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('Unknown error'))
|
||||
const result = await isOnboardingComplete()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('retries on 502 errors before falling back', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('502 Bad Gateway'))
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
expect(mockedRpc.isOnboardingComplete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries on 503 errors', async () => {
|
||||
mockedRpc.isOnboardingComplete
|
||||
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
|
||||
.mockResolvedValueOnce(false)
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
const result = await promise
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to localStorage after exhausting retries', async () => {
|
||||
mockedRpc.isOnboardingComplete.mockRejectedValue(new Error('502 Bad Gateway'))
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
describe('completeOnboarding', () => {
|
||||
it('calls RPC and sets localStorage', async () => {
|
||||
mockedRpc.completeOnboarding.mockResolvedValue(true)
|
||||
await completeOnboarding()
|
||||
expect(mockedRpc.completeOnboarding).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
|
||||
it('sets localStorage even when RPC fails', async () => {
|
||||
mockedRpc.completeOnboarding.mockRejectedValue(new Error('Network error'))
|
||||
const promise = completeOnboarding()
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
await promise
|
||||
expect(localStorage.getItem('neode_onboarding_complete')).toBe('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Module boundary stubs (per plan: jsdom has no real blob decoding —
|
||||
// assert on what was requested and what was routed where, not byte content) ──
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const playMock = vi.fn()
|
||||
vi.mock('../useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: playMock }),
|
||||
}))
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
const IMAGE_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-1',
|
||||
filename: 'photos/sunset.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
size_bytes: 2048,
|
||||
}
|
||||
const VIDEO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-2',
|
||||
filename: 'clips/holiday.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
size_bytes: 4096,
|
||||
}
|
||||
const AUDIO_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-3',
|
||||
filename: 'music/track.mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
size_bytes: 1024,
|
||||
}
|
||||
const DOC_ITEM: OwnedItemLike = {
|
||||
onion: 'abc123.onion',
|
||||
content_id: 'content-4',
|
||||
filename: 'docs/invoice.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 512,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||
let windowOpenSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||
revokeObjectURLSpy = vi.fn()
|
||||
URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL
|
||||
URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
||||
windowOpenSpy = vi.fn()
|
||||
window.open = windowOpenSpy as unknown as typeof window.open
|
||||
// atob is provided by jsdom; stub it to avoid depending on real base64 semantics.
|
||||
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||
})
|
||||
|
||||
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value).toHaveLength(1)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('routes a video mime to the lightbox, not window.open', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(VIDEO_ITEM)
|
||||
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
expect(viewer.lightboxIndex.value).toBe(0)
|
||||
expect(viewer.lightboxItems.value[0]?.name).toBe('holiday.mp4')
|
||||
})
|
||||
|
||||
it('routes an audio mime to the audio player, never the lightbox', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(AUDIO_ITEM)
|
||||
|
||||
expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the browser tab for a mime with no in-app viewer', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(DOC_ITEM)
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener')
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
// Existing revoke timer for the browser-tab path is untouched.
|
||||
vi.advanceTimersByTime(60000)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('the synthetic lightbox item name carries the real extension', async () => {
|
||||
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(viewer.lightboxItems.value[0]?.name.endsWith('.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('sets opening for the whole duration of the fetch and clears it on success', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => {
|
||||
// No cached-resource / refreshing concept is wired into this composable at
|
||||
// all: opening only ever reflects the current open() call's own RPC.
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
expect(viewer.opening.value).toBeNull() // idle before any open()
|
||||
const p = viewer.open(IMAGE_ITEM)
|
||||
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await p
|
||||
expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state
|
||||
})
|
||||
|
||||
it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Request timeout'))
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined()
|
||||
|
||||
expect(viewer.error.value).toBeTruthy()
|
||||
expect(viewer.opening.value).toBeNull()
|
||||
expect(viewer.lightboxIndex.value).toBeNull()
|
||||
})
|
||||
|
||||
it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => {
|
||||
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||
const viewer = usePaidItemViewer()
|
||||
|
||||
const p1 = viewer.open(IMAGE_ITEM)
|
||||
const p2 = viewer.open(IMAGE_ITEM)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
|
||||
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { usePipSession } from '../usePipSession'
|
||||
import { isPipSupported } from '../../utils/pip'
|
||||
|
||||
// jsdom has no picture-in-picture implementation at all, so these three
|
||||
// document/element members don't exist until stubbed — matching the plan's
|
||||
// note that `pipSupported` (module-level) can't be restubbed after import,
|
||||
// which is exactly why `isPipSupported()` exists as a call-time check.
|
||||
function definePipStub(enabled: boolean) {
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||
value: enabled,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
definePipStub(true)
|
||||
if (!('pictureInPictureElement' in document)) {
|
||||
Object.defineProperty(document, 'pictureInPictureElement', {
|
||||
value: null,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
|
||||
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
|
||||
return null as unknown as PictureInPictureWindow
|
||||
}
|
||||
}
|
||||
if (!document.exitPictureInPicture) {
|
||||
document.exitPictureInPicture = async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
function hostEl(): HTMLElement | null {
|
||||
return document.querySelector('[data-pip-session-host]')
|
||||
}
|
||||
|
||||
describe('usePipSession', () => {
|
||||
afterEach(() => {
|
||||
usePipSession().release()
|
||||
})
|
||||
|
||||
it('adopts a video into a body-level host that survives the owner unmounting', () => {
|
||||
const Owner = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', [h('video')])
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Owner, { attachTo: document.body })
|
||||
const video = wrapper.find('video').element as HTMLVideoElement
|
||||
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
expect(session.active.value).toBe(true)
|
||||
expect(session.element.value).toBe(video)
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
// The owning component is gone; the video must still be connected.
|
||||
expect(document.body.contains(video)).toBe(true)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
})
|
||||
|
||||
it('release() removes the adopted element and leaves the host empty', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
expect(hostEl()?.contains(video)).toBe(true)
|
||||
|
||||
session.release()
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
expect(hostEl()?.contains(video)).toBe(false)
|
||||
expect(hostEl()?.childElementCount).toBe(0)
|
||||
})
|
||||
|
||||
it('creates exactly one host node no matter how many times the composable is called', () => {
|
||||
const video = document.createElement('video')
|
||||
usePipSession().adopt(video)
|
||||
usePipSession()
|
||||
usePipSession()
|
||||
expect(document.querySelectorAll('[data-pip-session-host]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('a leavepictureinpicture event on the adopted element releases the session', () => {
|
||||
const video = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
session.adopt(video)
|
||||
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
|
||||
expect(session.active.value).toBe(false)
|
||||
expect(session.element.value).toBeNull()
|
||||
})
|
||||
|
||||
it('adopting a second element while one is active releases the first rather than leaking it', () => {
|
||||
const first = document.createElement('video')
|
||||
const second = document.createElement('video')
|
||||
const session = usePipSession()
|
||||
|
||||
session.adopt(first)
|
||||
session.adopt(second)
|
||||
|
||||
expect(session.element.value).toBe(second)
|
||||
expect(hostEl()?.contains(first)).toBe(false)
|
||||
expect(hostEl()?.contains(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPipSupported', () => {
|
||||
it('reads document state at call time, not at import time', () => {
|
||||
definePipStub(false)
|
||||
expect(isPipSupported()).toBe(false)
|
||||
|
||||
definePipStub(true)
|
||||
expect(isPipSupported()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
describe('useToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Get a fresh toast instance and clear any leftover state
|
||||
const { toasts, dismiss } = useToast()
|
||||
// Dismiss all existing toasts
|
||||
for (const t of [...toasts.value]) {
|
||||
dismiss(t.id)
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates a success toast', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Operation complete')
|
||||
|
||||
expect(toasts.value.length).toBeGreaterThanOrEqual(1)
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Operation complete')
|
||||
expect(toast.variant).toBe('success')
|
||||
expect(toast.dismissing).toBe(false)
|
||||
})
|
||||
|
||||
it('creates an error toast', () => {
|
||||
const { error, toasts } = useToast()
|
||||
|
||||
error('Something went wrong')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('Something went wrong')
|
||||
expect(toast.variant).toBe('error')
|
||||
})
|
||||
|
||||
it('creates an info toast', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('FYI: Node syncing')
|
||||
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
expect(toast.message).toBe('FYI: Node syncing')
|
||||
expect(toast.variant).toBe('info')
|
||||
})
|
||||
|
||||
it('auto-dismisses toast after duration', () => {
|
||||
const { success, toasts } = useToast()
|
||||
|
||||
success('Will auto-dismiss')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
const toastId = toast.id
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(true)
|
||||
|
||||
// After 3000ms, the toast should start dismissing
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
const dismissingToast = toasts.value.find((t) => t.id === toastId)
|
||||
if (dismissingToast) {
|
||||
expect(dismissingToast.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After another 300ms, the toast should be fully removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toastId)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss marks toast as dismissing then removes it', () => {
|
||||
const { info, toasts, dismiss } = useToast()
|
||||
|
||||
info('Dismissable')
|
||||
const toast = toasts.value[toasts.value.length - 1]!
|
||||
|
||||
dismiss(toast.id)
|
||||
|
||||
// Should be marked as dismissing
|
||||
const found = toasts.value.find((t) => t.id === toast.id)
|
||||
if (found) {
|
||||
expect(found.dismissing).toBe(true)
|
||||
}
|
||||
|
||||
// After 300ms animation delay, should be removed
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
expect(toasts.value.some((t) => t.id === toast.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss is a no-op for nonexistent toast ID', () => {
|
||||
const { dismiss, toasts } = useToast()
|
||||
const countBefore = toasts.value.length
|
||||
|
||||
dismiss(999999)
|
||||
|
||||
expect(toasts.value.length).toBe(countBefore)
|
||||
})
|
||||
|
||||
it('each toast gets a unique ID', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
info('First')
|
||||
info('Second')
|
||||
info('Third')
|
||||
|
||||
const ids = toasts.value.slice(-3).map((t) => t.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(3)
|
||||
})
|
||||
|
||||
it('caps visible toasts at 5', () => {
|
||||
const { info, toasts } = useToast()
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
info(`Toast ${i}`)
|
||||
}
|
||||
|
||||
expect(toasts.value.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('toasts ref is readonly', () => {
|
||||
const { toasts } = useToast()
|
||||
// The readonly wrapper prevents direct mutation
|
||||
expect(typeof toasts.value).toBe('object')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// Regression suite for the recurring "tx link opens tx1138.com instead of
|
||||
// the local Mempool app" bug (reported again on .228, 2026-08-06).
|
||||
//
|
||||
// The root cause was never the explorer preference — it was that
|
||||
// `getAppState` reports `not-installed` for an app whose container list has
|
||||
// not been fetched yet. A click that landed before the list arrived sent
|
||||
// the user to a third-party explorer, telling that operator which
|
||||
// transaction they cared about. These tests pin the fix: the decision waits
|
||||
// for real data, and the local app wins whenever it exists.
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
const openSession = vi.fn()
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ openSession }),
|
||||
}))
|
||||
|
||||
let containerState: string
|
||||
let fetched: boolean
|
||||
const ensureFetched = vi.fn(async () => {
|
||||
// Mirrors the real store: state only becomes knowable after the fetch.
|
||||
fetched = true
|
||||
})
|
||||
vi.mock('@/stores/container', () => ({
|
||||
useContainerStore: () => ({
|
||||
ensureFetched,
|
||||
getAppState: (_id: string) => (fetched ? containerState : 'not-installed'),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useTxExplorer, DEFAULT_TX_EXPLORER } from '../useTxExplorer'
|
||||
|
||||
const TX = 'a'.repeat(64)
|
||||
|
||||
describe('useTxExplorer.openTx', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
fetched = false
|
||||
containerState = 'running'
|
||||
// Reset module-scope prefs/pending between tests.
|
||||
const { setExplorer, cancelPending } = useTxExplorer()
|
||||
setExplorer(DEFAULT_TX_EXPLORER, false)
|
||||
cancelPending()
|
||||
})
|
||||
|
||||
it('opens the local Mempool app when it is running', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('waits for the container list rather than assuming not-installed (the race)', async () => {
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
// fetched=false at click time — the old synchronous check read
|
||||
// 'not-installed' here and went external.
|
||||
await openTx(TX)
|
||||
expect(ensureFetched).toHaveBeenCalled()
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
expect(pendingTx.value).toBeNull()
|
||||
})
|
||||
|
||||
it('still prefers the local app when it is installed but stopped', async () => {
|
||||
containerState = 'stopped'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('prefers the local app mid-restart rather than leaking to a third party', async () => {
|
||||
containerState = 'restarting'
|
||||
const { openTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||
})
|
||||
|
||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||
containerState = 'not-installed'
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
await openTx(TX)
|
||||
expect(openSession).not.toHaveBeenCalled()
|
||||
expect(pendingTx.value).toBe(TX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref, Teleport } from 'vue'
|
||||
import { useViewActive } from '../useViewActive'
|
||||
|
||||
/**
|
||||
* Teleported chrome must not outlive the screen that raised it.
|
||||
*
|
||||
* Main tabs are KeepAlive'd, so navigating away deactivates a view instead of
|
||||
* unmounting it. Anything Teleported to <body> is outside the view's subtree
|
||||
* and therefore survives that deactivation — Mesh's mobile tab bar and the
|
||||
* shared BackButton stayed pinned above the bottom bar on every other screen.
|
||||
*/
|
||||
const ViewWithTeleportedChrome = defineComponent({
|
||||
name: 'ViewWithTeleportedChrome',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
return () =>
|
||||
h('div', [
|
||||
isViewActive.value
|
||||
? h(Teleport, { to: 'body' }, [h('button', { class: 'leaky-chrome' }, 'Back')])
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const Other = defineComponent({ name: 'Other', setup: () => () => h('div', 'other screen') })
|
||||
|
||||
function chromeCount() {
|
||||
return document.body.querySelectorAll('.leaky-chrome').length
|
||||
}
|
||||
|
||||
describe('useViewActive', () => {
|
||||
it('removes teleported chrome when the view is deactivated, and restores it on return', async () => {
|
||||
const showFirst = ref(true)
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, {
|
||||
default: () => (showFirst.value ? h(ViewWithTeleportedChrome) : h(Other)),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
// Navigate away: KeepAlive DEACTIVATES rather than unmounts.
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(0)
|
||||
|
||||
// Returning must bring it back — the whole point of KeepAlive is that the
|
||||
// instance survived, so the chrome has to come back with it.
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('keeps the instance alive across the round trip (performance is not sacrificed)', async () => {
|
||||
const showFirst = ref(true)
|
||||
const seen: number[] = []
|
||||
const Counting = defineComponent({
|
||||
name: 'Counting',
|
||||
setup() {
|
||||
const isViewActive = useViewActive()
|
||||
const uid = Math.random()
|
||||
seen.push(uid)
|
||||
return () => h('div', [isViewActive.value ? h(Teleport, { to: 'body' }, [h('i', { class: 'leaky-chrome' })]) : null])
|
||||
},
|
||||
})
|
||||
|
||||
const host = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(KeepAlive, null, { default: () => (showFirst.value ? h(Counting) : h(Other)) }),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
)
|
||||
|
||||
showFirst.value = false
|
||||
await host.vm.$nextTick()
|
||||
showFirst.value = true
|
||||
await host.vm.$nextTick()
|
||||
|
||||
// setup() ran once: the view was cached, not re-created. If this ever
|
||||
// becomes 2, the fix has been "solved" by throwing away the perf work.
|
||||
expect(seen.length).toBe(1)
|
||||
expect(chromeCount()).toBe(1)
|
||||
|
||||
host.unmount()
|
||||
})
|
||||
|
||||
it('defaults to active outside a KeepAlive boundary', () => {
|
||||
// Neither hook fires here. A component used both ways — or mounted bare in
|
||||
// a test — must render normally rather than stay invisible forever.
|
||||
const host = mount(ViewWithTeleportedChrome, { attachTo: document.body })
|
||||
expect(chromeCount()).toBe(1)
|
||||
host.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Archy content adapter — maps `core/archipelago/src/content_server.rs`'s
|
||||
* `ContentItem` (peer files, this node's own shared files, IndeeHub movies,
|
||||
* paid/owned purchases) onto AIUI's `Film`/`Song`/`Podcast` shapes so its
|
||||
* existing `FilmGrid`/`SongGrid`/`NewsGrid` components can render real node
|
||||
* data instead of records regex-scraped out of the model's own reply text
|
||||
* (D-12, 13-CONTEXT.md).
|
||||
*
|
||||
* RESEARCH Pitfall 4: `ContentItem` (`id`, `filename`, `mime_type`,
|
||||
* `size_bytes`, `description`, `access`, `availability`, `added_at`) has NO
|
||||
* shape overlap with `Film`/`Song`/`Podcast` (`posterUrl`, `coverUrl`,
|
||||
* `sources[]`, `genres`, `runtime`, `director`, ...). This is a hand-written
|
||||
* adapter, not a pass-through — every field below is a deliberate mapping
|
||||
* decision, pinned by `__tests__/archyContentAdapter.test.ts`.
|
||||
*
|
||||
* neode-ui does not depend on `@aiui/core` (D-19 keeps the two packages
|
||||
* decoupled even though they now live in one repo), so the target shapes are
|
||||
* declared locally here rather than imported. They are kept structurally
|
||||
* identical to `aiui/packages/core/src/types/content.ts`'s `Film`/`Song`/
|
||||
* `Podcast`, plus a small Archipelago-only extension (`locked`/`priceSats`)
|
||||
* that AIUI's grids simply ignore today (D-14's paid-unlock state) — the
|
||||
* "shape pinning" test below is the regression pin against silent drift.
|
||||
*/
|
||||
|
||||
// ─── Target shapes (structurally match aiui/packages/core/src/types/content.ts) ───
|
||||
|
||||
export interface FilmSource {
|
||||
type: 'plex' | 'nextcloud' | 'youtube' | 'free-web' | 'indeehub'
|
||||
name: string
|
||||
url: string
|
||||
quality?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Film {
|
||||
id: string
|
||||
title: string
|
||||
year: number
|
||||
posterUrl: string
|
||||
backdropUrl?: string
|
||||
synopsis: string
|
||||
genres: string[]
|
||||
rating: number
|
||||
runtime: number
|
||||
director: string
|
||||
cast: string[]
|
||||
trailerUrl?: string
|
||||
sources: FilmSource[]
|
||||
/** Archipelago extension (not part of AIUI's `Film` type): set when this
|
||||
* item is `access: 'Paid'` and not yet unlocked. AIUI's `FilmGrid` reads
|
||||
* only the fields above and ignores unknown ones, so this is additive. */
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
export interface SongSource {
|
||||
type:
|
||||
| 'plex'
|
||||
| 'spotify'
|
||||
| 'youtube'
|
||||
| 'apple-music'
|
||||
| 'bandcamp'
|
||||
| 'soundcloud'
|
||||
| 'wavlake'
|
||||
| 'internet_archive'
|
||||
| 'jamendo'
|
||||
| 'odysee'
|
||||
| 'funkwhale'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
album?: string
|
||||
year?: number
|
||||
coverUrl?: string
|
||||
duration?: number
|
||||
genres?: string[]
|
||||
sources?: SongSource[]
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
export interface PodcastSource {
|
||||
type: 'fountain' | 'rumble' | 'youtube' | 'podcastindex' | 'castopod' | 'odysee' | 'podverse' | 'ipfs' | 'rss'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface Podcast {
|
||||
id: string
|
||||
title: string
|
||||
host?: string
|
||||
description?: string
|
||||
coverUrl?: string
|
||||
year?: number
|
||||
episodeCount?: number
|
||||
genres?: string[]
|
||||
sources: PodcastSource[]
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
/** Structurally matches `aiui/packages/core/src/types/content.ts`'s
|
||||
* `ImageItem` — declared locally for the same D-19 reason as the shapes
|
||||
* above (neode-ui does not depend on `@aiui/core`). */
|
||||
export interface ImageItem {
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
source?: string
|
||||
attribution?: string
|
||||
locked?: boolean
|
||||
priceSats?: number
|
||||
}
|
||||
|
||||
// ─── Source shape: content_server.rs's ContentItem, as seen over RPC ───
|
||||
|
||||
/** `AccessControl` (`core/archipelago/src/content_server.rs`) serializes via
|
||||
* serde's default externally-tagged representation with `rename_all =
|
||||
* "lowercase"`: unit variants become bare strings, the struct variant
|
||||
* becomes `{ paid: { price_sats, accepted } }`. */
|
||||
export type ArchyAccessControl =
|
||||
| 'free'
|
||||
| 'peersonly'
|
||||
| { paid: { price_sats: number; accepted?: string[] } }
|
||||
|
||||
/** `Availability`, same serialization convention. Not consumed by the
|
||||
* adapter's mapping logic today (RPC scope already decides what's fetched);
|
||||
* kept on the type for fixture fidelity and future use. */
|
||||
export type ArchyAvailability = 'nobody' | 'allpeers' | { specific: { peers: string[] } }
|
||||
|
||||
/** The wire shape of `content_server::ContentItem`, mirrored field-for-field. */
|
||||
export interface ArchyContentItem {
|
||||
id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
description?: string | null
|
||||
access?: ArchyAccessControl
|
||||
availability?: ArchyAvailability
|
||||
added_at?: string | null
|
||||
}
|
||||
|
||||
export interface ArchyContentBundle {
|
||||
films: Film[]
|
||||
songs: Song[]
|
||||
podcasts: Podcast[]
|
||||
/** Shared photos. Images were previously classified 'excluded' and
|
||||
* dropped on the floor, so a node sharing mostly photos rendered as an
|
||||
* empty grid — the single biggest gap between what the assistant could
|
||||
* DESCRIBE and what the surface could SHOW. AIUI has had an image grid
|
||||
* (`panelImages`/`ImageGrid`) the whole time; nothing fed it. */
|
||||
images: ImageItem[]
|
||||
}
|
||||
|
||||
export interface AdaptContentOptions {
|
||||
/** Where this batch of items came from — decides the `sources[]` badge
|
||||
* and how a playable URL is built. Not a per-item field: a whole RPC
|
||||
* response (one node's catalog, one peer's catalog, or IndeeHub) shares
|
||||
* one source. */
|
||||
source: 'own' | 'peer' | 'indeehub'
|
||||
/** Required when `source === 'peer'` — the peer's onion address, needed
|
||||
* to build the Range-streaming proxy URL. */
|
||||
peerOnion?: string
|
||||
}
|
||||
|
||||
// ─── Classification ───────────────────────────────────────────────────────
|
||||
|
||||
type ContentBucket = 'video' | 'audio' | 'image' | 'excluded'
|
||||
|
||||
// `m4a`, `aac`, `opus` and `wma` classify as audio via this extension
|
||||
// fallback — `ShareModal.vue`'s mime map omits exactly these four today, so
|
||||
// files shared with those extensions arrive here as generic
|
||||
// `application/octet-stream` (or another wrong mime) rather than `audio/*`.
|
||||
// Without the fallback they would be silently mis-typed as `excluded`
|
||||
// instead of routing to the Songs bucket. 13-11 fixes the share side; this
|
||||
// adapter must not inherit the same blind spot in the meantime.
|
||||
const AUDIO_EXT_FALLBACK = new Set([
|
||||
'm4a',
|
||||
'aac',
|
||||
'opus',
|
||||
'wma',
|
||||
'mp3',
|
||||
'flac',
|
||||
'wav',
|
||||
'ogg',
|
||||
])
|
||||
|
||||
const VIDEO_EXT_FALLBACK = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v'])
|
||||
|
||||
// Same reasoning as the audio fallback above: a photo shared with a mime
|
||||
// this node could not identify still has an unambiguous extension, and a
|
||||
// node whose catalog is mostly photos should not present as empty.
|
||||
const IMAGE_EXT_FALLBACK = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'bmp'])
|
||||
|
||||
function extensionOf(filename: string): string {
|
||||
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
|
||||
const idx = base.lastIndexOf('.')
|
||||
return idx > 0 ? base.slice(idx + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
/** Strip the extension from a filename to derive a display title. Directory
|
||||
* separators are stripped first so a full relative path collapses to a
|
||||
* bare filename-derived title. */
|
||||
function stripExtension(filename: string): string {
|
||||
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
|
||||
const idx = base.lastIndexOf('.')
|
||||
return idx > 0 ? base.slice(0, idx) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which grid bucket a `ContentItem` belongs to. Video and audio mimes
|
||||
* (and, as a fallback for a wrong/generic mime, video and audio extensions)
|
||||
* route to Film/Song respectively; everything else (image, document, or
|
||||
* anything unrecognized) is excluded from all three buckets rather than
|
||||
* mis-typed into one. `ContentItem` carries no podcast-specific signal
|
||||
* (no episode/feed metadata), so nothing classifies as `podcast` here —
|
||||
* `adaptToPodcast` exists for shape completeness and future reuse (e.g. an
|
||||
* RSS/podcast-feed source) but `adaptContentItems` never calls it today.
|
||||
*/
|
||||
export function classifyByMime(item: Pick<ArchyContentItem, 'mime_type' | 'filename'>): ContentBucket {
|
||||
const mime = (item.mime_type || '').toLowerCase().trim()
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
|
||||
const ext = extensionOf(item.filename || '')
|
||||
if (AUDIO_EXT_FALLBACK.has(ext)) return 'audio'
|
||||
if (VIDEO_EXT_FALLBACK.has(ext)) return 'video'
|
||||
if (IMAGE_EXT_FALLBACK.has(ext)) return 'image'
|
||||
return 'excluded'
|
||||
}
|
||||
|
||||
// ─── Access / paid-lock helpers ────────────────────────────────────────────
|
||||
|
||||
function paidPriceSats(access: ArchyAccessControl | undefined): number | null {
|
||||
if (access && typeof access === 'object' && 'paid' in access) {
|
||||
return access.paid.price_sats
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Source badge + URL building ───────────────────────────────────────────
|
||||
|
||||
// FilmSource's type union has no literal that means "this node" or "a peer"
|
||||
// by name — these are infrastructure-flavored badges borrowed from AIUI's
|
||||
// existing (unmodified, D-12) vocabulary. 'nextcloud' (self-hosted file
|
||||
// storage) stands in for this node's own catalog; 'plex' (a media server)
|
||||
// stands in for a peer's shared catalog; 'indeehub' is IndeeHub's own
|
||||
// literal. SongSource's union has no 'nextcloud' entry at all, so the
|
||||
// self-hosted analogue there is 'funkwhale' (a self-hosted, federated audio
|
||||
// server) — the closest existing badge to "this node". These three literal
|
||||
// values are pinned by the adapter's tests so a later refactor cannot
|
||||
// quietly change what a grid badge means (13-06-PLAN.md Task 1).
|
||||
const FILM_SOURCE_TYPE: Record<AdaptContentOptions['source'], FilmSource['type']> = {
|
||||
own: 'nextcloud',
|
||||
peer: 'plex',
|
||||
indeehub: 'indeehub',
|
||||
}
|
||||
|
||||
const SONG_SOURCE_TYPE: Record<AdaptContentOptions['source'], SongSource['type']> = {
|
||||
own: 'funkwhale',
|
||||
peer: 'plex',
|
||||
// IndeeHub carries no audio catalog in this plan's scope — audio arriving
|
||||
// tagged 'indeehub' is not an expected path, so this falls back to the
|
||||
// generic peer-media badge rather than an invalid literal.
|
||||
indeehub: 'plex',
|
||||
}
|
||||
|
||||
const PODCAST_SOURCE_TYPE: Record<AdaptContentOptions['source'], PodcastSource['type']> = {
|
||||
own: 'rss',
|
||||
peer: 'rss',
|
||||
indeehub: 'rss',
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<AdaptContentOptions['source'], string> = {
|
||||
own: 'This node',
|
||||
peer: 'Peer',
|
||||
indeehub: 'IndeeHub',
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a playable media URL for an unlocked item. **Never** builds a URL
|
||||
* containing a credential in its query string (T-13-32): own-node media
|
||||
* resolves through the existing content endpoint (`/content/<id>`, which is
|
||||
* itself unauthenticated by design — content_server access control is
|
||||
* per-item, not per-session), and peer media through the existing Rust
|
||||
* Range-streaming proxy (`/api/peer-content/<onion>/<id>`, which rides the
|
||||
* page's own session cookie automatically as a same-origin request). Both
|
||||
* already exist; this function names them, it does not mint anything new.
|
||||
*/
|
||||
function buildMediaUrl(item: ArchyContentItem, opts: AdaptContentOptions): string {
|
||||
if (opts.source === 'peer') {
|
||||
if (!opts.peerOnion) return ''
|
||||
return `/api/peer-content/${encodeURIComponent(opts.peerOnion)}/${encodeURIComponent(item.id)}`
|
||||
}
|
||||
// 'own' and 'indeehub' items both live in this node's own catalog once
|
||||
// added (IndeeHub ingestion still lands an entry in the same catalog —
|
||||
// D-14 routes it through the existing content subsystem, not a new one).
|
||||
return `/content/${encodeURIComponent(item.id)}`
|
||||
}
|
||||
|
||||
// ─── Per-type mapping ───────────────────────────────────────────────────────
|
||||
|
||||
export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): Film {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = FILM_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
year: 0,
|
||||
posterUrl: '',
|
||||
synopsis: item.description ?? '',
|
||||
genres: [],
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
director: '',
|
||||
cast: [],
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a shared photo onto AIUI's `ImageItem`. A locked (paid, not yet
|
||||
* bought) image gets an EMPTY `url` for the same reason a locked film
|
||||
* does: the card should render its price and lock, not silently fetch
|
||||
* bytes the operator has not paid for.
|
||||
*/
|
||||
export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): ImageItem {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const title = stripExtension(item.filename || '')
|
||||
return {
|
||||
id: item.id,
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
title,
|
||||
description: item.description ?? '',
|
||||
// `alt` falls back to the title rather than being left empty — a photo
|
||||
// grid with no alt text is unreadable to a screen reader.
|
||||
alt: title,
|
||||
source: SOURCE_LABEL[opts.source],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = SONG_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
artist: '',
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for shape completeness and future reuse (see `classifyByMime`'s
|
||||
* doc comment) — not called by `adaptContentItems` today, since
|
||||
* `ContentItem` carries no signal that would classify an item as a podcast
|
||||
* rather than a song. */
|
||||
export function adaptToPodcast(item: ArchyContentItem, opts: AdaptContentOptions): Podcast {
|
||||
const priceSats = paidPriceSats(item.access)
|
||||
// 'own' items are served to the authenticated owner by the node's
|
||||
// owner-bypass (`serve_content`) even when they're listed paid for
|
||||
// buyers — the operator never pays for their own files, so never lock
|
||||
// them (a locked card suppresses the playable URL, which is exactly the
|
||||
// placeholder-only grid the operator reported).
|
||||
const locked = opts.source !== 'own' && priceSats !== null
|
||||
const sourceType = PODCAST_SOURCE_TYPE[opts.source]
|
||||
return {
|
||||
id: item.id,
|
||||
title: stripExtension(item.filename || ''),
|
||||
description: item.description ?? '',
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: SOURCE_LABEL[opts.source],
|
||||
url: locked ? '' : buildMediaUrl(item, opts),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
locked,
|
||||
...(priceSats !== null ? { priceSats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Deterministic ordering ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sort by `added_at` descending, `id` ascending as the tiebreak. A missing
|
||||
* `added_at` sorts as the oldest possible value rather than throwing or
|
||||
* sorting first. Calling this twice on the same input in a different array
|
||||
* order yields identical output order — the property the AIUI-03 "ordering"
|
||||
* edge requires.
|
||||
*/
|
||||
export function sortDeterministic(items: ArchyContentItem[]): ArchyContentItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const at = a.added_at ?? ''
|
||||
const bt = b.added_at ?? ''
|
||||
if (at !== bt) return at > bt ? -1 : 1
|
||||
if (a.id === b.id) return 0
|
||||
return a.id < b.id ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a batch of `ContentItem`s (all from the same source — this node, one
|
||||
* peer, or IndeeHub) into grid-ready `Film`/`Song`/`Podcast` records.
|
||||
*
|
||||
* - An empty input produces `{ films: [], songs: [], podcasts: [] }` — never
|
||||
* `undefined`, never a thrown error.
|
||||
* - Two items with identical `filename`/`size_bytes` but different `id`
|
||||
* produce two distinct cards — cards key on `id`, never on filename+size
|
||||
* (the AIUI-03 "adjacency" edge; also T-13-37).
|
||||
* - Output ordering is deterministic (see `sortDeterministic`).
|
||||
*/
|
||||
export function adaptContentItems(
|
||||
items: ArchyContentItem[] | null | undefined,
|
||||
opts: AdaptContentOptions,
|
||||
): ArchyContentBundle {
|
||||
const sorted = sortDeterministic(items ?? [])
|
||||
const films: Film[] = []
|
||||
const songs: Song[] = []
|
||||
const podcasts: Podcast[] = []
|
||||
const images: ImageItem[] = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const bucket = classifyByMime(item)
|
||||
if (bucket === 'video') films.push(adaptToFilm(item, opts))
|
||||
else if (bucket === 'audio') songs.push(adaptToSong(item, opts))
|
||||
else if (bucket === 'image') images.push(adaptToImage(item, opts))
|
||||
// 'excluded' (documents/archives/other) — no grid renders these, so
|
||||
// they stay out of every bucket rather than being mistyped into one.
|
||||
}
|
||||
|
||||
return { films, songs, podcasts, images }
|
||||
}
|
||||
|
||||
// ─── Library mapping (13-11) ────────────────────────────────────────────
|
||||
//
|
||||
// `music.list-tracks` (13-07, `core/archipelago/src/api/rpc/music.rs`)
|
||||
// returns real tag-extracted metadata (title/artist/album/duration) for
|
||||
// this node's indexed library — a materially different, richer input than
|
||||
// `ContentItem` above (which carries no tag data at all; `adaptToSong`
|
||||
// above always sets `artist: ''`). `adaptLibraryTracks` is a sibling
|
||||
// mapping, not a replacement: it feeds the same `songs` bucket of the
|
||||
// `ArchyContentBundle`/`content:push` shape with real metadata instead of
|
||||
// filename-derived guesses.
|
||||
|
||||
/** `MusicSource` (`core/archipelago/src/music/mod.rs`), as seen over RPC.
|
||||
* Serde's default externally-tagged representation: the unit variant
|
||||
* `OwnLibrary` becomes the bare string `"OwnLibrary"`; the struct variant
|
||||
* `Peer { onion }` becomes `{ "Peer": { "onion": string } }`. */
|
||||
export type ArchyMusicSource = 'OwnLibrary' | { Peer: { onion: string } }
|
||||
|
||||
/** The wire shape of `core/archipelago/src/music/mod.rs`'s `Track`, as
|
||||
* returned by `music.list-tracks` — field names match the Rust struct
|
||||
* verbatim (no serde rename). */
|
||||
export interface ArchyLibraryTrack {
|
||||
id: { source: ArchyMusicSource; path: string }
|
||||
title: string
|
||||
artist?: string | null
|
||||
album?: string | null
|
||||
album_artist?: string | null
|
||||
track_number?: number | null
|
||||
disc_number?: number | null
|
||||
year?: number | null
|
||||
duration_secs: number
|
||||
has_tags: boolean
|
||||
content_hash?: string | null
|
||||
}
|
||||
|
||||
/** A library track grouped under its album — exported for shape
|
||||
* completeness and future reuse (`adaptLibraryAlbums`'s doc comment),
|
||||
* mirroring `adaptToPodcast`'s status in this same file: not consumed by
|
||||
* this plan's own wiring (`SongGrid` renders a flat track list), but a
|
||||
* real, tested mapping a future album-detail view can reuse without
|
||||
* re-deriving the grouping. */
|
||||
export interface ArchyLibraryAlbum {
|
||||
album: string
|
||||
albumArtist: string
|
||||
tracks: Song[]
|
||||
}
|
||||
|
||||
function isPeerMusicSource(source: ArchyMusicSource): source is { Peer: { onion: string } } {
|
||||
return typeof source === 'object' && source !== null && 'Peer' in source
|
||||
}
|
||||
|
||||
/** Build a playable URL for a library track. **Never** builds a URL
|
||||
* carrying a credential in its query string (T-13-71, same rule as
|
||||
* `buildMediaUrl` above).
|
||||
*
|
||||
* An `OwnLibrary` track's `path` is an absolute, canonicalized filesystem
|
||||
* path rooted at `media_roots()`'s first entry
|
||||
* (`data_dir/filebrowser/Music`, 13-04/13-07) — everything after the
|
||||
* `/filebrowser/` path segment is the exact same FileBrowser-relative path
|
||||
* `filebrowser-client.ts`'s `streamUrl` already serves via
|
||||
* `/app/filebrowser/api/raw<path>` (the T-13-39 fix, 13-06), so this reuses
|
||||
* that existing route rather than minting a new one.
|
||||
*
|
||||
* A `Peer` track's `path` is the local byte-cache layout
|
||||
* `<data_dir>/purchased-content/<onion>/<content_id>` (13-07's second media
|
||||
* root) and resolves through the existing peer Range-streaming proxy —
|
||||
* exactly `buildMediaUrl`'s peer branch above, just deriving `onion` from
|
||||
* `MusicSource::Peer` instead of an adapter option and `content_id` from
|
||||
* the path's own basename. */
|
||||
function buildLibraryTrackUrl(track: ArchyLibraryTrack): string {
|
||||
if (isPeerMusicSource(track.id.source)) {
|
||||
const onion = track.id.source.Peer.onion
|
||||
const segments = track.id.path.split('/').filter(Boolean)
|
||||
const contentId = segments[segments.length - 1]
|
||||
if (!onion || !contentId) return ''
|
||||
return `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(contentId)}`
|
||||
}
|
||||
const marker = '/filebrowser/'
|
||||
const idx = track.id.path.indexOf(marker)
|
||||
if (idx === -1) return ''
|
||||
const relative = track.id.path.slice(idx + marker.length)
|
||||
if (!relative) return ''
|
||||
const encoded = relative
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join('/')
|
||||
return `/app/filebrowser/api/raw/${encoded}`
|
||||
}
|
||||
|
||||
function librarySourceKey(source: ArchyMusicSource): string {
|
||||
return isPeerMusicSource(source) ? `peer:${source.Peer.onion}` : 'own'
|
||||
}
|
||||
|
||||
/** Stable per-track id: `TrackId` (`{ source, path }`) has no single string
|
||||
* identity on the wire, so one is derived here deterministically from the
|
||||
* same two fields — the same input always produces the same id. */
|
||||
function libraryTrackId(track: ArchyLibraryTrack): string {
|
||||
return `${librarySourceKey(track.id.source)}:${track.id.path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `music.list-tracks` records onto AIUI's `Song` shape.
|
||||
*
|
||||
* - Title/artist/album/duration are carried through from the extracted
|
||||
* tags (`title`, `artist`, `album`, `duration_secs` → `duration`).
|
||||
* - A track whose `artist` tag is absent falls back to `album_artist`, and
|
||||
* to `''` if that is absent too — never the literal `null`/`undefined`.
|
||||
* - Ordering is **not** recomputed here: `music.list-tracks`'s own
|
||||
* response is already deterministically ordered
|
||||
* `(disc, track number, title)` with `(source, path)` as the final
|
||||
* tiebreak (13-07) — re-sorting by a different key in the browser would
|
||||
* make the grid and the RPC disagree about what "first" means, so this
|
||||
* is a straight, order-preserving map.
|
||||
* - No cover art is ever set (`coverUrl` stays `undefined`): `Track`
|
||||
* carries no artwork field at all, and AIUI's own artwork sources are
|
||||
* dev-server-only Vite middleware, 404 on a node (13-CONTEXT.md
|
||||
* landmine) — `SongGrid`'s existing no-artwork fallback renders instead
|
||||
* of a broken image, unchanged.
|
||||
* - A peer-sourced track's `sources[0].type` differs from an own-library
|
||||
* track's, using the same `'funkwhale'`/`'plex'` literals `SONG_SOURCE_TYPE`
|
||||
* already pins above (13-06).
|
||||
* - No produced URL ever carries a credential in its query string.
|
||||
* - `null`/`undefined`/empty input produces `[]`, never `undefined`.
|
||||
*/
|
||||
export function adaptLibraryTracks(tracks: ArchyLibraryTrack[] | null | undefined): Song[] {
|
||||
return (tracks ?? []).map((track) => {
|
||||
const peer = isPeerMusicSource(track.id.source)
|
||||
const sourceType: SongSource['type'] = peer ? 'plex' : 'funkwhale'
|
||||
const artist = track.artist ?? track.album_artist ?? ''
|
||||
return {
|
||||
id: libraryTrackId(track),
|
||||
title: track.title,
|
||||
artist,
|
||||
album: track.album ?? undefined,
|
||||
year: track.year ?? undefined,
|
||||
duration: track.duration_secs,
|
||||
sources: [
|
||||
{
|
||||
type: sourceType,
|
||||
name: peer ? 'Peer' : 'This node',
|
||||
url: buildLibraryTrackUrl(track),
|
||||
icon: sourceType,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Group `music.list-tracks` records into albums, keyed on
|
||||
* `(album_artist, album)` — the same derived-albums grouping
|
||||
* `13-MUSIC-MODEL.md` defines server-side for `music.list-albums`, computed
|
||||
* here over already-adapted `Song`s so a future album-detail view can reuse
|
||||
* it without a second RPC round trip. A track with no `album` tag forms no
|
||||
* album bucket (nothing to group it under) but is still present in
|
||||
* `adaptLibraryTracks`'s flat output. Grouping preserves the input's own
|
||||
* order — first-seen album first, tracks in the order they appear — so
|
||||
* calling this twice on the same input array yields identical output order.
|
||||
*/
|
||||
export function adaptLibraryAlbums(tracks: ArchyLibraryTrack[] | null | undefined): ArchyLibraryAlbum[] {
|
||||
const list = tracks ?? []
|
||||
const songs = adaptLibraryTracks(list)
|
||||
const albums: ArchyLibraryAlbum[] = []
|
||||
const index = new Map<string, ArchyLibraryAlbum>()
|
||||
|
||||
list.forEach((track, i) => {
|
||||
const album = track.album ?? ''
|
||||
if (!album) return
|
||||
const albumArtist = track.album_artist ?? track.artist ?? ''
|
||||
const key = `${albumArtist}::${album}`
|
||||
let bucket = index.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { album, albumArtist, tracks: [] }
|
||||
index.set(key, bucket)
|
||||
albums.push(bucket)
|
||||
}
|
||||
bucket.tracks.push(songs[i]!)
|
||||
})
|
||||
|
||||
return albums
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
parseFramesReducer,
|
||||
areFramesComplete,
|
||||
framesToData,
|
||||
totalNumberOfFrames,
|
||||
currentNumberOfFrames,
|
||||
} from 'qrloop'
|
||||
|
||||
/**
|
||||
* Collects animated-QR frames (qrloop format, as used by k484 for large
|
||||
* Fedimint tokens) and reassembles them into the original token string.
|
||||
*/
|
||||
export function useAnimatedQRDecoder() {
|
||||
const framesState = ref<ReturnType<typeof parseFramesReducer> | null>(null)
|
||||
const isComplete = ref(false)
|
||||
const decodedData = ref<string | null>(null)
|
||||
const uniqueFrames = ref<Set<string>>(new Set())
|
||||
|
||||
/** Feed one scanned frame; returns true once the full payload is decoded. */
|
||||
function addFrame(frame: string): boolean {
|
||||
if (isComplete.value) return true
|
||||
if (uniqueFrames.value.has(frame)) return false
|
||||
uniqueFrames.value.add(frame)
|
||||
|
||||
try {
|
||||
framesState.value = parseFramesReducer(framesState.value, frame)
|
||||
if (areFramesComplete(framesState.value)) {
|
||||
const dataBuffer = framesToData(framesState.value)
|
||||
// Tokens travel as URL-safe base64
|
||||
decodedData.value = dataBuffer
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
isComplete.value = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
// A frame that qrloop rejects may just be a different QR format
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function progressText(): string {
|
||||
if (!framesState.value) return ''
|
||||
const total = totalNumberOfFrames(framesState.value)
|
||||
const current = currentNumberOfFrames(framesState.value)
|
||||
return `${current}/${total} frames`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
framesState.value = null
|
||||
uniqueFrames.value.clear()
|
||||
isComplete.value = false
|
||||
decodedData.value = null
|
||||
}
|
||||
|
||||
return { isComplete, decodedData, addFrame, reset, progressText }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
const currentSrc = ref<string | null>(null)
|
||||
const currentName = ref('')
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const error = ref<string | null>(null)
|
||||
let initialized = false
|
||||
|
||||
/** Create the Audio element and attach listeners once */
|
||||
function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
audio.value = new Audio()
|
||||
audio.value.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio.value?.currentTime ?? 0
|
||||
})
|
||||
audio.value.addEventListener('loadedmetadata', () => {
|
||||
duration.value = audio.value?.duration ?? 0
|
||||
error.value = null
|
||||
})
|
||||
// Buffering / connecting over mesh|Tor → show a loader until it can play.
|
||||
audio.value.addEventListener('loadstart', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('waiting', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('canplay', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('playing', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('ended', () => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('pause', () => {
|
||||
playing.value = false
|
||||
})
|
||||
audio.value.addEventListener('play', () => {
|
||||
playing.value = true
|
||||
error.value = null
|
||||
})
|
||||
audio.value.addEventListener('error', () => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
|
||||
})
|
||||
}
|
||||
|
||||
function play(src: string, name: string) {
|
||||
init()
|
||||
error.value = null
|
||||
|
||||
if (currentSrc.value === src && playing.value) {
|
||||
audio.value!.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSrc.value !== src) {
|
||||
loading.value = true
|
||||
audio.value!.src = src
|
||||
currentSrc.value = src
|
||||
currentName.value = name
|
||||
}
|
||||
|
||||
// play() rejects (e.g. NotSupportedError when the peer 404s and there's no
|
||||
// decodable source) independently of the 'error' event above — uncaught,
|
||||
// this surfaces as a raw console error instead of the friendly message
|
||||
// already wired up there. The 'error' listener sets the same state, so
|
||||
// this just needs to stop the rejection from going unhandled.
|
||||
audio.value!.play().catch(() => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function pause() {
|
||||
audio.value?.pause()
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
if (audio.value) {
|
||||
audio.value.currentTime = time
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
audio.value.currentTime = 0
|
||||
}
|
||||
playing.value = false
|
||||
currentSrc.value = null
|
||||
currentName.value = ''
|
||||
}
|
||||
|
||||
const progress = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
export function useAudioPlayer() {
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
stop,
|
||||
playing,
|
||||
loading,
|
||||
currentName,
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
currentSrc,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
/**
|
||||
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
|
||||
*
|
||||
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
|
||||
* lease, samples the sync rate, and exposes a ticking countdown so setup
|
||||
* screens can show "~2h 14m remaining" that visibly counts down between
|
||||
* polls. Module-level singleton — every consumer sees the same state.
|
||||
*/
|
||||
|
||||
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
|
||||
export const IBD_SYNCED_AT = 99.9
|
||||
|
||||
const POLL_MS = 15_000
|
||||
const TICK_MS = 1_000
|
||||
/** Ignore rate samples older than this when estimating */
|
||||
const SAMPLE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
export const bitcoinSyncPercent = ref(0)
|
||||
export const bitcoinBlockHeight = ref(0)
|
||||
export const bitcoinSyncAvailable = ref(false)
|
||||
export const bitcoinSyncLoaded = ref(false)
|
||||
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
|
||||
|
||||
const etaSeconds = ref<number | null>(null)
|
||||
|
||||
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
|
||||
export const bitcoinSyncEtaText = computed(() => {
|
||||
const s = etaSeconds.value
|
||||
if (s === null) return ''
|
||||
if (s < 60) return 'less than a minute'
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}m ${sec}s`
|
||||
})
|
||||
|
||||
let samples: { t: number; p: number }[] = []
|
||||
let etaBase: { at: number; secs: number } | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let leases = 0
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 8000,
|
||||
})
|
||||
const pct = (btc.sync_progress ?? 0) * 100
|
||||
bitcoinSyncPercent.value = pct
|
||||
bitcoinBlockHeight.value = btc.block_height ?? 0
|
||||
bitcoinSyncAvailable.value = true
|
||||
bitcoinSyncLoaded.value = true
|
||||
|
||||
const now = Date.now()
|
||||
samples.push({ t: now, p: pct })
|
||||
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
|
||||
|
||||
if (pct >= IBD_SYNCED_AT) {
|
||||
etaBase = null
|
||||
etaSeconds.value = 0
|
||||
return
|
||||
}
|
||||
const first = samples[0]
|
||||
if (first && now - first.t >= 10_000 && pct > first.p) {
|
||||
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
|
||||
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
|
||||
}
|
||||
} catch {
|
||||
bitcoinSyncAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!etaBase) {
|
||||
if (!bitcoinSynced.value) etaSeconds.value = null
|
||||
return
|
||||
}
|
||||
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a polling lease. Returns a release function — call it on unmount.
|
||||
* Polling only runs while at least one lease is held.
|
||||
*/
|
||||
export function acquireBitcoinSync(): () => void {
|
||||
leases++
|
||||
if (leases === 1) {
|
||||
void poll()
|
||||
pollTimer = setInterval(() => void poll(), POLL_MS)
|
||||
tickTimer = setInterval(tick, TICK_MS)
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
leases = Math.max(0, leases - 1)
|
||||
if (leases === 0) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
pollTimer = null
|
||||
tickTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
|
||||
let activeLocks = 0
|
||||
let previousOverflow = ''
|
||||
|
||||
function lock() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (activeLocks === 0) {
|
||||
previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
activeLocks += 1
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (typeof document === 'undefined' || activeLocks === 0) return
|
||||
activeLocks -= 1
|
||||
if (activeLocks === 0) {
|
||||
document.body.style.overflow = previousOverflow
|
||||
previousOverflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useBodyScrollLock(active: Ref<boolean>) {
|
||||
watch(
|
||||
active,
|
||||
(isActive, wasActive) => {
|
||||
if (isActive && !wasActive) lock()
|
||||
if (!isActive && wasActive) unlock()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (active.value) unlock()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Stale-while-revalidate resource hook over the shared resources store.
|
||||
//
|
||||
// Usage:
|
||||
// const files = useCachedResource<CloudFile[]>({
|
||||
// key: 'cloud.my-files',
|
||||
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
|
||||
// ttlMs: 30_000,
|
||||
// })
|
||||
// // template: files.data renders instantly on revisit (cache), while
|
||||
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
|
||||
//
|
||||
// Behavior:
|
||||
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
|
||||
// snapshot (survives reload) → fetch.
|
||||
// - Sticky-ready: never regresses ready → loading; refreshes are
|
||||
// 'refreshing' so content stays on screen.
|
||||
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
|
||||
// background refresh runs only if the TTL has lapsed (or never fetched).
|
||||
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
|
||||
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
|
||||
// stale (debounced by TTL, so focus-flapping is free).
|
||||
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
||||
// the last subscribed component unmounts.
|
||||
|
||||
import { computed, getCurrentScope, onActivated, onScopeDispose, type ComputedRef } from 'vue'
|
||||
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||
|
||||
export interface CachedResourceOptions<T> {
|
||||
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
|
||||
key: string
|
||||
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
|
||||
fetcher: (signal: AbortSignal) => Promise<T>
|
||||
/** Data older than this triggers a background revalidate (default 30s). */
|
||||
ttlMs?: number
|
||||
/** Snapshot to sessionStorage so reloads paint instantly. REQUIRED (no
|
||||
* default) — `persist ?? true` was the exact footgun CR-01 hit (a wallet
|
||||
* resource omitted this and silently persisted balances to
|
||||
* sessionStorage), so every call site must make the decision explicitly.
|
||||
* Sensitive data (money, identity, peer-identity/DID/pubkey payloads)
|
||||
* MUST be `false`; static/aggregate/non-identifying data may be `true`. */
|
||||
persist: boolean
|
||||
/** Revalidate (if stale) when the window regains focus (default true). */
|
||||
revalidateOnFocus?: boolean
|
||||
/** Fetch on first use (default true). Set false for lazy resources. */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
export interface CachedResource<T> {
|
||||
entry: ResourceEntry<T>
|
||||
/** Convenience computed views over the entry. */
|
||||
data: ComputedRef<T | null>
|
||||
loadState: ComputedRef<ResourceLoadState>
|
||||
error: ComputedRef<string | null>
|
||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
||||
isStale: ComputedRef<boolean>
|
||||
ageMs: ComputedRef<number | null>
|
||||
/** Force a refresh now (deduped with any in-flight one). */
|
||||
refresh: () => Promise<void>
|
||||
/** Mark stale + debounce-refresh all mounted users of this key. */
|
||||
invalidate: () => void
|
||||
/** Optimistically update cached data; returns rollback for RPC failure. */
|
||||
optimistic: (update: (current: T | null) => T) => () => void
|
||||
}
|
||||
|
||||
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
|
||||
const store = useResourcesStore()
|
||||
const ttlMs = opts.ttlMs ?? 30_000
|
||||
const persist = opts.persist
|
||||
const entry = store.entry<T>(opts.key, persist)
|
||||
|
||||
const aborter = new AbortController()
|
||||
const fetcher = () => opts.fetcher(aborter.signal)
|
||||
const refresh = () => store.refresh(opts.key, fetcher, { persist })
|
||||
|
||||
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
|
||||
const refreshIfStale = () => {
|
||||
if (stale()) void refresh()
|
||||
}
|
||||
|
||||
// Register as a live revalidator so invalidate(key) reaches us.
|
||||
const unsubscribe = store.subscribe(opts.key, () => void refresh())
|
||||
|
||||
const onFocus = () => refreshIfStale()
|
||||
if (opts.revalidateOnFocus ?? true) {
|
||||
window.addEventListener('focus', onFocus)
|
||||
}
|
||||
|
||||
// Tied to the owning effect scope (component setup or manual scope);
|
||||
// outside any scope (tests, module init) there's nothing to dispose.
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
aborter.abort()
|
||||
})
|
||||
|
||||
// Reactivation (a KeepAlive'd component being shown again) is a distinct
|
||||
// trigger from mount and from window focus: onScopeDispose doesn't fire
|
||||
// on deactivate (the instance is preserved, not destroyed), and the
|
||||
// focus listener doesn't fire on an in-SPA tab switch (the window never
|
||||
// loses focus). Without this a kept-alive tab would paint instantly
|
||||
// forever and never revalidate. Vue no-ops onActivated outside a
|
||||
// <KeepAlive> boundary, so this is safe for every existing consumer.
|
||||
//
|
||||
// `immediate: false` resources are a distinct case (found in 02-04's
|
||||
// audit, once Cloud.vue/Server.vue's main-tab paths joined
|
||||
// KEEP_ALIVE_PATHS): `stale()` is true for any never-fetched entry
|
||||
// (`fetchedAt === null`), so a bare `refreshIfStale()` here would fire
|
||||
// the fetch the moment the tab is first activated — defeating a resource
|
||||
// deliberately marked "fetch on first use" (e.g. a tab-gated fetch that
|
||||
// should wait for the user to open that sub-tab). Only auto-revalidate
|
||||
// an `immediate: false` resource on activation once it has actually been
|
||||
// fetched at least once; before that, activation is a no-op and the
|
||||
// resource's own explicit trigger (a watcher, an onMounted/onActivated
|
||||
// "kick") still owns the first fetch.
|
||||
onActivated(() => {
|
||||
if (opts.immediate === false && entry.fetchedAt === null) return
|
||||
refreshIfStale()
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.immediate ?? true) refreshIfStale()
|
||||
|
||||
return {
|
||||
entry,
|
||||
data: computed(() => entry.data),
|
||||
loadState: computed(() => entry.loadState),
|
||||
error: computed(() => entry.error),
|
||||
isStale: computed(() => entry.data !== null && stale()),
|
||||
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
|
||||
refresh,
|
||||
invalidate: () => store.invalidate(opts.key),
|
||||
// Pass this resource's own already-decided `persist` through explicitly
|
||||
// (WR-04) — store.optimistic() requires it rather than defaulting, so
|
||||
// the entry's persist decision can never silently diverge by omission.
|
||||
optimistic: (update) => store.optimistic<T>(opts.key, update, persist),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
export function useCollapsingHeaderTabs(
|
||||
headerRef: Ref<HTMLElement | null>,
|
||||
primaryRef: Ref<HTMLElement | null>,
|
||||
tabsProbeRef: Ref<HTMLElement | null>,
|
||||
minSearchWidth = 176,
|
||||
minTabsWidth = 260
|
||||
) {
|
||||
const collapsed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function measure() {
|
||||
const header = headerRef.value
|
||||
const probe = tabsProbeRef.value
|
||||
if (!header || !probe) return
|
||||
|
||||
const primaryWidth = primaryRef.value?.getBoundingClientRect().width ?? 0
|
||||
const tabsWidth = probe.getBoundingClientRect().width
|
||||
const gapWidth = 48
|
||||
const fullTabsFit = primaryWidth + tabsWidth + minSearchWidth + gapWidth <= header.clientWidth
|
||||
const usableTabsFit = primaryWidth + minTabsWidth + minSearchWidth + gapWidth <= header.clientWidth
|
||||
collapsed.value = !fullTabsFit && !usableTabsFit
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
nextTick(() => requestAnimationFrame(measure))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scheduleMeasure()
|
||||
resizeObserver = new ResizeObserver(scheduleMeasure)
|
||||
if (headerRef.value) resizeObserver.observe(headerRef.value)
|
||||
if (primaryRef.value) resizeObserver.observe(primaryRef.value)
|
||||
if (tabsProbeRef.value) resizeObserver.observe(tabsProbeRef.value)
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
})
|
||||
|
||||
return { collapsed, measure: scheduleMeasure }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||
* bypasses the once-per-browser auto-show gate.
|
||||
*/
|
||||
export const companionIntroRequested = ref(false)
|
||||
|
||||
export function openCompanionIntro(): void {
|
||||
companionIntroRequested.value = true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Escape hatch for the "Checking containers…" / "Checking..." states.
|
||||
//
|
||||
// If the server never flips `containers-scanned` to true (e.g. the UI missed
|
||||
// a websocket broadcast), views gating on it would spin forever. This starts
|
||||
// a timeout once initial data has loaded and, if the scan flag is still false
|
||||
// when it fires, treats the scan as complete so the UI falls through to its
|
||||
// real empty/install states. A periodic store-level resync exists too — this
|
||||
// is the belt-and-suspenders guarantee that the spinner is always bounded.
|
||||
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
|
||||
const DEFAULT_SCAN_TIMEOUT_MS = 20_000
|
||||
|
||||
export function useContainersScanTimeout(
|
||||
containersScanned: Ref<boolean>,
|
||||
hasLoadedInitialData: Ref<boolean>,
|
||||
timeoutMs: number = DEFAULT_SCAN_TIMEOUT_MS,
|
||||
) {
|
||||
const scanTimedOut = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[containersScanned, hasLoadedInitialData],
|
||||
([scanned, loaded]) => {
|
||||
if (scanned) {
|
||||
// Real signal arrived — cancel the escape hatch.
|
||||
clearTimer()
|
||||
scanTimedOut.value = false
|
||||
return
|
||||
}
|
||||
if (loaded && timer === undefined && !scanTimedOut.value) {
|
||||
timer = setTimeout(() => {
|
||||
scanTimedOut.value = true
|
||||
timer = undefined
|
||||
}, timeoutMs)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
if (getCurrentInstance()) onBeforeUnmount(clearTimer)
|
||||
|
||||
/** True once the server reports the scan done OR the timeout has elapsed. */
|
||||
const effectiveContainersScanned = computed(
|
||||
() => containersScanned.value || scanTimedOut.value,
|
||||
)
|
||||
|
||||
return { effectiveContainersScanned, scanTimedOut }
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
/**
|
||||
* Controller / gamepad navigation for Archipelago.
|
||||
*
|
||||
* Navigation model (from the design spec):
|
||||
*
|
||||
* SIDEBAR (vertical list):
|
||||
* Up/Down = move between items, wraps top↔bottom, auto-navigates
|
||||
* Right = jump to first container in main content
|
||||
* Left = does nothing
|
||||
*
|
||||
* MAIN CONTENT (container tile grid):
|
||||
* Arrows = move between containers spatially (the red tile grid)
|
||||
* Enter = trigger container's primary action (navigate link / launch)
|
||||
* Escape = back to sidebar
|
||||
* Left from leftmost container = back to sidebar
|
||||
*
|
||||
* INSIDE CONTAINER (yellow inner controls — entered via second Enter):
|
||||
* Arrows = move between inner controls spatially
|
||||
* Escape = exit back to the container tile
|
||||
* Cannot move to other containers without exiting first
|
||||
*
|
||||
* TEXT INPUTS:
|
||||
* Up/Down = exit field, navigate to nearest element
|
||||
* Enter = submit (click next button)
|
||||
* Left/Right = cursor movement (stay in field)
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
// ─── Element Queries ────────────────────────────────────────────
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(root: Document | HTMLElement = document): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
el =>
|
||||
!el.hasAttribute('disabled') &&
|
||||
el.offsetParent !== null &&
|
||||
!el.hasAttribute('data-controller-ignore') &&
|
||||
!el.closest('[data-controller-ignore]')
|
||||
)
|
||||
}
|
||||
|
||||
/** Sidebar items */
|
||||
function getSidebarElements(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="sidebar"]') as HTMLElement | null
|
||||
return zone ? getFocusableElements(zone) : []
|
||||
}
|
||||
|
||||
/** Main zone containers only — the [C] tile grid */
|
||||
function getContainers(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (!zone) return []
|
||||
return Array.from(zone.querySelectorAll<HTMLElement>('[data-controller-container]')).filter(
|
||||
el => el.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
/** Nav bar items [N] — focusable elements in main zone that are NOT inside any container
|
||||
* (mode-switcher buttons, tab buttons, search inputs above the grid) */
|
||||
function getNavBarItems(): HTMLElement[] {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (!zone) return []
|
||||
return getFocusableElements(zone).filter(el =>
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
}
|
||||
|
||||
function isNavBarItem(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
if (!isInZone(el, 'main')) return false
|
||||
if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
|
||||
// On container-free pages (e.g. Settings), don't classify elements as nav bar items —
|
||||
// let them fall through to the main zone handler which supports linear up/down/right nav.
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (zone && !zone.querySelector('[data-controller-container]')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Inner focusables within a container (buttons, links — not the container itself) */
|
||||
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return getFocusableElements(container).filter(
|
||||
el => el !== container && !el.hasAttribute('data-controller-container')
|
||||
)
|
||||
}
|
||||
|
||||
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
if (!el) return false
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
/** Topmost open modal dialog, if any — it owns navigation while visible. */
|
||||
function getOpenModal(): HTMLElement | null {
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
|
||||
).filter(el => el.offsetParent !== null)
|
||||
return dialogs[dialogs.length - 1] ?? null
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
return !!container && container !== el
|
||||
}
|
||||
|
||||
function isContainer(el: HTMLElement | null): boolean {
|
||||
return !!el?.hasAttribute('data-controller-container')
|
||||
}
|
||||
|
||||
// ─── Spatial Navigation ─────────────────────────────────────────
|
||||
|
||||
function findNearestInDirection(
|
||||
from: HTMLElement,
|
||||
candidates: HTMLElement[],
|
||||
direction: 'up' | 'down' | 'left' | 'right'
|
||||
): HTMLElement | null {
|
||||
const fromRect = from.getBoundingClientRect()
|
||||
const fromCX = fromRect.left + fromRect.width / 2
|
||||
const fromCY = fromRect.top + fromRect.height / 2
|
||||
const threshold = 50
|
||||
|
||||
const filtered = candidates.filter(el => {
|
||||
if (el === from) return false
|
||||
const r = el.getBoundingClientRect()
|
||||
switch (direction) {
|
||||
case 'left': return r.right <= fromRect.left + threshold
|
||||
case 'right': return r.left >= fromRect.right - threshold
|
||||
case 'up': return r.bottom <= fromRect.top + threshold
|
||||
case 'down': return r.top >= fromRect.bottom - threshold
|
||||
}
|
||||
})
|
||||
|
||||
if (!filtered.length) return null
|
||||
|
||||
const scored = filtered.map(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
const isVertical = direction === 'up' || direction === 'down'
|
||||
const overlap = isVertical
|
||||
? Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||
: Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||
const dist = isVertical ? Math.abs(cy - fromCY) : Math.abs(cx - fromCX)
|
||||
return { el, overlap, dist }
|
||||
})
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const isVertical = direction === 'up' || direction === 'down'
|
||||
// For vertical nav: prefer closest element first, use overlap as tiebreaker.
|
||||
// This prevents a distant full-width element from winning over a closer narrow one.
|
||||
if (isVertical) {
|
||||
// Both have overlap — prefer closer distance
|
||||
if (a.overlap > 0 && b.overlap > 0) {
|
||||
if (a.dist !== b.dist) return a.dist - b.dist
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
return a.el.getBoundingClientRect().left - b.el.getBoundingClientRect().left
|
||||
}
|
||||
// One has overlap, the other doesn't — prefer the one with overlap
|
||||
if (a.overlap !== b.overlap) return b.overlap - a.overlap
|
||||
return a.dist - b.dist
|
||||
}
|
||||
// Horizontal: overlap first (same row), then distance
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
return a.dist - b.dist
|
||||
})
|
||||
|
||||
return scored[0]?.el ?? null
|
||||
}
|
||||
|
||||
// ─── Focus Memory ───────────────────────────────────────────────
|
||||
|
||||
const zoneFocusMemory = new Map<string, HTMLElement>()
|
||||
|
||||
function rememberFocus(zone: string, el: HTMLElement) {
|
||||
zoneFocusMemory.set(zone, el)
|
||||
}
|
||||
|
||||
function recallFocus(zone: string): HTMLElement | null {
|
||||
const el = zoneFocusMemory.get(zone)
|
||||
if (!el) return null
|
||||
if (document.contains(el) && el.offsetParent !== null) return el
|
||||
zoneFocusMemory.delete(zone)
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Focus Helper ───────────────────────────────────────────────
|
||||
|
||||
function focusEl(el: HTMLElement, sound: 'move' | 'action' | 'back' = 'move') {
|
||||
playNavSound(sound)
|
||||
el.focus({ preventScroll: true })
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// ─── Nav-Ring Modality ──────────────────────────────────────────
|
||||
// The orange focus ring must only render while the user is actually
|
||||
// navigating directionally (arrows / gamepad). Ring visibility is gated in
|
||||
// CSS behind html.controller-nav; directional input turns it on, any
|
||||
// pointer input (tap, click) turns it off. Without this gate the ring
|
||||
// painted on plain :focus — every tap on a tabindex card, and every
|
||||
// programmatic route-change autofocus, lit it up with no controller in
|
||||
// sight (worst on mobile).
|
||||
function setNavRing(on: boolean) {
|
||||
document.documentElement.classList.toggle('controller-nav', on)
|
||||
}
|
||||
|
||||
function navRingActive(): boolean {
|
||||
return document.documentElement.classList.contains('controller-nav')
|
||||
}
|
||||
|
||||
// ─── Main Composable ────────────────────────────────────────────
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useControllerStore()
|
||||
const isControllerActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
|
||||
watch([isControllerActive, gamepadCount], () => {
|
||||
store.setActive(isControllerActive.value)
|
||||
store.setGamepadCount(gamepadCount.value)
|
||||
}, { immediate: true })
|
||||
|
||||
let keyNavTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function checkGamepads() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
const count = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
|
||||
if (count !== gamepadCount.value) {
|
||||
gamepadCount.value = count
|
||||
isControllerActive.value = count > 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keyboard Handler ───────────────────────────────────────
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
if (!navKeys.includes(e.key)) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// ── MODAL SCOPE ──────────────────────────────────────────
|
||||
// An open dialog owns navigation: focus is pulled inside, arrows move
|
||||
// spatially between its controls, Enter activates. Escape stays with the
|
||||
// modal's own close handling. Standard mapping, no per-modal code.
|
||||
const modal = getOpenModal()
|
||||
if (modal) {
|
||||
if (e.key === 'Escape') return
|
||||
const focusables = getFocusableElements(modal)
|
||||
if (!focusables.length) return
|
||||
if (!activeEl || !modal.contains(activeEl)) {
|
||||
e.preventDefault()
|
||||
const first = focusables[0]
|
||||
if (first) focusEl(first)
|
||||
return
|
||||
}
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
// Typing keys stay with the field; Enter keeps the form-submit
|
||||
// behavior below; only Up/Down leave the field spatially.
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const dir =
|
||||
e.key === 'ArrowDown' ? ('down' as const)
|
||||
: e.key === 'ArrowUp' ? ('up' as const)
|
||||
: e.key === 'ArrowLeft' ? ('left' as const)
|
||||
: ('right' as const)
|
||||
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
return
|
||||
}
|
||||
|
||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
target.tagName === 'INPUT' &&
|
||||
(target as HTMLInputElement).type !== 'submit' &&
|
||||
!target.hasAttribute('data-controller-no-submit')
|
||||
) {
|
||||
// Enter in input: click next button (submit pattern). Live-filter
|
||||
// search boxes opt out via data-controller-no-submit — the "next
|
||||
// focusable element" after a search field is often an unrelated
|
||||
// button (a clear icon, a sideload/upload action) that just happens
|
||||
// to sit next to it in the DOM, not a submit action for the query.
|
||||
e.preventDefault()
|
||||
const all = getFocusableElements(containerRef?.value ?? document)
|
||||
const idx = all.indexOf(target)
|
||||
const next = idx >= 0 ? all[idx + 1] : undefined
|
||||
if (next && (next.tagName === 'BUTTON' || next.getAttribute('role') === 'button')) {
|
||||
next.focus()
|
||||
next.click()
|
||||
} else if (next) {
|
||||
next.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
// Up/Down: exit field, navigate spatially
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowDown' ? 'down' as const : 'up' as const
|
||||
const all = getFocusableElements(containerRef?.value ?? document)
|
||||
const candidates = all.filter(el => el !== target)
|
||||
const nearest = findNearestInDirection(target, candidates, dir)
|
||||
if (nearest) {
|
||||
focusEl(nearest)
|
||||
} else {
|
||||
// Spatial nav failed — try containers directly (e.g. search bar → first container)
|
||||
const containers = getContainers()
|
||||
const containerNearest = containers.length
|
||||
? findNearestInDirection(target, containers, dir)
|
||||
: null
|
||||
if (containerNearest) {
|
||||
focusEl(containerNearest)
|
||||
} else {
|
||||
// Last fallback: tab order
|
||||
const idx = all.indexOf(target)
|
||||
const fallback = dir === 'down' ? all[idx + 1] : all[idx - 1]
|
||||
if (fallback) focusEl(fallback)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Left/Right: cursor movement in field, but exit at edges
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const input = target as HTMLInputElement
|
||||
const atStart = input.selectionStart === 0 && input.selectionEnd === 0
|
||||
const atEnd = input.selectionStart === (input.value?.length ?? 0)
|
||||
if ((e.key === 'ArrowLeft' && atStart) || (e.key === 'ArrowRight' && atEnd)) {
|
||||
e.preventDefault()
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' as const : 'right' as const
|
||||
const all = getFocusableElements(containerRef?.value ?? document)
|
||||
const candidates = all.filter(el => el !== target)
|
||||
const nearest = findNearestInDirection(target, candidates, dir)
|
||||
if (nearest) focusEl(nearest)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Other keys (Escape): handled below.
|
||||
if (e.key !== 'Escape') return
|
||||
}
|
||||
|
||||
// ── CLOSE OVERLAYS (Escape) ─────────────────────────────
|
||||
if (e.key === 'Escape') {
|
||||
if (useAppLauncherStore().isOpen) { useAppLauncherStore().close(); e.preventDefault(); return }
|
||||
if (useSpotlightStore().isOpen) { useSpotlightStore().close(); e.preventDefault(); return }
|
||||
if (useCLIStore().isOpen) { useCLIStore().close(); e.preventDefault(); return }
|
||||
|
||||
// Inside container inner controls → exit to container
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
||||
if (container && container.tabIndex >= 0) {
|
||||
focusEl(container, 'back')
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// On a container or anywhere in main → go to sidebar
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const sidebar = getSidebarElements()
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? sidebar[0]
|
||||
if (target) {
|
||||
rememberFocus('main', activeEl)
|
||||
focusEl(target, 'back')
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Detail pages: go back
|
||||
if (/\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── ENTER ───────────────────────────────────────────────
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
|
||||
if (isContainer(activeEl)) {
|
||||
// Container declares its own click as THE action (e.g. a media file
|
||||
// card whose click plays it) — without this the a[href] fallback
|
||||
// below hits the card's download link and gamepad-select downloads
|
||||
// a song instead of playing it.
|
||||
if (activeEl.hasAttribute('data-controller-primary')) {
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
// Prioritised action: install button
|
||||
if (activeEl.hasAttribute('data-controller-install')) {
|
||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||
if (btn) { playNavSound('action'); btn.click(); return }
|
||||
}
|
||||
// Prioritised action: launch button
|
||||
if (activeEl.hasAttribute('data-controller-launch')) {
|
||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-launch-btn]:not([disabled])')
|
||||
if (btn) { playNavSound('action'); btn.click(); return }
|
||||
}
|
||||
// Primary link (e.g. dashboard cards with a[href])
|
||||
const primaryLink = activeEl.querySelector<HTMLElement>('a[href]')
|
||||
if (primaryLink) {
|
||||
playNavSound('action')
|
||||
primaryLink.click()
|
||||
return
|
||||
}
|
||||
// Fallback: first non-disabled action button (skip uninstall/delete buttons)
|
||||
const inner = getInnerFocusables(activeEl)
|
||||
const actionBtn = inner.find(el =>
|
||||
(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') &&
|
||||
!el.getAttribute('aria-label')?.toLowerCase().includes('uninstall') &&
|
||||
!el.closest('[class*="absolute top"]')
|
||||
) ?? inner[0]
|
||||
if (actionBtn) {
|
||||
focusEl(actionBtn, 'action')
|
||||
return
|
||||
}
|
||||
// Last resort: click the container itself (triggers goToApp on AppCard)
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
return
|
||||
}
|
||||
|
||||
// Regular element: click it
|
||||
if (activeEl) {
|
||||
playNavSound('action')
|
||||
activeEl.click()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── ARROW KEYS ──────────────────────────────────────────
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
|
||||
e.preventDefault()
|
||||
|
||||
// Mark controller as active
|
||||
isControllerActive.value = true
|
||||
setNavRing(true)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
keyNavTimeout = setTimeout(() => { isControllerActive.value = gamepadCount.value > 0 }, 3000)
|
||||
|
||||
// Nothing focused yet (fresh page, or focus was lost) — enter nav mode
|
||||
// on the first arrow press by focusing the first container, instead of
|
||||
// spatially navigating from <body>.
|
||||
if (!activeEl || activeEl === document.body || activeEl === document.documentElement) {
|
||||
autoFocusMain()
|
||||
return
|
||||
}
|
||||
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' as const
|
||||
: e.key === 'ArrowRight' ? 'right' as const
|
||||
: e.key === 'ArrowUp' ? 'up' as const
|
||||
: 'down' as const
|
||||
|
||||
// ── SIDEBAR ─────────────────────────────────────────────
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const items = getSidebarElements()
|
||||
const idx = items.indexOf(activeEl)
|
||||
|
||||
if (dir === 'up' || dir === 'down') {
|
||||
// Linear wrap
|
||||
if (idx < 0) return
|
||||
const nextIdx = dir === 'down'
|
||||
? (idx >= items.length - 1 ? 0 : idx + 1)
|
||||
: (idx <= 0 ? items.length - 1 : idx - 1)
|
||||
const next = items[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
focusEl(next)
|
||||
// Auto-navigate sidebar links (not buttons — Logout etc. require Enter)
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute('href')
|
||||
if (href?.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === 'right') {
|
||||
// Jump to first container in main
|
||||
rememberFocus('sidebar', activeEl)
|
||||
const remembered = recallFocus('main')
|
||||
// Only use remembered if it's a container (not a nav bar button)
|
||||
const target = (remembered && isContainer(remembered)) ? remembered : null
|
||||
const containers = getContainers()
|
||||
const dest = target ?? containers[0]
|
||||
if (dest) {
|
||||
focusEl(dest)
|
||||
} else {
|
||||
// Check if this is a container-free page (e.g. Settings) — focus first button immediately
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
const hasAnyContainers = zone?.querySelector('[data-controller-container]')
|
||||
if (!hasAnyContainers && zone) {
|
||||
const focusable = getFocusableElements(zone)
|
||||
if (focusable[0]) { focusEl(focusable[0]); return }
|
||||
}
|
||||
// Containers not rendered yet (route transition / animation in progress)
|
||||
// Poll until they appear, up to 1s
|
||||
let attempts = 0
|
||||
const poll = setInterval(() => {
|
||||
attempts++
|
||||
const retryContainers = getContainers()
|
||||
if (retryContainers[0]) {
|
||||
clearInterval(poll)
|
||||
focusEl(retryContainers[0])
|
||||
} else if (attempts >= 10) {
|
||||
clearInterval(poll)
|
||||
// Last resort: focus first focusable element
|
||||
if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Left from sidebar: does nothing
|
||||
return
|
||||
}
|
||||
|
||||
// ── INSIDE CONTAINER (inner controls) ───────────────────
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
const inner = getInnerFocusables(container)
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) focusEl(next)
|
||||
// Can't leave container via arrows — must use Escape
|
||||
return
|
||||
}
|
||||
|
||||
// ── NAV BAR [N] — secondary controls above the grid ────
|
||||
if (isNavBarItem(activeEl)) {
|
||||
const navItems = getNavBarItems()
|
||||
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
// Spatial nav between nav bar items
|
||||
const next = findNearestInDirection(activeEl, navItems, dir)
|
||||
if (next) { focusEl(next); return }
|
||||
// Left from leftmost nav item → sidebar
|
||||
if (dir === 'left') {
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? getSidebarElements()[0]
|
||||
if (target) focusEl(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === 'down' || dir === 'up') {
|
||||
// Up/Down from standalone element → find nearest focusable (container or button) in direction.
|
||||
// Searches containers + standalone elements together so mixed pages (Settings) don't jump.
|
||||
if (dir === 'down') rememberFocus('navBar', activeEl)
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (zone) {
|
||||
const allFocusable = getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
const target = findNearestInDirection(activeEl, allFocusable, dir)
|
||||
if (target) { focusEl(target); return }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── MAIN ZONE: CONTAINER TILE GRID [C] ──────────────────
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const containers = getContainers()
|
||||
|
||||
// Try spatial nav to containers + standalone focusables (not inner buttons).
|
||||
// This handles mixed pages (e.g. Settings) where containers and buttons coexist.
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
const navTargets = zone ? getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
) : containers
|
||||
const next = findNearestInDirection(activeEl, navTargets, dir)
|
||||
if (next) {
|
||||
rememberFocus('main', next)
|
||||
focusEl(next)
|
||||
return
|
||||
}
|
||||
|
||||
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
|
||||
if (dir === 'up') {
|
||||
const remembered = recallFocus('navBar')
|
||||
if (remembered) { focusEl(remembered); return }
|
||||
const navItems = getNavBarItems()
|
||||
if (navItems.length) {
|
||||
const nearest = findNearestInDirection(activeEl, navItems, 'up')
|
||||
if (nearest) { focusEl(nearest); return }
|
||||
const first = navItems[0]
|
||||
if (first) { focusEl(first); return }
|
||||
}
|
||||
// No nav bar items — try any focusable element above (linear page nav)
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (zone) {
|
||||
const allFocusable = getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
const above = findNearestInDirection(activeEl, allFocusable, 'up')
|
||||
if (above) { rememberFocus('main', above); focusEl(above) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Left from leftmost container → sidebar
|
||||
if (dir === 'left') {
|
||||
rememberFocus('main', activeEl)
|
||||
const remembered = recallFocus('sidebar')
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = remembered ?? activeTab ?? getSidebarElements()[0]
|
||||
if (target) focusEl(target)
|
||||
return
|
||||
}
|
||||
|
||||
// At grid edges: try containers + nav bar items as fallback
|
||||
// (prevents dead ends, but never jumps into container inner controls)
|
||||
if (dir === 'down' || dir === 'right') {
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (zone) {
|
||||
const allFocusable = getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
|
||||
if (fallback) {
|
||||
rememberFocus('main', fallback)
|
||||
focusEl(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── FALLBACK: unhandled focusable element ───────────────
|
||||
// Covers standalone buttons/links in empty/error states, modals, etc.
|
||||
// that aren't inside a recognized zone or container.
|
||||
if (dir === 'left') {
|
||||
const sidebar = getSidebarElements()
|
||||
const sidebarZone = document.querySelector('[data-controller-zone="sidebar"]')
|
||||
const activeTab = sidebarZone?.querySelector<HTMLElement>('.nav-tab-active')
|
||||
const target = activeTab ?? sidebar[0]
|
||||
if (target) { rememberFocus('main', activeEl); focusEl(target) }
|
||||
} else {
|
||||
// Exclude container inner buttons to prevent focus getting lost
|
||||
const all = getFocusableElements().filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
const next = findNearestInDirection(activeEl, all, dir)
|
||||
if (next) focusEl(next)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Gamepad Detection ──────────────────────────────────────
|
||||
|
||||
function handleGamepadConnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 1
|
||||
isControllerActive.value = true
|
||||
setNavRing(true)
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected() {
|
||||
const gamepads = navigator.getGamepads?.()
|
||||
gamepadCount.value = gamepads ? Array.from(gamepads).filter(g => g?.connected).length : 0
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}
|
||||
|
||||
// ─── Scroll Support ────────────────────────────────────────
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
// Scroll the container UNDER THE POINTER, not the focused element. Real
|
||||
// wheel events always target the element beneath the cursor, so walking up
|
||||
// from e.target matches native behaviour. Using document.activeElement here
|
||||
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
|
||||
// peer list, still focused after a click) instead of the panel actually
|
||||
// being hovered — producing a double-scroll where both moved at once.
|
||||
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
|
||||
if (!start) return
|
||||
let p: HTMLElement | null = start
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||
if (e.deltaY !== 0) { p.scrollTop += e.deltaY; e.preventDefault() }
|
||||
return
|
||||
}
|
||||
p = p.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-Focus on Route Change ────────────────────────────
|
||||
|
||||
function autoFocusMain() {
|
||||
// Only when the user is actually in directional-nav mode. For pointer
|
||||
// and touch users this programmatic focus painted the first card's
|
||||
// focus ring on every route change with zero controller input.
|
||||
if (!navRingActive()) return
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
// Don't steal focus from inputs, modals, or sidebar
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
|
||||
if (document.querySelector('[role="dialog"]')) return
|
||||
if (isInZone(active, 'sidebar')) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Re-check sidebar after RAF — user may still be navigating
|
||||
if (isInZone(document.activeElement as HTMLElement, 'sidebar')) return
|
||||
const remembered = recallFocus('main')
|
||||
if (remembered) { remembered.focus({ preventScroll: true }); return }
|
||||
const containers = getContainers()
|
||||
if (containers[0]) containers[0].focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => route.path, () => {
|
||||
zoneFocusMemory.delete('main')
|
||||
zoneFocusMemory.delete('navBar')
|
||||
setTimeout(autoFocusMain, 150)
|
||||
})
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
// Any pointer interaction ends directional-nav mode — the ring should
|
||||
// never linger once the user reaches for mouse or touch.
|
||||
function handlePointerDown() {
|
||||
setNavRing(false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkGamepads()
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
window.addEventListener('pointerdown', handlePointerDown, { capture: true, passive: true })
|
||||
window.addEventListener('wheel', handleWheel, { passive: false })
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
pollIntervalId = setInterval(() => checkGamepads(), 500)
|
||||
setTimeout(autoFocusMain, 300)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
window.removeEventListener('pointerdown', handlePointerDown, { capture: true } as EventListenerOptions)
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected)
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
|
||||
if (pollIntervalId) clearInterval(pollIntervalId)
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
setNavRing(false)
|
||||
})
|
||||
|
||||
return { isControllerActive, gamepadCount }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Public-demo helpers.
|
||||
*
|
||||
* The demo build (VITE_DEMO=1) replays the typing splash + intro/onboarding on
|
||||
* EVERY fresh boot of the app at '/' (first visit or browser refresh) — see
|
||||
* App.vue and RootRedirect.demoRoute. Also exposes the shared demo credentials
|
||||
* shown on the login screen.
|
||||
*/
|
||||
|
||||
export const IS_DEMO =
|
||||
import.meta.env.VITE_DEMO === '1' || import.meta.env.VITE_DEMO === 'true'
|
||||
|
||||
/** Memorable shared password for the public demo (must match the mock backend). */
|
||||
export const DEMO_PASSWORD = 'entertoexit'
|
||||
|
||||
/** Forget any legacy per-day gate marker (pre-2026-07 builds stored one). */
|
||||
export function clearDemoIntroSeen(): void {
|
||||
try {
|
||||
localStorage.removeItem('demo_intro_date')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Demoable apps ───────────────────────────────────────────────────────────
|
||||
// Only these apps actually do something in the demo (a mock UI or a real
|
||||
// proxied site). Everything else shows "No demo" on a disabled install button
|
||||
// and is not launchable.
|
||||
// IndeeHub's real site sends X-Frame-Options: SAMEORIGIN and its SPA uses
|
||||
// absolute-root asset paths, so a /app/indeedhub/ path-prefix proxy broke it.
|
||||
// Instead nginx-demo.conf runs a WHOLE-ORIGIN reverse proxy of
|
||||
// https://indee.tx1138.com on a dedicated port (framing headers stripped,
|
||||
// demo sign-in seeded) and the iframe loads http://<demo-host>:<port>/.
|
||||
const DEMO_PROXY_PORTS: Record<string, number> = {
|
||||
indeedhub: 2101,
|
||||
}
|
||||
|
||||
// Apps loaded in the in-app iframe via a same-origin path served by the mock
|
||||
// backend (per-app mock UIs and placeholder dashboards).
|
||||
const DEMO_MOCK_UI: Record<string, string> = {
|
||||
mempool: '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'bitcoin-knots': '/app/bitcoin-knots/',
|
||||
'bitcoin-core': '/app/bitcoin-core/',
|
||||
bitcoin: '/app/bitcoin-core/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
electrs: '/app/electrumx/',
|
||||
electrumx: '/app/electrumx/',
|
||||
'archy-electrs-ui': '/app/electrumx/',
|
||||
lnd: '/app/lnd/',
|
||||
'lnd-ui': '/app/lnd/',
|
||||
'archy-lnd-ui': '/app/lnd/',
|
||||
thunderhub: '/app/lnd/',
|
||||
fedimint: '/app/fedimint/',
|
||||
fedimintd: '/app/fedimint/',
|
||||
filebrowser: '/app/filebrowser/',
|
||||
// Static placeholder dashboards served by the mock backend (DEMO_APP_PAGES).
|
||||
'btcpay-server': '/app/btcpay-server/',
|
||||
grafana: '/app/grafana/',
|
||||
nextcloud: '/app/nextcloud/',
|
||||
jellyfin: '/app/jellyfin/',
|
||||
vaultwarden: '/app/vaultwarden/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
searxng: '/app/searxng/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a demo app opens externally (new tab / in-app browser) because its
|
||||
* real site blocks iframing. Nothing does anymore — frame-busting sites are
|
||||
* whole-origin proxied (DEMO_PROXY_PORTS) instead. Kept exported so call
|
||||
* sites in appLauncher.ts / AppSession.vue compile unchanged.
|
||||
*/
|
||||
export function isDemoExternal(_appId: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Can this app be launched/installed in the demo? */
|
||||
export function isDemoApp(appId: string): boolean {
|
||||
return appId in DEMO_PROXY_PORTS || appId in DEMO_MOCK_UI
|
||||
}
|
||||
|
||||
/** Resolve the demo launch URL for an app, or null if it isn't demoable. */
|
||||
export function demoAppUrl(appId: string): string | null {
|
||||
const proxyPort = DEMO_PROXY_PORTS[appId]
|
||||
if (proxyPort !== undefined && typeof window !== 'undefined') {
|
||||
// Same host the demo itself is served from — never a hardcoded host/IP.
|
||||
return `${window.location.protocol}//${window.location.hostname}:${proxyPort}/`
|
||||
}
|
||||
return DEMO_MOCK_UI[appId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
/** Shape of GET /electrs-status (see core electrs_status.rs ElectrsSyncStatus). */
|
||||
export interface ElectrsSyncStatus {
|
||||
indexed_height: number
|
||||
bitcoin_height: number
|
||||
network_height: number
|
||||
progress_pct: number
|
||||
status: string // "starting" | "waiting" | "syncing" | "indexing" | "synced" | "error"
|
||||
stale: boolean
|
||||
error: string | null
|
||||
index_size: string | null
|
||||
tor_onion: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls GET /electrs-status while active. Used to show an ElectrumX sync screen
|
||||
* *before* the real Electrum UI — the Electrum server only accepts client
|
||||
* connections (and the UI only works) once the on-chain index is built, which
|
||||
* is a long initial process.
|
||||
*
|
||||
* Fails OPEN: if the status can't be fetched we report not-syncing, so a status
|
||||
* outage never blocks the normal iframe path. We only gate the UI when we
|
||||
* positively know the index is still being built (status !== "synced").
|
||||
*/
|
||||
export function useElectrsSync() {
|
||||
const status = ref<ElectrsSyncStatus | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/electrs-status', { cache: 'no-store' })
|
||||
if (res.ok) status.value = (await res.json()) as ElectrsSyncStatus
|
||||
} catch {
|
||||
/* keep last known value; fail open */
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer) return
|
||||
void poll()
|
||||
timer = setInterval(() => void poll(), 8000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True only when we know ElectrumX is still building its index. */
|
||||
const syncing = computed(() => !!status.value && status.value.status !== 'synced')
|
||||
|
||||
onUnmounted(stop)
|
||||
return { status, syncing, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'])
|
||||
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'wma'])
|
||||
const VIDEO_EXTS = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv'])
|
||||
const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'txt', 'rtf', 'odt', 'md'])
|
||||
const SHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods'])
|
||||
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z', 'bz2'])
|
||||
|
||||
export type FileCategory = 'folder' | 'image' | 'audio' | 'video' | 'document' | 'spreadsheet' | 'archive' | 'file'
|
||||
|
||||
export function getFileCategory(ext: string, isDir: boolean): FileCategory {
|
||||
if (isDir) return 'folder'
|
||||
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio'
|
||||
if (VIDEO_EXTS.has(ext)) return 'video'
|
||||
if (DOC_EXTS.has(ext)) return 'document'
|
||||
if (SHEET_EXTS.has(ext)) return 'spreadsheet'
|
||||
if (ARCHIVE_EXTS.has(ext)) return 'archive'
|
||||
return 'file'
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<FileCategory, string[]> = {
|
||||
folder: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
||||
audio: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
video: ['M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z', 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
|
||||
image: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
document: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
spreadsheet: ['M3 10h18M3 14h18M3 6h18M3 18h18M8 6v12M16 6v12'],
|
||||
archive: ['M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'],
|
||||
file: ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'],
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<FileCategory, string> = {
|
||||
folder: 'text-amber-400',
|
||||
audio: 'text-orange-400',
|
||||
video: 'text-purple-400',
|
||||
image: 'text-blue-400',
|
||||
document: 'text-green-400',
|
||||
spreadsheet: 'text-emerald-400',
|
||||
archive: 'text-yellow-400',
|
||||
file: 'text-white/50',
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<FileCategory, string> = {
|
||||
folder: 'Folder',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
image: 'Image',
|
||||
document: 'Document',
|
||||
spreadsheet: 'Spreadsheet',
|
||||
archive: 'Archive',
|
||||
file: 'File',
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE_CLASSES: Record<FileCategory, string> = {
|
||||
folder: 'bg-amber-500/15 text-amber-400/70',
|
||||
audio: 'bg-orange-500/15 text-orange-400/70',
|
||||
video: 'bg-purple-500/15 text-purple-400/70',
|
||||
image: 'bg-blue-500/15 text-blue-400/70',
|
||||
document: 'bg-green-500/15 text-green-400/70',
|
||||
spreadsheet: 'bg-emerald-500/15 text-emerald-400/70',
|
||||
archive: 'bg-yellow-500/15 text-yellow-400/70',
|
||||
file: 'bg-white/8 text-white/50',
|
||||
}
|
||||
|
||||
export function useFileType(ext: Ref<string>, isDir: Ref<boolean>) {
|
||||
const category = computed(() => getFileCategory(ext.value, isDir.value))
|
||||
const isImage = computed(() => category.value === 'image')
|
||||
const isAudio = computed(() => category.value === 'audio')
|
||||
const isVideo = computed(() => category.value === 'video')
|
||||
const iconPaths = computed(() => CATEGORY_ICONS[category.value])
|
||||
const iconColor = computed(() => CATEGORY_COLORS[category.value])
|
||||
const badgeLabel = computed(() => CATEGORY_LABELS[category.value])
|
||||
const badgeClass = computed(() => CATEGORY_BADGE_CLASSES[category.value])
|
||||
|
||||
return { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass }
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const now = Date.now()
|
||||
const diff = now - date.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
// Session-level guard: the "finish setup" toast fires at most once per page load.
|
||||
let firedThisSession = false
|
||||
|
||||
/**
|
||||
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
|
||||
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
|
||||
* sitting on the fund-wallet / open-channel steps). Mount once in the
|
||||
* dashboard layout.
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
// A goal qualifies while it's in progress and its manual fund/channel steps
|
||||
// aren't done yet. If several qualify, the first wins — finishing the shared
|
||||
// fund + channel steps completes the lightning part of any of them.
|
||||
const pendingLightningGoalId = computed<string | null>(() => {
|
||||
if (firedThisSession) return null
|
||||
for (const goal of GOALS) {
|
||||
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
|
||||
if (!hasFundStep) continue
|
||||
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
|
||||
const done = goalStore.progress[goal.id]?.completedSteps ?? []
|
||||
const manualPending = goal.steps.some(
|
||||
(s) => s.action !== 'install' && !done.includes(s.id),
|
||||
)
|
||||
if (manualPending) return goal.id
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Only poll the chain while there's actually a goal waiting on it.
|
||||
let release: (() => void) | null = null
|
||||
watchEffect(() => {
|
||||
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
|
||||
if (shouldWatch && !release) {
|
||||
release = acquireBitcoinSync()
|
||||
} else if (!shouldWatch && release) {
|
||||
// Goal finished/reset or the chain synced — stop polling.
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
// Fire only on a REAL transition: we must have observed the chain unsynced
|
||||
// at least once this session, so a node that's already synced at page load
|
||||
// doesn't toast.
|
||||
let sawUnsynced = false
|
||||
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
|
||||
if (!loaded) return
|
||||
if (!synced) {
|
||||
sawUnsynced = true
|
||||
return
|
||||
}
|
||||
if (!sawUnsynced || firedThisSession) return
|
||||
const goalId = pendingLightningGoalId.value
|
||||
if (!goalId) return
|
||||
firedThisSession = true
|
||||
toast.action(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
},
|
||||
)
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Shared "this action needs a working Lightning node" gate (2026-08-02).
|
||||
//
|
||||
// Creating a Lightning invoice used to fail at the RPC layer whenever this
|
||||
// node had no usable Lightning implementation — `lnd.createinvoice` returned
|
||||
// connection-refused and the Receive screen rendered "Operation failed. Check
|
||||
// server logs for details." That reads as the wallet being broken, when the
|
||||
// truth is a missing (or stopped) prerequisite the user can act on.
|
||||
//
|
||||
// Callers ask `requireLightningNode()` BEFORE attempting the call. When there
|
||||
// is no usable node it opens the global LightningRequiredModal and returns
|
||||
// false, so the caller bails without surfacing an error at all.
|
||||
//
|
||||
// Keyed on package STATE, not mere presence: `package-data` carries an entry
|
||||
// for a Lightning app that is known to this node but not actually running, so
|
||||
// `id in packages` is NOT "installed and usable" — that assumption was the
|
||||
// first version's bug, and it let the raw RPC error through on a node with no
|
||||
// lnd container at all.
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
/** Package ids that provide a Lightning node.
|
||||
*
|
||||
* `lnd` ships today. Core Lightning is the next implementation the modal
|
||||
* offers — when its app id lands in the catalog, add it here and flip its
|
||||
* `available` flag in LightningRequiredModal; nothing else changes. */
|
||||
export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
|
||||
/** `absent` — nothing installed, offer to install one.
|
||||
* `stopped` — installed but not running, point the user at My Apps.
|
||||
* `no-funds` — running, but there is nothing to pay with / no inbound
|
||||
* liquidity to be paid into; send the user to the Lightning setup goal.
|
||||
* `running` — good to go. */
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
const show = ref(false)
|
||||
const status = ref<LightningStatus>('absent')
|
||||
/** Which direction raised the funding modal, so the copy can be specific. */
|
||||
const fundingDirection = ref<'send' | 'receive'>('receive')
|
||||
|
||||
export function useLightningRequired() {
|
||||
// The store is resolved lazily, inside the functions that need it, rather
|
||||
// than at composable-call time: a component may legitimately be mounted in
|
||||
// a test (or any context) without an active Pinia, and merely *having* this
|
||||
// gate available must not be what breaks it.
|
||||
/** Best status across every known Lightning implementation. */
|
||||
function lightningStatus(): LightningStatus {
|
||||
const pkgs = (useAppStore().packages ?? {}) as Record<string, { state?: string } | undefined>
|
||||
let best: LightningStatus = 'absent'
|
||||
for (const id of LIGHTNING_NODE_APP_IDS) {
|
||||
const entry = pkgs[id]
|
||||
if (!entry) continue
|
||||
if (entry.state === PackageState.Running) return 'running'
|
||||
// Present but not running: installing, starting, stopped, exited…
|
||||
best = 'stopped'
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function hasLightningNode(): boolean {
|
||||
return lightningStatus() === 'running'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a Lightning-only action. Returns true to proceed; returns false and
|
||||
* opens the modal (in the mode matching why) when there is no usable node.
|
||||
*/
|
||||
function requireLightningNode(): boolean {
|
||||
const s = lightningStatus()
|
||||
if (s === 'running') return true
|
||||
status.value = s
|
||||
show.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
function close() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the same modal in its funding mode: the node is installed and
|
||||
* running, but has no usable balance/liquidity yet. Reuses this modal
|
||||
* rather than inventing a second one, and routes to the Lightning setup
|
||||
* goal where funding and channel-opening already live.
|
||||
*/
|
||||
function openLightningFunding() {
|
||||
status.value = 'no-funds'
|
||||
show.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a failed Lightning attempt onto the funding modal when the node is
|
||||
* running but has nothing to pay with / no inbound liquidity. Returns true
|
||||
* when it handled the error, so the caller can skip showing a raw string.
|
||||
*
|
||||
* Matched on the message because LND surfaces these as plain text: there is
|
||||
* no distinct error code for "no channels" vs "no route" vs "insufficient
|
||||
* balance", and all three mean the same thing to the user — fund me.
|
||||
*/
|
||||
function handleLightningFailure(err: unknown): boolean {
|
||||
if (lightningStatus() !== 'running') return false
|
||||
const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase()
|
||||
const fundingRelated = [
|
||||
'no route',
|
||||
'no routes',
|
||||
'insufficient',
|
||||
'no channel',
|
||||
'not enough',
|
||||
'balance',
|
||||
'unable to find a path',
|
||||
'no path',
|
||||
].some((needle) => msg.includes(needle))
|
||||
if (!fundingRelated) return false
|
||||
openLightningFunding()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The full readiness gate: node installed AND running AND with liquidity in
|
||||
* the direction being attempted.
|
||||
*
|
||||
* Node state alone is not enough — LND happily mints an invoice with zero
|
||||
* channels, so a state-only gate hands the user an invoice nobody can pay
|
||||
* (and a send that can only fail). `receive` needs inbound liquidity,
|
||||
* `send` needs outbound.
|
||||
*
|
||||
* Fails OPEN on an RPC error: if we cannot read the channel list we let the
|
||||
* attempt proceed rather than block a working wallet on a transient blip.
|
||||
*/
|
||||
async function requireLightningReady(direction: 'send' | 'receive'): Promise<boolean> {
|
||||
if (!requireLightningNode()) return false
|
||||
try {
|
||||
const res = await rpcClient.call<{ total_inbound?: number; total_outbound?: number }>({
|
||||
method: 'lnd.listchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
const liquidity = direction === 'receive' ? res?.total_inbound ?? 0 : res?.total_outbound ?? 0
|
||||
if (liquidity > 0) return true
|
||||
fundingDirection.value = direction
|
||||
openLightningFunding()
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
fundingDirection,
|
||||
status,
|
||||
lightningStatus,
|
||||
hasLightningNode,
|
||||
requireLightningNode,
|
||||
openLightningFunding,
|
||||
requireLightningReady,
|
||||
handleLightningFailure,
|
||||
close,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Login screen audio: intro loop (MP3) + transition sounds.
|
||||
*
|
||||
* First-install vs returning-user gate: the synthwave loop, welcome
|
||||
* voice, pop/whoosh/oomph transitions exist for the first-boot cinematic
|
||||
* moment. After the user has completed onboarding we silence all of
|
||||
* them — every subsequent login should be quiet. Typing sounds are
|
||||
* exempt and continue to play regardless.
|
||||
*/
|
||||
|
||||
/** One-way, per-page-load override: when App.vue decides to run the intro
|
||||
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
|
||||
* cinematic — speech, synthwave, pops — must be audible even though
|
||||
* localStorage already says onboarding is complete. Without this the
|
||||
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
|
||||
let cinematicMode = false
|
||||
export function enableCinematicSounds() {
|
||||
cinematicMode = true
|
||||
}
|
||||
|
||||
/** True when the node has not yet completed onboarding — i.e. we're
|
||||
* still in the first-install cinematic — or when the intro is being
|
||||
* deliberately replayed this page load (see enableCinematicSounds).
|
||||
* Reads the localStorage cache set by useOnboarding (which is
|
||||
* re-seeded from the backend on each successful check), so this stays
|
||||
* correct after a browser clear once the onboarding-complete probe
|
||||
* runs. Sound calls that fire before that probe completes will fall
|
||||
* through silent on an already-onboarded node — which is exactly what
|
||||
* we want: ordinary re-logins stay quiet. */
|
||||
function isFirstInstallPhase(): boolean {
|
||||
if (cinematicMode) return true
|
||||
try {
|
||||
return localStorage.getItem('neode_onboarding_complete') !== '1'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
|
||||
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
|
||||
function getContext(): AudioContext | null {
|
||||
return audioContext
|
||||
}
|
||||
|
||||
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
|
||||
function ensureContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
if (!Ctx) return null
|
||||
audioContext = new Ctx()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||||
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||
|
||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
|
||||
* Uses Web Audio API so it plays after context is resumed (user gesture). */
|
||||
export function playLoopStart() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
fetch(LOOP_START_URL)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buf) => ctx.decodeAudioData(buf))
|
||||
.then((decoded) => {
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = decoded
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.value = 0.5
|
||||
src.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
src.start(0)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
|
||||
export function resumeAudioContext() {
|
||||
const ctx = ensureContext()
|
||||
if (ctx?.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
|
||||
export function startSynthwave() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
try {
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
stopSynthwave()
|
||||
|
||||
const audio = new Audio(INTRO_AUDIO_URL)
|
||||
audio.loop = true
|
||||
|
||||
const ctx = ctxOrNull
|
||||
const source = ctx.createMediaElementSource(audio)
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = 0.25
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
introGain = gainNode
|
||||
introAudio = audio
|
||||
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Stop intro loop (call on login success) */
|
||||
export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(introGain.gain.value, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
introAudio?.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}, 220)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop ALL login audio and close AudioContext. Call on route change to dashboard. */
|
||||
export function stopAllAudio() {
|
||||
// Stop synthwave loop
|
||||
if (introAudio) {
|
||||
introAudio.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}
|
||||
// Stop intro typing
|
||||
stopIntroTyping()
|
||||
// Close AudioContext to kill any lingering BufferSource nodes (playLoopStart)
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {})
|
||||
audioContext = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Pop sound - plays when intro initiator (tap to start) is pressed */
|
||||
export function playPop() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const audio = new Audio('/assets/audio/pop.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Whoosh transition on successful login */
|
||||
export function playLoginSuccessWhoosh() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||||
woosh.volume = 0.5
|
||||
woosh.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
|
||||
export function playTypingSound() {
|
||||
const audio = new Audio('/assets/audio/typing.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
|
||||
let introTypingAudio: HTMLAudioElement | null = null
|
||||
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
|
||||
|
||||
export function playIntroTyping() {
|
||||
stopIntroTyping()
|
||||
introTypingAudio = new Audio(INTRO_TYPING_URL)
|
||||
introTypingAudio.volume = 0.5
|
||||
introTypingAudio.loop = true
|
||||
introTypingAudio.play().catch(() => {})
|
||||
}
|
||||
|
||||
export function stopIntroTyping() {
|
||||
if (introTypingAudio) {
|
||||
introTypingAudio.pause()
|
||||
introTypingAudio.currentTime = 0
|
||||
introTypingAudio = null
|
||||
}
|
||||
}
|
||||
|
||||
const WELCOME_SPEECH_URL = '/assets/audio/welcome-noderunner.mp3'
|
||||
|
||||
/** Sci-fi female voice: "Welcome Noderunner" - plays when welcome text types in.
|
||||
* Requires pre-recorded audio from ElevenLabs. Run:
|
||||
* ELEVENLABS_API_KEY=your_key node neode-ui/scripts/generate-welcome-speech.js
|
||||
* Browse sci-fi voices at elevenlabs.io/voice-library and set ELEVENLABS_VOICE_ID for custom voice. */
|
||||
export function playWelcomeNoderunnerSpeech() {
|
||||
if (!isFirstInstallPhase()) return
|
||||
const audio = new Audio(WELCOME_SPEECH_URL)
|
||||
audio.volume = 0.9
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing tick - for dashboard welcome typing (typing.mp3) */
|
||||
let typingTickPool: HTMLAudioElement[] = []
|
||||
const TYPING_TICK_POOL_SIZE = 5
|
||||
|
||||
function getTypingTick(): HTMLAudioElement {
|
||||
if (typingTickPool.length === 0) {
|
||||
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
|
||||
const a = new Audio('/assets/audio/typing.mp3')
|
||||
a.volume = 0.4
|
||||
typingTickPool.push(a)
|
||||
}
|
||||
}
|
||||
const a = typingTickPool.shift()!
|
||||
typingTickPool.push(a)
|
||||
return a
|
||||
}
|
||||
|
||||
export function playTypingTick() {
|
||||
const a = getTypingTick()
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Keyboard input sound - short synthesized click per key. Does NOT use typing.mp3 or intro-typing.mp3. */
|
||||
export function playKeyboardTypingSound() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(1200, t)
|
||||
gain.gain.setValueAtTime(0, t)
|
||||
gain.gain.linearRampToValueAtTime(0.06, t + 0.002)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + 0.04)
|
||||
}
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
/**
|
||||
* @param force Play regardless of install phase. The first dashboard entry
|
||||
* right after the onboarding wizard needs this: by then onboarding is already
|
||||
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
|
||||
* exists to keep ordinary re-logins quiet) would silence the one entrance
|
||||
* that SHOULD be loud.
|
||||
*/
|
||||
export function playDashboardLoadOomph(force = false) {
|
||||
if (!force && !isFirstInstallPhase()) return
|
||||
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
|
||||
// AudioContext — so the context must be recreated here, not just fetched.
|
||||
// The login click's sticky user activation lets the fresh context run.
|
||||
const ctx = ensureContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
|
||||
// Soft layered thud - sine only, smooth attack/decay
|
||||
const layers = [
|
||||
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
|
||||
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
|
||||
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
|
||||
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
|
||||
]
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const L = layers[i]!
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = L.freq
|
||||
g.gain.setValueAtTime(0, t)
|
||||
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
osc.start(t + i * 0.01)
|
||||
osc.stop(t + L.dur)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface MarketplaceAppInfo {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
icon: string
|
||||
category: string
|
||||
description: string | { short?: string; long?: string }
|
||||
author: string
|
||||
source: string
|
||||
manifestUrl: string
|
||||
url: string
|
||||
repoUrl: string
|
||||
s9pkUrl: string
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
screenshots?: AppScreenshot[]
|
||||
containerConfig?: {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type AppScreenshot = string | {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||
|
||||
export function useMarketplaceApp() {
|
||||
function setCurrentApp(app: Partial<MarketplaceAppInfo> & { id: string }) {
|
||||
// Create a clean, serializable copy
|
||||
currentMarketplaceApp.value = {
|
||||
id: app.id,
|
||||
title: app.title ?? '',
|
||||
version: app.version ?? '',
|
||||
icon: app.icon ?? '',
|
||||
category: app.category ?? '',
|
||||
description: app.description ?? '',
|
||||
author: app.author ?? '',
|
||||
source: app.source ?? '',
|
||||
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url || '',
|
||||
url: app.url || app.s9pkUrl || app.manifestUrl || '',
|
||||
repoUrl: app.repoUrl ?? '',
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
screenshots: Array.isArray(app.screenshots) ? app.screenshots : undefined,
|
||||
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentApp() {
|
||||
return currentMarketplaceApp.value
|
||||
}
|
||||
|
||||
function clearCurrentApp() {
|
||||
currentMarketplaceApp.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
setCurrentApp,
|
||||
getCurrentApp,
|
||||
clearCurrentApp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
export interface ReceivedMessage {
|
||||
from_pubkey: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const MESSAGE_POLL_INTERVAL = 30000 // 30s
|
||||
|
||||
// Shared state (singleton) so toast works across route changes
|
||||
const receivedMessages = ref<ReceivedMessage[]>([])
|
||||
const lastMessageCount = ref(0)
|
||||
const loadingMessages = ref(false)
|
||||
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function useMessageToast() {
|
||||
const router = useRouter()
|
||||
|
||||
const unreadCount = computed(() =>
|
||||
Math.max(0, receivedMessages.value.length - lastMessageCount.value)
|
||||
)
|
||||
|
||||
async function loadReceivedMessages() {
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await rpcClient.getReceivedMessages()
|
||||
const msgs = (res.messages || []) as ReceivedMessage[]
|
||||
receivedMessages.value = msgs
|
||||
// New messages since last check? (don't show toast on initial load)
|
||||
if (msgs.length > lastMessageCount.value && lastMessageCount.value > 0) {
|
||||
const newCount = msgs.length - lastMessageCount.value
|
||||
const latest = msgs[msgs.length - 1]
|
||||
toastMessage.value = {
|
||||
show: true,
|
||||
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
|
||||
// Only deep-link to a specific chat when it's a single new message
|
||||
// from one sender; otherwise open the mesh list.
|
||||
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
} catch (e) {
|
||||
// Stop polling on auth failure — session expired, no point retrying
|
||||
if (e instanceof Error && /401|Unauthorized/i.test(e.message)) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
if (import.meta.env.DEV) console.error('Failed to load messages:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthenticated(): boolean {
|
||||
return localStorage.getItem('neode-auth') === 'true'
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
if (!isAuthenticated()) return
|
||||
loadReceivedMessages()
|
||||
pollTimer = setInterval(() => {
|
||||
if (!isAuthenticated()) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
loadReceivedMessages()
|
||||
}, MESSAGE_POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function markAsRead() {
|
||||
lastMessageCount.value = receivedMessages.value.length
|
||||
}
|
||||
|
||||
function dismissToastAndOpenMessages() {
|
||||
const peer = toastMessage.value.fromPubkey
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
markAsRead()
|
||||
// Open the specific conversation when we know the sender; else the mesh list.
|
||||
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
|
||||
}
|
||||
|
||||
// Dismiss the toast without navigating (the close icon).
|
||||
function closeToast() {
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
receivedMessages,
|
||||
lastMessageCount,
|
||||
loadingMessages,
|
||||
toastMessage,
|
||||
unreadCount,
|
||||
loadReceivedMessages,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
markAsRead,
|
||||
dismissToastAndOpenMessages,
|
||||
closeToast,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for mobile back button positioning
|
||||
* Ensures back buttons are always 8px above the mobile tab bar
|
||||
* Uses ResizeObserver to reactively update when tab bar height changes
|
||||
*/
|
||||
export function useMobileBackButton() {
|
||||
const tabBarHeight = ref<number>(72) // Default fallback height
|
||||
|
||||
// Computed property for bottom position - always 16px above tab bar
|
||||
const bottomPosition = computed(() => {
|
||||
return `${tabBarHeight.value + 8}px`
|
||||
})
|
||||
|
||||
// Computed property for Tailwind class (for use in class bindings)
|
||||
const bottomClass = computed(() => {
|
||||
// Use Tailwind arbitrary value with the computed height
|
||||
return `bottom-[${tabBarHeight.value + 8}px]`
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateTabBarHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Try to find the mobile tab bar element
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar && tabBar.offsetHeight > 0) {
|
||||
tabBarHeight.value = tabBar.offsetHeight
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: read from CSS variable if available
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--mobile-tab-bar-height')
|
||||
.trim()
|
||||
|
||||
if (cssVar) {
|
||||
const height = parseFloat(cssVar)
|
||||
if (!isNaN(height) && height > 0) {
|
||||
tabBarHeight.value = height
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: keep current value (don't reset to 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initial update
|
||||
updateTabBarHeight()
|
||||
|
||||
// Watch for CSS variable changes
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
|
||||
// Watch for tab bar size changes
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
resizeObserver.observe(tabBar)
|
||||
}
|
||||
|
||||
// Also listen to window resize as fallback
|
||||
window.addEventListener('resize', updateTabBarHeight)
|
||||
|
||||
// Periodic check to ensure we're always in sync (safety net)
|
||||
intervalId = setInterval(() => {
|
||||
updateTabBarHeight()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mutationObserver) {
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
bottomPosition,
|
||||
bottomClass,
|
||||
tabBarHeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Back/forward integration for modals (kiosk, remote browsers, mobile).
|
||||
//
|
||||
// Without this, the browser's Back control (mouse side-button on kiosk,
|
||||
// gesture on mobile, toolbar button in a remote browser) navigates the
|
||||
// ROUTER while a modal is open — at best closing the whole screen under a
|
||||
// dialog, at worst leaving the app. The native-app expectation, and what
|
||||
// the companion webview already provides, is: Back closes the topmost
|
||||
// dialog first.
|
||||
//
|
||||
// Mechanics: opening a modal pushes one history entry (same URL, a depth
|
||||
// marker in state — router keys are preserved by spreading the existing
|
||||
// state). A popstate that lands BELOW our depth means the user pressed
|
||||
// Back over an open modal: close the topmost one. A UI-side close (X,
|
||||
// backdrop, Esc) consumes its own entry with history.back() so Back never
|
||||
// needs pressing twice — guarded by the depth marker so it can never eat
|
||||
// a router entry. One module-level stack serves every BaseModal instance,
|
||||
// so stacked modals close one per Back, top first.
|
||||
import { watch, type Ref } from 'vue'
|
||||
|
||||
type Entry = { close: () => void }
|
||||
|
||||
const stack: Entry[] = []
|
||||
// Set when a popstate initiated the close: the history entry is already
|
||||
// gone, so the close-side cleanup must not call history.back() again.
|
||||
let poppedClose = false
|
||||
let listening = false
|
||||
|
||||
function modalDepth(state: unknown): number {
|
||||
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
|
||||
}
|
||||
|
||||
function ensureListener() {
|
||||
if (listening || typeof window === 'undefined') return
|
||||
listening = true
|
||||
window.addEventListener('popstate', (e) => {
|
||||
// Landed at a depth below the open-modal count → this Back was aimed
|
||||
// at the topmost modal. One entry per Back press: close exactly one.
|
||||
// (A popstate at or above our depth is someone else's navigation —
|
||||
// e.g. our own cleanup back, or a forward — leave it alone.)
|
||||
if (modalDepth(e.state) < stack.length) {
|
||||
const top = stack[stack.length - 1]
|
||||
if (top) {
|
||||
poppedClose = true
|
||||
top.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Call from a modal component with its visibility and close trigger. */
|
||||
export function useModalHistory(show: Ref<boolean>, close: () => void) {
|
||||
ensureListener()
|
||||
const entry: Entry = { close }
|
||||
watch(show, (open, was) => {
|
||||
if (open === was) return
|
||||
if (open) {
|
||||
stack.push(entry)
|
||||
try {
|
||||
// Preserve vue-router's own keys in state — clobbering them breaks
|
||||
// its scroll restoration and position tracking.
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state ?? {}), __archyModal: stack.length },
|
||||
'',
|
||||
)
|
||||
} catch { /* history can throw in exotic embeds — modal still works */ }
|
||||
} else {
|
||||
const wasTop = stack[stack.length - 1] === entry
|
||||
const i = stack.indexOf(entry)
|
||||
if (i >= 0) stack.splice(i, 1)
|
||||
if (poppedClose) {
|
||||
poppedClose = false
|
||||
return
|
||||
}
|
||||
// UI-side close of the top modal: consume the entry we pushed, but
|
||||
// only if it is still the current one (a route change after opening
|
||||
// moves history past it — backing out then would eat a real entry).
|
||||
if (wasTop && modalDepth(window.history.state) > stack.length) {
|
||||
try { window.history.back() } catch { /* same guard as above */ }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Modal keyboard navigation: Escape to close, Arrow keys to move between buttons.
|
||||
* Restores focus to the previously active element when closing via Escape.
|
||||
*/
|
||||
|
||||
import { onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
|
||||
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export interface UseModalKeyboardOptions {
|
||||
restoreFocusRef?: Ref<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function useModalKeyboard(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
isOpen: Ref<boolean>,
|
||||
onClose: () => void,
|
||||
options?: UseModalKeyboardOptions
|
||||
) {
|
||||
const restoreFocusRef = options?.restoreFocusRef
|
||||
|
||||
// Save the element that had focus when modal opens (before focus moves to modal)
|
||||
watch(isOpen, (open) => {
|
||||
if (open && restoreFocusRef) {
|
||||
restoreFocusRef.value = document.activeElement as HTMLElement | null
|
||||
}
|
||||
})
|
||||
function getFocusables(): HTMLElement[] {
|
||||
const el = containerRef.value
|
||||
if (!el) return []
|
||||
return Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(e) => e.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen.value) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
restoreFocusRef?.value?.focus?.()
|
||||
onClose()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
const focusables = getFocusables()
|
||||
if (focusables.length === 0) return
|
||||
|
||||
const current = document.activeElement as HTMLElement | null
|
||||
const idx = current ? focusables.indexOf(current) : -1
|
||||
|
||||
let nextIdx: number
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
|
||||
nextIdx = idx < focusables.length - 1 ? idx + 1 : 0
|
||||
} else {
|
||||
nextIdx = idx > 0 ? idx - 1 : focusables.length - 1
|
||||
}
|
||||
|
||||
focusables[nextIdx]?.focus()
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Epic interface sounds for controller/keyboard navigation.
|
||||
* Layered synthesis - cool, impactful, celebratory for actions.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') {
|
||||
if (type === 'move') {
|
||||
const audio = new Audio('/assets/audio/arrows.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
if (type === 'select' || type === 'action') {
|
||||
const audio = new Audio('/assets/audio/enter.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'back': {
|
||||
playTone(ctx, 440, 0.06, 0.08, 'sine')
|
||||
playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03)
|
||||
playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Onboarding state - backend is authoritative.
|
||||
* "Unknown" (backend unreachable) must NEVER default to false —
|
||||
* that would falsely send an already-onboarded user back through
|
||||
* the intro after a browser clear / update / reboot.
|
||||
*/
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T | null> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
const isRetryable = /502|503|504|timeout|fetch|network|abort/i.test(msg)
|
||||
if (!isRetryable || i === maxRetries - 1) return null
|
||||
// Exponential-ish backoff: 500, 1000, 2000, 4000, 8000 (capped)
|
||||
const delay = Math.min(500 * Math.pow(2, i), 8000)
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true/false if the backend gave a definitive answer, null if
|
||||
* the backend is unreachable. Callers MUST handle null explicitly —
|
||||
* do not coerce to boolean without thinking about the consequences.
|
||||
*/
|
||||
export async function checkOnboardingStatus(): Promise<boolean | null> {
|
||||
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 5)
|
||||
if (result !== null) {
|
||||
if (result) {
|
||||
try { localStorage.setItem('neode_onboarding_complete', '1') } catch {}
|
||||
} else {
|
||||
try { localStorage.removeItem('neode_onboarding_complete') } catch {}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean-only variant for places that genuinely cannot wait.
|
||||
* Backend answer wins; on backend-unreachable, trusts a prior
|
||||
* localStorage cache (set by a past successful check on THIS node).
|
||||
* Returns false only when both the backend and the cache agree —
|
||||
* or when the cache is empty on a genuinely fresh install.
|
||||
*
|
||||
* Prefer checkOnboardingStatus() where possible so the caller can
|
||||
* distinguish "confirmed fresh install" from "can't reach backend".
|
||||
*/
|
||||
export async function isOnboardingComplete(): Promise<boolean> {
|
||||
const result = await checkOnboardingStatus()
|
||||
if (result !== null) return result
|
||||
// Backend unreachable — trust the local cache. If the cache says
|
||||
// we're onboarded, we almost certainly are (this browser saw a
|
||||
// prior backend 'true' and re-seeded the flag). If the cache is
|
||||
// empty, we genuinely don't know; returning false here is the
|
||||
// last-resort fallback, and the calling views should additionally
|
||||
// keep polling the backend instead of treating this as gospel.
|
||||
return localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
}
|
||||
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
localStorage.removeItem('neode_onboarding_step')
|
||||
}
|
||||
|
||||
/** Save current onboarding step so refresh resumes where user left off */
|
||||
export function saveOnboardingStep(step: string): void {
|
||||
localStorage.setItem('neode_onboarding_step', step)
|
||||
}
|
||||
|
||||
/** Get the last saved onboarding step, or 'intro' if none */
|
||||
export function getSavedOnboardingStep(): string {
|
||||
return localStorage.getItem('neode_onboarding_step') || 'intro'
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useAudioPlayer } from './useAudioPlayer'
|
||||
|
||||
/**
|
||||
* Shape of a purchased item as listed by `content.owned-list` — the minimum
|
||||
* fields this composable needs to fetch and route it. Cloud.vue's `PaidItem`
|
||||
* interface satisfies this structurally.
|
||||
*/
|
||||
export interface OwnedItemLike {
|
||||
onion: string
|
||||
content_id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
/** Same key formula the Paid Files row uses for `:key` — one key, one row. */
|
||||
export function paidItemKey(it: OwnedItemLike): string {
|
||||
return it.onion + it.content_id
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, decode, route-to-viewer and loading/error state for a purchased
|
||||
* item (UIFIX-04 / UIFIX-06).
|
||||
*
|
||||
* - image/video → routed into the caller's MediaLightbox via `lightboxItems`
|
||||
* / `lightboxIndex`, fed a synthetic FileBrowserItem whose blob URL is
|
||||
* served back through `resolveBlobUrl`. The lightbox owns revoking that
|
||||
* URL on unmount — this composable must never schedule a competing revoke
|
||||
* for a URL it has handed to the lightbox.
|
||||
* - audio → today's global bottom-bar player, unchanged.
|
||||
* - anything else (no in-app viewer) → today's browser-tab fallback,
|
||||
* unchanged, including its own revoke timer.
|
||||
*/
|
||||
export function usePaidItemViewer() {
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
const opening = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const lightboxItems = ref<FileBrowserItem[]>([])
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
// Synthetic-path -> already-fetched blob URL. Populated only for items
|
||||
// routed to the lightbox; resolveBlobUrl reads from here and never fetches.
|
||||
const urlByPath = new Map<string, string>()
|
||||
// One in-flight fetch per item key — a second open() for the same key
|
||||
// while the first is pending returns the same promise instead of issuing
|
||||
// a second RPC (UIFIX-04 concurrency edge / T-01-65).
|
||||
const inFlight = new Map<string, Promise<void>>()
|
||||
|
||||
async function resolveBlobUrl(path: string): Promise<string> {
|
||||
const url = urlByPath.get(path)
|
||||
if (!url) throw new Error('Not resolved')
|
||||
return url
|
||||
}
|
||||
|
||||
function runOpen(it: OwnedItemLike, key: string): Promise<void> {
|
||||
return (async () => {
|
||||
opening.value = key
|
||||
error.value = null
|
||||
try {
|
||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||
method: 'content.owned-get',
|
||||
params: { onion: it.onion, content_id: it.content_id },
|
||||
timeout: 60000,
|
||||
})
|
||||
const b64 = res.data_base64 || res.data
|
||||
if (!b64) {
|
||||
error.value = "Couldn't open this file — the peer returned no data."
|
||||
return
|
||||
}
|
||||
const bin = atob(b64)
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
const mime = res.mime_type || it.mime_type
|
||||
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||
|
||||
// Music ALWAYS plays in the global bottom-bar player — never a
|
||||
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||
if (mime.startsWith('audio/')) {
|
||||
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
|
||||
return
|
||||
}
|
||||
|
||||
if (mime.startsWith('image/') || mime.startsWith('video/')) {
|
||||
const basename = it.filename.split('/').pop() || it.filename
|
||||
const path = `paid://${key}`
|
||||
urlByPath.set(path, url)
|
||||
const synthetic: FileBrowserItem = {
|
||||
name: basename,
|
||||
path,
|
||||
size: it.size_bytes,
|
||||
modified: '',
|
||||
isDir: false,
|
||||
type: mime,
|
||||
extension: basename.includes('.') ? basename.split('.').pop()!.toLowerCase() : '',
|
||||
}
|
||||
lightboxItems.value = [synthetic]
|
||||
lightboxIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// No in-app viewer (documents, etc.) — keep today's behaviour.
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch {
|
||||
error.value = "Couldn't open this file — it may be unavailable right now."
|
||||
} finally {
|
||||
opening.value = null
|
||||
inFlight.delete(key)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function open(it: OwnedItemLike): Promise<void> {
|
||||
const key = paidItemKey(it)
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) return existing
|
||||
const task = runOpen(it, key)
|
||||
inFlight.set(key, task)
|
||||
return task
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxIndex.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
opening,
|
||||
error,
|
||||
lightboxItems,
|
||||
lightboxIndex,
|
||||
resolveBlobUrl,
|
||||
open,
|
||||
closeLightbox,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
/** Singleton picture-in-picture session.
|
||||
*
|
||||
* A video handed to `adopt()` is moved into a body-level custodial host so
|
||||
* that it survives the unmount of whatever view rendered it — a Teleport
|
||||
* and a KeepAlive'd view both move their subtree on deactivation, and a
|
||||
* moved element is a removed element as far as the picture-in-picture spec
|
||||
* is concerned. Owning the element at the document level sidesteps that
|
||||
* entirely: nothing about where the video *used* to live can touch it once
|
||||
* it has been adopted.
|
||||
*
|
||||
* Module singleton, no Vue lifecycle hooks — a lifecycle hook in a bare
|
||||
* composable silently no-ops outside a component's setup(). */
|
||||
|
||||
const active = ref(false)
|
||||
const element = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
let host: HTMLDivElement | null = null
|
||||
let leaveHandler: (() => void) | null = null
|
||||
|
||||
function ensureHost(): HTMLDivElement {
|
||||
if (host) return host
|
||||
const el = document.createElement('div')
|
||||
el.setAttribute('data-pip-session-host', '')
|
||||
el.setAttribute('aria-hidden', 'true')
|
||||
el.style.position = 'fixed'
|
||||
el.style.top = '0'
|
||||
el.style.left = '0'
|
||||
el.style.width = '1px'
|
||||
el.style.height = '1px'
|
||||
el.style.overflow = 'hidden'
|
||||
el.style.opacity = '0'
|
||||
el.style.pointerEvents = 'none'
|
||||
el.style.zIndex = '-1'
|
||||
document.body.appendChild(el)
|
||||
host = el
|
||||
return el
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const video = element.value
|
||||
if (video) {
|
||||
if (leaveHandler) video.removeEventListener('leavepictureinpicture', leaveHandler)
|
||||
video.pause()
|
||||
video.removeAttribute('src')
|
||||
video.src = ''
|
||||
video.load()
|
||||
if (video.parentNode) video.parentNode.removeChild(video)
|
||||
}
|
||||
leaveHandler = null
|
||||
element.value = null
|
||||
active.value = false
|
||||
}
|
||||
|
||||
function adopt(video: HTMLVideoElement): void {
|
||||
if (active.value && element.value && element.value !== video) {
|
||||
release()
|
||||
}
|
||||
const hostEl = ensureHost()
|
||||
hostEl.appendChild(video)
|
||||
element.value = video
|
||||
active.value = true
|
||||
leaveHandler = () => release()
|
||||
video.addEventListener('leavepictureinpicture', leaveHandler)
|
||||
}
|
||||
|
||||
export function usePipSession() {
|
||||
return {
|
||||
active: readonly(active),
|
||||
element: readonly(element),
|
||||
adopt,
|
||||
release,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
dismissing: boolean
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, variant, dismissing: false, action })
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration)
|
||||
}
|
||||
|
||||
// Cap at 5 visible toasts
|
||||
if (toasts.value.length > 5) {
|
||||
toasts.value.shift()
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx === -1) return
|
||||
toasts.value[idx]!.dismissing = true
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts: readonly(toasts),
|
||||
success: (msg: string) => addToast(msg, 'success'),
|
||||
error: (msg: string) => addToast(msg, 'error'),
|
||||
info: (msg: string) => addToast(msg, 'info'),
|
||||
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
|
||||
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
|
||||
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
|
||||
dismiss: dismissToast,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Shared "open this transaction in an explorer" logic (2026-07-22).
|
||||
//
|
||||
// Nodes with a PRUNED bitcoin node can't run the Mempool app at all, but the
|
||||
// wallet UI's tx links used to blindly open the local app anyway (blank app
|
||||
// frame). Routing:
|
||||
// - local Mempool app running → open it, exactly as before
|
||||
// - otherwise → an EXTERNAL explorer, after a one-time
|
||||
// consent modal: viewing a tx on someone else's mempool tells that
|
||||
// server's operator which transaction you're interested in (plus your
|
||||
// IP), so the user must knowingly opt in and may point the link at
|
||||
// their own trusted instance instead.
|
||||
//
|
||||
// Preference + acknowledgement persist per browser in localStorage
|
||||
// (`archipelago.tx-explorer.v1`); the Settings → System section edits the
|
||||
// same values.
|
||||
import { ref } from 'vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
|
||||
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
|
||||
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
|
||||
|
||||
const KEY = 'archipelago.tx-explorer.v1'
|
||||
|
||||
interface TxExplorerPrefs {
|
||||
url: string
|
||||
acknowledged: boolean
|
||||
}
|
||||
|
||||
function loadPrefs(): TxExplorerPrefs {
|
||||
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Module-scope state: one source of truth shared by every caller, the global
|
||||
// consent modal, and the Settings section.
|
||||
const prefs = ref<TxExplorerPrefs>(loadPrefs())
|
||||
/** Tx hash awaiting user consent — non-null shows ExternalExplorerModal. */
|
||||
const pendingTx = ref<string | null>(null)
|
||||
|
||||
function savePrefs() {
|
||||
localStorage.setItem(KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
|
||||
export function useTxExplorer() {
|
||||
const launcher = useAppLauncherStore()
|
||||
const containers = useContainerStore()
|
||||
|
||||
/** Normalized explorer base URL (no trailing slash). */
|
||||
function explorerUrl(): string {
|
||||
return (prefs.value.url || DEFAULT_TX_EXPLORER).trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function openExternal(txHash: string) {
|
||||
window.open(`${explorerUrl()}/tx/${txHash}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for every "view transaction" affordance in the app.
|
||||
*
|
||||
* The local Mempool app WINS whenever it is installed — including while
|
||||
* it is stopped or restarting, where the app session's own controls are
|
||||
* the right place to land. Only a node that genuinely does not have the
|
||||
* app (the pruned-node case this file was written for) ever reaches an
|
||||
* external explorer.
|
||||
*
|
||||
* The await is load-bearing, not incidental. `getAppState` reports
|
||||
* `not-installed` for an app it simply has not fetched yet, so the old
|
||||
* synchronous check sent a user with a perfectly healthy local Mempool
|
||||
* to a third-party explorer whenever they clicked before the container
|
||||
* list arrived — a privacy leak decided by a race, and the reason this
|
||||
* regression kept coming back (reported again on .228, 2026-08-06).
|
||||
*/
|
||||
async function openTx(txHash: string) {
|
||||
await containers.ensureFetched()
|
||||
if (containers.getAppState('mempool') !== 'not-installed') {
|
||||
launcher.openSession('mempool', { path: `/tx/${txHash}` })
|
||||
return
|
||||
}
|
||||
if (prefs.value.acknowledged) {
|
||||
openExternal(txHash)
|
||||
return
|
||||
}
|
||||
pendingTx.value = txHash
|
||||
}
|
||||
|
||||
/** Consent modal confirm: persist the (possibly edited) URL + ack, open. */
|
||||
function confirmPending(url: string, dontAskAgain: boolean) {
|
||||
const trimmed = url.trim()
|
||||
prefs.value.url = trimmed || DEFAULT_TX_EXPLORER
|
||||
if (dontAskAgain) prefs.value.acknowledged = true
|
||||
savePrefs()
|
||||
const tx = pendingTx.value
|
||||
pendingTx.value = null
|
||||
if (tx) openExternal(tx)
|
||||
}
|
||||
|
||||
function cancelPending() {
|
||||
pendingTx.value = null
|
||||
}
|
||||
|
||||
/** Settings hook: change the explorer and/or reset the consent. */
|
||||
function setExplorer(url: string, acknowledged?: boolean) {
|
||||
prefs.value.url = url.trim() || DEFAULT_TX_EXPLORER
|
||||
if (acknowledged !== undefined) prefs.value.acknowledged = acknowledged
|
||||
savePrefs()
|
||||
}
|
||||
|
||||
return {
|
||||
prefs,
|
||||
pendingTx,
|
||||
explorerUrl,
|
||||
openTx,
|
||||
confirmPending,
|
||||
cancelPending,
|
||||
setExplorer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref, onActivated, onDeactivated, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Is this view the one currently on screen?
|
||||
*
|
||||
* Main tab views are `KeepAlive`'d (see `keepAliveRoutes.ts`), which is what
|
||||
* makes revisiting a tab instant — navigating away DEACTIVATES the view rather
|
||||
* than unmounting it. That is exactly the behaviour we want for performance, and
|
||||
* exactly the trap for anything the view `Teleport`s to `<body>`: teleported
|
||||
* content is not inside the view's own DOM subtree, so it keeps rendering over
|
||||
* whatever screen the user went to next. Mesh's mobile tab bar and its chat
|
||||
* back button did this — they stayed pinned above the bottom bar on every other
|
||||
* screen.
|
||||
*
|
||||
* `BaseModal` solves the transient-dialog half of this by closing itself on any
|
||||
* route change. That is the right fix for a dialog and the wrong one for
|
||||
* persistent chrome: a tab bar has no "closed" state to fall back to, and
|
||||
* forcing one would lose the user's place. Chrome should simply not be rendered
|
||||
* while its owner is off screen, and should come back exactly as it was.
|
||||
*
|
||||
* Usage — gate the `Teleport` itself, so nothing reaches `<body>` at all:
|
||||
*
|
||||
* const isViewActive = useViewActive()
|
||||
* <Teleport v-if="isViewActive" to="body"> … </Teleport>
|
||||
*
|
||||
* Defaults to `true`: outside a `KeepAlive` boundary neither hook ever fires, so
|
||||
* a view used both ways (or a component mounted bare in a test) must render
|
||||
* normally rather than stay invisible forever. Inside `KeepAlive`, Vue fires
|
||||
* `onActivated` immediately after `onMounted`, so the initial `true` is correct
|
||||
* there too and there is no first-paint gap.
|
||||
*/
|
||||
export function useViewActive(): Ref<boolean> {
|
||||
const isViewActive = ref(true)
|
||||
onActivated(() => {
|
||||
isViewActive.value = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isViewActive.value = false
|
||||
})
|
||||
return isViewActive
|
||||
}
|
||||
Reference in New Issue
Block a user