Root cause, reproduced live against checkstr.atobitcoin.io with a real browser: the default relays were bad. wss://relay.damus.io was returning 503 at the WS handshake, and wss://nos.lol accepted the connection but silently never sent an OK for a brand-new pubkey's first event -- no error, just permanent silence. Promise.any over both left the check-in dialog frozen indefinitely with zero feedback. - Swap default relays to three that were actually verified (via a raw WebSocket test, bypassing any client library) to send an explicit OK within a couple seconds for a fresh key: relay.primal.net, offchain.pub, relay.nostr.wirednet.jp. - Add a hard client-side timeout (10s) around every publish, normalizing both failure paths (AggregateError from all-relays-rejected, or nothing responding at all) into one clear message instead of an indefinite hang. - Disable the Check In/Check Out buttons and show a pending label while a publish is in flight, instead of giving no feedback at all.
241 lines
7.5 KiB
JavaScript
241 lines
7.5 KiB
JavaScript
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 };
|