Initial commit: Nosterm client (terminal-style Nostr chat SPA)
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user