import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey } from 'https://esm.sh/nostr-tools@2.7.2'; // Addressable (NIP-01 30000-39999 range) kind for "current room status". // Not a registered NIP kind -- picked clear of known reserved numbers // (long-form 30023, live event 30311, classified listing 30402/3, // calendar 31922/3, app handler 31989/90, git repo 30617/8). Addressable // events are scoped per (pubkey, kind, d-tag), so this only collides with // another app if it ALSO renders unrelated JSON under the same kind in a // confusing way -- worth registering properly if this grows beyond a demo. const ROOM_STATUS_KIND = 33001; const LOG_KIND = 1; const STORAGE_KEYS = { relays: 'checkstr.relays', propertyId: 'checkstr.propertyId', demoSecretKey: 'checkstr.demoSecretKey', }; // Picked after actually testing publish-ACK behavior for a brand-new // pubkey, not on reputation: wss://relay.damus.io was returning 503 at the // WS handshake, and wss://nos.lol accepted the connection but never sent // an OK for a first-time key's event (no error either -- just silence). // These three gave an explicit OK within a couple seconds in that same // test. Relay behavior drifts over time; re-verify if check-in starts // hanging again rather than assuming these stay good forever. const DEFAULT_RELAYS = [ 'wss://relay.primal.net', 'wss://offchain.pub', 'wss://relay.nostr.wirednet.jp', ]; const PUBLISH_TIMEOUT_MS = 10_000; function withTimeout(promise, ms, message) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } const pool = new SimplePool(); const state = { pubkey: null, signMode: null, // 'nip07' | 'demo' demoSk: null, relays: loadRelays(), propertyId: localStorage.getItem(STORAGE_KEYS.propertyId) || '', rooms: new Map(), // roomId -> { status, guest, since, eventCreatedAt } log: [], // recent activity notes, newest first roomSub: null, logSub: null, }; function loadRelays() { const raw = localStorage.getItem(STORAGE_KEYS.relays); if (!raw) return DEFAULT_RELAYS; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) && parsed.length ? parsed : DEFAULT_RELAYS; } catch { return DEFAULT_RELAYS; } } function saveRelays(relays) { state.relays = relays; localStorage.setItem(STORAGE_KEYS.relays, JSON.stringify(relays)); } function savePropertyId(id) { state.propertyId = id; localStorage.setItem(STORAGE_KEYS.propertyId, id); } function roomTag() { return `checkstr-${state.propertyId}`; } // --- Auth --- async function loginWithExtension() { if (!window.nostr) throw new Error('No NIP-07 extension found (e.g. Alby, nos2x).'); const pubkey = await window.nostr.getPublicKey(); state.pubkey = pubkey; state.signMode = 'nip07'; return pubkey; } function loginWithDemoKey() { let hex = localStorage.getItem(STORAGE_KEYS.demoSecretKey); let sk; if (hex) { sk = Uint8Array.from(hex.match(/.{2}/g).map((b) => parseInt(b, 16))); } else { sk = generateSecretKey(); localStorage.setItem( STORAGE_KEYS.demoSecretKey, Array.from(sk).map((b) => b.toString(16).padStart(2, '0')).join(''), ); } state.demoSk = sk; state.pubkey = getPublicKey(sk); state.signMode = 'demo'; return state.pubkey; } async function signEvent(template) { if (state.signMode === 'nip07') return window.nostr.signEvent(template); if (state.signMode === 'demo') return finalizeEvent(template, state.demoSk); throw new Error('Not logged in'); } // --- Publishing --- // Publishes a signed event and waits for at least one relay to ACK it. // Normalizes BOTH failure paths to one clear message: either every relay // rejects (AggregateError, whose default message is useless) or none // respond at all within PUBLISH_TIMEOUT_MS (the actual bug this fixes -- // a relay that accepts the connection but silently never sends OK used to // hang the UI forever with zero feedback). async function publishAndWait(signed, what) { try { await withTimeout( Promise.any(pool.publish(state.relays, signed)), PUBLISH_TIMEOUT_MS, `No relay acknowledged the ${what} within ${PUBLISH_TIMEOUT_MS / 1000}s. Try different relays in Setup.`, ); } catch (err) { if (err instanceof AggregateError) { throw new Error(`All ${state.relays.length} relay(s) rejected the ${what}.`); } throw err; } } async function publishRoomStatus(roomId, { status, guest, since }) { const template = { kind: ROOM_STATUS_KIND, created_at: Math.floor(Date.now() / 1000), tags: [ ['d', `${state.propertyId}:${roomId}`], ['status', status], ['room', roomId], ['t', roomTag()], ], content: JSON.stringify({ guest: guest || null, since: since || null }), }; const signed = await signEvent(template); await publishAndWait(signed, 'room-status update'); return signed; } async function publishLog(text, roomId) { const template = { kind: LOG_KIND, created_at: Math.floor(Date.now() / 1000), tags: [ ['t', 'checkstr'], ['t', roomTag()], ['room', roomId], ], content: text, }; const signed = await signEvent(template); await publishAndWait(signed, 'activity log entry'); return signed; } export async function checkIn(roomId, guestName) { const since = Math.floor(Date.now() / 1000); await publishRoomStatus(roomId, { status: 'occupied', guest: guestName, since }); await publishLog(`Checked in: ${guestName} → room ${roomId}`, roomId); } export async function checkOut(roomId) { const room = state.rooms.get(roomId); const guestName = room?.guest || 'guest'; await publishRoomStatus(roomId, { status: 'vacant', guest: null, since: null }); await publishLog(`Checked out: ${guestName} ← room ${roomId}`, roomId); } // --- Subscriptions --- function applyRoomEvent(evt) { const dTag = evt.tags.find((t) => t[0] === 'd')?.[1]; if (!dTag) return; const roomId = dTag.split(':').slice(1).join(':'); const existing = state.rooms.get(roomId); // Addressable events: only the newest created_at for this d-tag matters. if (existing && existing.eventCreatedAt >= evt.created_at) return; let parsed = {}; try { parsed = JSON.parse(evt.content); } catch { // malformed content from a non-conforming publisher; keep status tag only } const status = evt.tags.find((t) => t[0] === 'status')?.[1] || 'unknown'; state.rooms.set(roomId, { status, guest: parsed.guest ?? null, since: parsed.since ?? null, eventCreatedAt: evt.created_at, }); window.dispatchEvent(new CustomEvent('checkstr:rooms-changed')); } function applyLogEvent(evt) { if (state.log.some((e) => e.id === evt.id)) return; state.log.unshift(evt); state.log = state.log.slice(0, 50); window.dispatchEvent(new CustomEvent('checkstr:log-changed')); } export function subscribe() { unsubscribe(); if (!state.pubkey || !state.propertyId) return; state.roomSub = pool.subscribeMany( state.relays, [{ kinds: [ROOM_STATUS_KIND], authors: [state.pubkey], '#t': [roomTag()] }], { onevent: applyRoomEvent }, ); state.logSub = pool.subscribeMany( state.relays, [{ kinds: [LOG_KIND], '#t': [roomTag()], limit: 50 }], { onevent: applyLogEvent }, ); } export function unsubscribe() { state.roomSub?.close(); state.logSub?.close(); state.roomSub = null; state.logSub = null; state.rooms.clear(); state.log = []; } export { state, loginWithExtension, loginWithDemoKey, saveRelays, savePropertyId };