Files
ssmithx f8b9bbfbe2 fix: Check In/Out button hanging forever with no feedback
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.
2026-11-12 04:07:32 +00:00

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 };