Initial commit: Nosterm client (terminal-style Nostr chat SPA)
ci / build (push) Successful in 1m3s
ci / image (push) Successful in 41s

This commit is contained in:
Robert Goodall
2026-07-24 13:47:53 -04:00
commit cd2b900639
147 changed files with 16504 additions and 0 deletions
+181
View File
@@ -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();