182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
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=<id>` 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<void> {
|
|
// 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=<id>` 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|