import { get } from 'svelte/store'; import { relayRepository, roomRepository, messageRepository, muteRepository, profileRepository, settingsRepository } from './repositories'; import { recordToAddress } from './database'; import { settingsStore } from '$lib/stores/settings-store'; import { relayStore } from '$lib/stores/relay-store'; import { roomStore } from '$lib/stores/room-store'; import { muteStore } from '$lib/stores/mute-store'; import { terminalStore } from '$lib/stores/terminal-store'; import { relayService } from '$lib/nostr/relay-service'; import { roomService } from '$lib/nostr/room-service'; import { profileService } from '$lib/nostr/profile-service'; import { initTheme, setTheme, loadCustomThemes } from '$lib/stores/theme-store'; import { resolveTheme } from '$lib/themes/theme-registry'; import { RESTORE_MESSAGES_PER_ROOM } from '$lib/config'; import { shortenPubkey } from '$lib/utils/pubkey'; /** * loads saved state at startup and keeps indexeddb in sync with the runtime * stores after that. lives outside the stores so they stay framework-pure and * unit-testable without indexeddb. * * heads up (security): cached events are UNTRUSTED on load. we show restored * messages as history, but re-validation happens live when the room's sub * re-delivers events; restored records look exactly like remote chat (never * system lines) and their content is plain text only. */ /** * read a `?theme=` query param and return the matching theme id, or null. * checked against the registry (built-in + custom, alias-aware) so an unknown * or junk value just gets ignored. then we strip the param from the url so a * reload/bookmark doesn't keep clobbering the user's later choice. */ function readThemeParam(): string | null { if (typeof window === 'undefined') return null; try { const raw = new URLSearchParams(window.location.search).get('theme'); if (!raw) return null; const theme = resolveTheme(raw); if (!theme) return null; // drop the param without a navigation/reload. const url = new URL(window.location.href); url.searchParams.delete('theme'); window.history.replaceState({}, '', url); return theme.id; } catch { return null; } } class PersistenceService { private started = false; /** load saved settings, mutes, profiles, relays, and rooms. */ async restore(): Promise { // 1) settings first, so the theme lands before the ui is interactive. const savedSettings = await settingsRepository.load(); if (savedSettings) settingsStore.set(savedSettings); // register saved custom themes before applying, so a custom active theme // resolves instead of falling back to the default. await loadCustomThemes(); // a `?theme=` query param (e.g. from the marketing site's "launch // client" link) beats the saved theme so a visitor's pick follows them // into the app. only a known theme wins; anything else is ignored and the // saved/default theme applies as usual. const urlThemeId = readThemeParam(); initTheme(get(settingsStore).themeId); if (urlThemeId) setTheme(urlThemeId); // resolve + apply + save // 2) muted pubkeys. const muted = await muteRepository.list(); muteStore.setAll(muted); // 3) cached profiles (so restored/live messages show names right away). for (const profile of await profileRepository.all()) { profileService.hydrate(profile.pubkey, profile.name, profile.nip05); } // 4) reconnect to relays we used before. const relays = await relayRepository.list(); for (const relay of relays) { void relayService.connect(relay.url); } // 5) rejoin rooms we were in and restore their recent history. const rooms = await roomRepository.list(); for (const room of rooms) { await this.restoreRoom(room.id, room.name); // rejoin re-opens the live sub (which re-validates events). void roomService.join(recordToAddress(room)); } if (relays.length > 0 || rooms.length > 0) { terminalStore.system( `Restored ${relays.length} relay(s) and ${rooms.length} room(s) from your last session.`, 'system' ); } this.start(); } /** dump a room's cached messages back into the terminal as history. */ private async restoreRoom(roomId: string, roomName: string): Promise { const messages = await messageRepository.recent(roomId, RESTORE_MESSAGES_PER_ROOM); if (messages.length === 0) return; terminalStore.system(`--- ${roomName} history (${messages.length}) ---`, 'system', roomId); for (const m of messages) { if (get(muteStore).has(m.authorPubkey)) continue; const name = profileService.cachedName(m.authorPubkey) ?? shortenPubkey(m.authorPubkey); terminalStore.push({ type: m.action ? 'action' : 'message', author: m.authorPubkey, authorDisplayName: name, content: m.content, roomId, timestamp: m.createdAt ? m.createdAt * 1000 : Date.now() }); } } /** start mirroring runtime store changes into indexeddb. idempotent. */ private start(): void { if (this.started) return; this.started = true; // save settings on every change. settingsStore.subscribe((settings) => { void settingsRepository.save(settings); }); // save the relay set (added/removed) reactively. relayStore.subscribe((relays) => { void this.syncRelays(Object.keys(relays)); }); // save joined rooms reactively. roomStore.subscribe((state) => { void this.syncRooms(state.rooms); }); // save mute list reactively. muteStore.subscribe((set) => { void this.syncMutes([...set]); }); } private async syncRelays(current: string[]): Promise { const stored = new Set((await relayRepository.list()).map((r) => r.url)); for (const url of current) if (!stored.has(url)) await relayRepository.add(url); for (const url of stored) if (!current.includes(url)) await relayRepository.remove(url); } private async syncRooms( rooms: Record< string, { id: string; address: { relayUrl: string; groupId: string }; name: string; topic?: string } > ): Promise { const stored = new Set((await roomRepository.list()).map((r) => r.id)); const currentIds = new Set(Object.keys(rooms)); for (const room of Object.values(rooms)) { await roomRepository.add(room.address, room.name, room.topic); } for (const id of stored) if (!currentIds.has(id)) await roomRepository.remove(id); } private async syncMutes(current: string[]): Promise { const stored = new Set(await muteRepository.list()); for (const pk of current) if (!stored.has(pk)) await muteRepository.add(pk); for (const pk of stored) if (!current.includes(pk)) await muteRepository.remove(pk); } } export const persistenceService = new PersistenceService();