commit 21093eb8004cdedca9b13d6837dcfd12c0edf603 Author: Jason Tudisco Date: Sun Sep 27 08:37:10 2026 -0600 Initial commit: Goostr static site Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2752eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.DS_Store diff --git a/.stignore b/.stignore new file mode 100644 index 0000000..02c854d --- /dev/null +++ b/.stignore @@ -0,0 +1,6 @@ +/node_modules +node_modules +/.yarn +.yarn +/.pnp.* +.pnp.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..2019fa3 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Goostr (static) + +Your Google account is your nostr identity. No server, no key management. + +**Pure static site.** No build step. No backend. No install required to run. + +## How it works + +1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`. +2. We look in the user's **Google Drive App Data folder** for a file `goostr-key-v1.json`. (App Data is a per-OAuth-client hidden folder in the user's Drive that only this app's client ID can read. WhatsApp uses it for backups; Enpass uses it for vault sync.) + - **If found** → download it. The 32 bytes inside are the nostr private key. User is now signed into their existing nostr identity. + - **If missing** → generate 32 fresh random bytes (`crypto.getRandomValues`), upload to App Data. User now has a brand-new nostr identity. +3. We fetch the user's Google name + picture. For a brand-new identity we publish a kind-0 metadata event with them so it has a profile. For an existing one, if no relay returns a kind-0 within ~6s, we publish it then. +4. SessionStorage caches the key inside the tab (max 12h) so reloads don't require re-auth. Closing the tab logs out. + +Note: the key is **stored**, not derived. It's random bytes that live in the Drive file. Delete that file (or revoke/delete the OAuth client, which makes App Data unreadable) and the identity is gone for good. + +### The demo app + +Once signed in, [`app.js`](public/app.js) shows your name, avatar and npub, a dot per relay (connecting / open / closed / timeout), your last 50 notes (kind 1), and a box to publish a new note. + +### Relays + +[`relay.js`](public/relay.js) opens every relay in [`config.js`](public/config.js) in parallel (4s timeout each) and lets the UI continue as soon as **one** is open. Relays that connect later are sent every active subscription plus anything published in the last 60s, so nothing is lost to a slow relay. + +### Why this is good + +- Works across devices: sign in on phone, laptop, anything — same Google account → same nostr identity. +- No nsec to lose, no seed phrase to back up. Recovery == "remember your Google password." +- The seed key never touches *this* device's disk. It lives in Google Drive (encrypted at rest by Google) and in tab-only sessionStorage. + +### What this is *not* + +- Not as secure as `keystr` (passkey-based). Anyone with your Google password + 2FA can read your nostr key. With keystr/passkeys, the user must physically be at the authenticator device. +- Not for situations where you want to be invisible to a single big platform — Google obviously knows you own this Drive App Data file. +- Not safe against script injection. While signed in, the raw key sits in `sessionStorage`, readable by any script on the page. That includes `nostr-tools`, loaded from `esm.sh` via the import map in `index.html` with no integrity check. Pin/self-host it before trusting this with a real identity. +- Every sign-in shows Google's consent screen (`prompt: "consent"`) unless `signIn({ silent: true })` is used. + +## One-time Google setup (~2 minutes) + +1. Open . +2. Create or select a project. +3. Enable the **Google Drive API** under "Library." +4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User). +5. Back in "Credentials" → **Create Credentials** → **OAuth client ID** → **Web application**. +6. Under **Authorized JavaScript origins** add: + - `http://localhost:5174` (for local dev) + - your deploy URL (e.g. `https://you.github.io`) +7. Copy the **Client ID** — looks like `1234567890-abcdef.apps.googleusercontent.com`. +8. Paste it into [`public/config.js`](public/config.js) as the value of `CLIENT_ID`. + +## Run it + +```bash +python3 -m http.server -d public 5174 +# or +npx serve public -l 5174 +``` + +Then open . Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev. + +## Deploy it + +Static folder. Drop `public/` into any host. GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3 — all fine. Make sure HTTPS is on, and add your deployed URL to the Google Cloud OAuth client's authorized origins. + +## Layout + +``` +public/ + index.html — entry point + setup banner + app.js — UI glue + goostr.js — library: signIn(), restoreSession(), clearSession() + drive.js — Google Drive App Data helpers + relay.js — minimal nostr WebSocket pool with replay-on-late-connect + style.css — dark UI + config.js — CLIENT_ID + relay list (edit this) +``` + +## Library use + +```js +import { signIn, restoreSession, clearSession } from "./goostr.js"; + +// On boot, try restoring first (sessionStorage): +let session = restoreSession(); +if (!session) { + session = await signIn({ + clientId: "1234.apps.googleusercontent.com", + onStatus: (msg) => console.log(msg), + }); +} +const { signer, profile } = session; +// signer.sign({ kind: 1, content: "hello nostr", tags: [] }) +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..850a4ac --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "goostr-static", + "version": "0.1.0", + "description": "Sign in with Google → derive a nostr identity from a key stored in your Drive App Data. Pure static site, no server.", + "type": "module", + "main": "public/goostr.js", + "exports": { + ".": "./public/goostr.js" + }, + "files": ["public/goostr.js", "public/drive.js"], + "peerDependencies": { + "nostr-tools": "^2.7.0" + }, + "scripts": { + "serve": "npx --yes serve public -l 5174" + }, + "keywords": ["nostr", "google", "oauth", "drive-appdata", "static"], + "license": "MIT" +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..2b6ce81 --- /dev/null +++ b/public/app.js @@ -0,0 +1,216 @@ +import { CLIENT_ID } from "./config.js"; +import { + signIn, + restoreSession, + clearSession, + isConfigured, +} from "./goostr.js"; +import { pool } from "./relay.js"; + +const $ = (id) => document.getElementById(id); +const views = ["loading", "signin", "home"]; +function show(view) { + for (const v of views) $(`view-${v}`).classList.toggle("hidden", v !== view); +} +function setStatus(id, msg, kind = "") { + const el = $(id); + el.textContent = msg; + el.className = "status" + (kind ? " " + kind : ""); +} + +let signer = null; +let currentProfile = null; +let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered + +const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( + ` + + 👤 + ` +)}`; + +// ---------- sign-in ---------- + +$("signin-go").addEventListener("click", async () => { + $("signin-go").disabled = true; + try { + const { signer: s, profile, isNewIdentity } = await signIn({ + clientId: CLIENT_ID, + onStatus: (msg) => setStatus("signin-status", msg), + }); + signer = s; + currentProfile = profile; + + setStatus("signin-status", "Connecting to relays…"); + const unsubStatus = pool.onStatus((snap) => { + const open = Object.values(snap).filter((s) => s === "open").length; + setStatus("signin-status", `Connecting to relays… ${open}/${Object.keys(snap).length}`); + }); + await pool.connectAll({ minRelays: 1 }); + unsubStatus(); + + if (isNewIdentity) { + // First time: publish a kind-0 with the Google name + picture so this + // user shows up properly on nostr. + publishProfileEvent(profile); + } + + enterHome(profile); + } catch (err) { + console.error(err); + setStatus("signin-status", err.message || String(err), "error"); + $("signin-go").disabled = false; + } +}); + +$("sign-out").addEventListener("click", () => { + clearSession(); + signer = null; + location.reload(); +}); + +// ---------- home / live profile / notes ---------- + +function publishProfileEvent(profile) { + const event = signer.sign({ + kind: 0, + content: JSON.stringify({ + name: profile.name, + picture: profile.picture, + // `nip05` would be nice here but we don't host one + }), + tags: [], + }); + pool.publish(event); + publishedProfileTs = event.created_at; +} + +function enterHome(profile) { + $("me-name").textContent = profile.name || "anonymous"; + $("me-pic").src = profile.picture || DEFAULT_AVATAR; + $("me-npub").textContent = signer.npub; + show("home"); + pool.onStatus(renderRelayDots); + startLiveProfile(signer.pubkey, profile); + loadNotes(); +} + +function renderRelayDots(snap) { + const host = $("relay-dots"); + host.innerHTML = ""; + for (const [url, state] of Object.entries(snap)) { + const dot = document.createElement("span"); + dot.className = `relay-dot ${state}`; + dot.title = `${url} — ${state}`; + host.appendChild(dot); + } +} + +function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } } + +function startLiveProfile(pubkey, googleProfile) { + // Long-lived sub for the latest kind-0. If a relay has a newer event than + // what's on screen, upgrade. If no relay returns anything within a few + // seconds, our google-side profile may not have been published anywhere — + // republish to make sure it lands. + let sawOne = false; + pool.subscribe( + [{ kinds: [0], authors: [pubkey] }], + (ev) => { + sawOne = true; + if (ev.created_at <= publishedProfileTs) return; + const p = safeJSON(ev.content); + publishedProfileTs = ev.created_at; + if (p.name) $("me-name").textContent = p.name; + if (p.picture) $("me-pic").src = p.picture; + }, + null + ); + + setTimeout(() => { + if (!sawOne && googleProfile?.name) { + // No relay had our profile. Publish it now. + publishProfileEvent(googleProfile); + } + }, 6000); +} + +let notesById = new Map(); + +function loadNotes() { + const tbody = $("notes-table").querySelector("tbody"); + tbody.innerHTML = ""; + setStatus("notes-status", "Loading…"); + notesById = new Map(); + + pool.subscribe( + [{ kinds: [1], authors: [signer.pubkey], limit: 50 }], + (ev) => { + if (notesById.has(ev.id)) return; + notesById.set(ev.id, ev); + renderRows([...notesById.values()], tbody); + setStatus("notes-status", ""); + }, + null + ); + + setTimeout(() => { + if (notesById.size === 0) { + setStatus("notes-status", "No notes yet. Publish one above."); + } + }, 8000); +} + +function renderRows(rows, tbody) { + const sorted = [...rows].sort((a, b) => b.created_at - a.created_at); + tbody.innerHTML = ""; + for (const ev of sorted) { + const tr = document.createElement("tr"); + const t = document.createElement("td"); + t.textContent = new Date(ev.created_at * 1000).toLocaleString(); + const c = document.createElement("td"); + c.textContent = ev.content; + tr.appendChild(t); + tr.appendChild(c); + tbody.appendChild(tr); + } +} + +$("note-send").addEventListener("click", () => { + const text = $("note-text").value.trim(); + if (!text) return; + $("note-send").disabled = true; + const event = signer.sign({ kind: 1, content: text, tags: [] }); + pool.publish(event); + $("note-text").value = ""; + $("note-send").disabled = false; + notesById.set(event.id, event); + renderRows([...notesById.values()], $("notes-table").querySelector("tbody")); + setStatus("notes-status", ""); +}); + +// ---------- boot ---------- + +(async function boot() { + if (!isConfigured(CLIENT_ID)) { + $("setup-banner").classList.remove("hidden"); + show("signin"); + setStatus("signin-status", "Configure CLIENT_ID in public/config.js first.", "error"); + $("signin-go").disabled = true; + return; + } + + const restored = restoreSession(); + if (restored) { + signer = restored.signer; + currentProfile = restored.profile; + show("loading"); + $("loading-text").textContent = "Welcome back. Connecting to relays…"; + pool.onStatus(renderRelayDots); + await pool.connectAll({ minRelays: 1 }); + enterHome(restored.profile); + return; + } + + show("signin"); +})(); diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..322c593 --- /dev/null +++ b/public/config.js @@ -0,0 +1,17 @@ +// Goostr config. Edit this file with your Google OAuth client ID before +// running the app. See README.md for how to get one. +// +// Format: "1234567890-abcdef.apps.googleusercontent.com" +export const CLIENT_ID = "114166903886-pd1lb3k10o66qacn10smdnd63k64vont.apps.googleusercontent.com"; + +// Public nostr relays to publish to and read from. +export const RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.snort.social", + "wss://nostr.wine", + "wss://offchain.pub", + "wss://relay.nostr.band", + "wss://nostr.mom", +]; diff --git a/public/drive.js b/public/drive.js new file mode 100644 index 0000000..4812c4a --- /dev/null +++ b/public/drive.js @@ -0,0 +1,77 @@ +// Google Drive AppData helpers. Files written to the appDataFolder space are +// invisible to the user in the regular Drive UI and only readable by the +// OAuth client (i.e. *this* app's client_id) that created them. +// +// Docs: https://developers.google.com/drive/api/guides/appdata + +const DRIVE = "https://www.googleapis.com/drive/v3"; +const UPLOAD = "https://www.googleapis.com/upload/drive/v3"; + +async function driveFetch(token, url, init = {}) { + const res = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + ...(init.headers || {}), + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Drive API ${res.status}: ${text.slice(0, 300)}`); + } + return res; +} + +// Find a file by exact name inside the appDataFolder. Returns the file id, or +// null if not found. +export async function findAppDataFile(token, name) { + const url = + `${DRIVE}/files?spaces=appDataFolder` + + `&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` + + `&fields=files(id,name,modifiedTime)`; + const res = await driveFetch(token, url); + const { files } = await res.json(); + if (!files || files.length === 0) return null; + // If somehow multiple, prefer the most recent. + files.sort((a, b) => (b.modifiedTime || "").localeCompare(a.modifiedTime || "")); + return files[0].id; +} + +export async function downloadFile(token, fileId) { + const res = await driveFetch(token, `${DRIVE}/files/${fileId}?alt=media`); + return res.text(); +} + +// Multipart upload of a small JSON blob to appDataFolder. If fileId is given, +// updates that file (preserving parents); otherwise creates a new file. +export async function uploadAppDataJson(token, name, content, fileId = null) { + const boundary = "goostr-" + Math.random().toString(36).slice(2); + const metadata = fileId + ? { name } + : { name, parents: ["appDataFolder"] }; + + const body = + `--${boundary}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n` + + `--${boundary}\r\n` + + `Content-Type: application/json\r\n\r\n` + + `${content}\r\n` + + `--${boundary}--`; + + const url = fileId + ? `${UPLOAD}/files/${fileId}?uploadType=multipart` + : `${UPLOAD}/files?uploadType=multipart`; + + const res = await driveFetch(token, url, { + method: fileId ? "PATCH" : "POST", + headers: { "Content-Type": `multipart/related; boundary=${boundary}` }, + body, + }); + return res.json(); +} + +export async function fetchUserInfo(token) { + const res = await driveFetch(token, "https://www.googleapis.com/oauth2/v3/userinfo"); + return res.json(); // { sub, name, picture, email, ... } +} diff --git a/public/goostr.js b/public/goostr.js new file mode 100644 index 0000000..7eab7a0 --- /dev/null +++ b/public/goostr.js @@ -0,0 +1,194 @@ +// Goostr — Google-account-to-nostr login library. +// +// Flow: +// signIn({ clientId }) +// → opens Google sign-in popup +// → requests OAuth scopes: profile, email, drive.appdata +// → looks in Drive App Data for goostr-key-v1.json +// · if found, downloads the 32-byte seed +// · if missing, generates 32 fresh bytes and uploads them +// → returns { signer, profile } where profile is the Google name/picture/email +// +// The 32-byte seed is the nostr private key. It lives in: +// 1. The user's Drive App Data — survives device loss, syncs across devices. +// 2. sessionStorage for the current tab — survives reload, dies on tab close. +// It is never written to localStorage, IndexedDB, or any other disk-persistent +// store on the local device. + +import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools"; +import { + findAppDataFile, + downloadFile, + uploadAppDataJson, + fetchUserInfo, +} from "./drive.js"; + +const KEY_FILE = "goostr-key-v1.json"; +const SCOPES = [ + "https://www.googleapis.com/auth/drive.appdata", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + "openid", +].join(" "); + +// ---------- Google Identity Services token client ---------- + +let gisReady = null; +function ensureGisLoaded() { + if (gisReady) return gisReady; + gisReady = new Promise((resolve, reject) => { + if (window.google?.accounts?.oauth2) return resolve(); + const s = document.createElement("script"); + s.src = "https://accounts.google.com/gsi/client"; + s.async = true; + s.defer = true; + s.onload = () => resolve(); + s.onerror = () => reject(new Error("Failed to load Google Identity Services script.")); + document.head.appendChild(s); + }); + return gisReady; +} + +async function requestAccessToken(clientId, { silent = false } = {}) { + await ensureGisLoaded(); + return new Promise((resolve, reject) => { + const client = google.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: SCOPES, + prompt: silent ? "" : "consent", + callback: (resp) => { + if (resp.error) return reject(new Error(`Google auth: ${resp.error}`)); + if (!resp.access_token) return reject(new Error("Google auth: no access token returned.")); + resolve(resp.access_token); + }, + error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)), + }); + client.requestAccessToken(); + }); +} + +// ---------- key bytes / signer ---------- + +function randomBytes(len) { + const buf = new Uint8Array(len); + crypto.getRandomValues(buf); + return buf; +} + +function b64encBytes(bytes) { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} +function b64decBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function makeSigner(privKeyBytes) { + const pubkey = getPublicKey(privKeyBytes); + return { + pubkey, + npub: nip19.npubEncode(pubkey), + sign(unsignedEvent) { + return finalizeEvent( + { + ...unsignedEvent, + pubkey, + created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000), + }, + privKeyBytes + ); + }, + }; +} + +// ---------- session persistence (tab-scoped) ---------- + +const SESSION_KEY = "goostr.session.v1"; +const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling + +function saveSession(privKeyBytes, profile) { + try { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() }) + ); + } catch {} +} + +export function restoreSession() { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const { k, p, t } = JSON.parse(raw); + if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) { + sessionStorage.removeItem(SESSION_KEY); + return null; + } + return { signer: makeSigner(b64decBytes(k)), profile: p || {} }; + } catch { + return null; + } +} + +export function clearSession() { + try { sessionStorage.removeItem(SESSION_KEY); } catch {} +} + +// ---------- key file on Drive ---------- + +async function loadOrCreateSeed(accessToken, onStatus) { + onStatus?.("Looking for your key in Google Drive…"); + const fileId = await findAppDataFile(accessToken, KEY_FILE); + + if (fileId) { + onStatus?.("Found existing key. Unlocking your nostr identity…"); + const text = await downloadFile(accessToken, fileId); + let parsed; + try { parsed = JSON.parse(text); } catch { parsed = null; } + if (!parsed?.k) throw new Error("Stored key file is malformed."); + return { bytes: b64decBytes(parsed.k), created: false }; + } + + onStatus?.("First time on this Google account — generating a new identity…"); + const bytes = randomBytes(32); + const payload = JSON.stringify({ + v: 1, + k: b64encBytes(bytes), + created_at: new Date().toISOString(), + }); + await uploadAppDataJson(accessToken, KEY_FILE, payload, null); + return { bytes, created: true }; +} + +// ---------- public API ---------- + +export async function signIn({ clientId, silent = false, onStatus } = {}) { + if (!clientId || clientId.startsWith("PASTE-")) { + throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md."); + } + + onStatus?.("Signing in with Google…"); + const accessToken = await requestAccessToken(clientId, { silent }); + + onStatus?.("Reading your Google profile…"); + const userInfo = await fetchUserInfo(accessToken); + const profile = { + sub: userInfo.sub, + name: userInfo.name || userInfo.email || "Anonymous", + picture: userInfo.picture || null, + email: userInfo.email || null, + }; + + const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus); + + saveSession(bytes, profile); + return { signer: makeSigner(bytes), profile, isNewIdentity: created }; +} + +export function isConfigured(clientId) { + return !!clientId && !clientId.startsWith("PASTE-") && clientId.endsWith(".apps.googleusercontent.com"); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..81ec906 --- /dev/null +++ b/public/index.html @@ -0,0 +1,85 @@ + + + + + + Goostr — Google to nostr + + + + +
+
+

Goostr

+

Your Google account is your nostr identity.

+
+ + + +
+

Checking session…

+
+ + + + + + +
+ + + + diff --git a/public/relay.js b/public/relay.js new file mode 100644 index 0000000..e26f7fa --- /dev/null +++ b/public/relay.js @@ -0,0 +1,210 @@ +// Minimal nostr relay pool — fast-start, fault-tolerant. +// +// Design: +// * Open all relays in parallel, each with a 4s connect timeout. +// * connectAll() resolves as soon as the FIRST relay opens (or after the +// overall timeout). The rest keep opening in the background. +// * Late-arriving relays automatically replay anything we've already sent +// (active subscriptions + recent publishes), so the user never waits. +// * Per-relay status is exposed via onStatus() so the UI can show progress. + +import { RELAYS } from "./config.js"; + +const PER_RELAY_CONNECT_TIMEOUT_MS = 4000; +const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll() +const PUBLISH_REPLAY_WINDOW_MS = 60_000; // re-send recent publishes to late relays + +class RelayPool { + constructor(urls = RELAYS) { + this.urls = urls; + this.sockets = new Map(); // url -> WebSocket (only OPEN ones) + this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout" + this.subs = new Map(); // subId -> { req, handler } + this.recentPublishes = []; // { msg, t } + this.subId = 0; + this._listeners = new Set(); // status change listeners + } + + onStatus(fn) { + this._listeners.add(fn); + fn(this.statusSnapshot()); + return () => this._listeners.delete(fn); + } + + statusSnapshot() { + const out = {}; + for (const u of this.urls) out[u] = this.status.get(u) || "idle"; + return out; + } + + _notify() { + const snap = this.statusSnapshot(); + for (const fn of this._listeners) fn(snap); + } + + _open(url) { + return new Promise((resolve) => { + let settled = false; + const finish = (ok) => { + if (settled) return; + settled = true; + resolve(ok); + }; + + let ws; + try { + ws = new WebSocket(url); + } catch { + this.status.set(url, "closed"); + this._notify(); + return finish(false); + } + + this.status.set(url, "connecting"); + this._notify(); + + const timer = setTimeout(() => { + if (ws.readyState === WebSocket.CONNECTING) { + this.status.set(url, "timeout"); + this._notify(); + try { ws.close(); } catch {} + finish(false); + } + }, PER_RELAY_CONNECT_TIMEOUT_MS); + + ws.addEventListener("open", () => { + clearTimeout(timer); + const t0 = performance.now(); + this.sockets.set(url, ws); + this.status.set(url, "open"); + this._notify(); + console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`); + + // Replay active subscriptions and recent publishes to this new relay. + for (const { req } of this.subs.values()) { + try { ws.send(req); } catch {} + } + const cutoff = Date.now() - PUBLISH_REPLAY_WINDOW_MS; + for (const { msg, t } of this.recentPublishes) { + if (t >= cutoff) { + try { ws.send(msg); } catch {} + } + } + finish(true); + }); + + ws.addEventListener("error", () => { + clearTimeout(timer); + if (this.status.get(url) !== "open") this.status.set(url, "closed"); + this._notify(); + finish(false); + }); + + ws.addEventListener("close", () => { + clearTimeout(timer); + this.sockets.delete(url); + if (this.status.get(url) !== "timeout") this.status.set(url, "closed"); + this._notify(); + finish(false); + }); + + // Wire messages to active subscription handlers. + ws.addEventListener("message", (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + const subId = msg[1]; + const sub = this.subs.get(subId); + if (sub) sub.handler(msg, url); + }); + }); + } + + // Resolves as soon as we have at least `minRelays` open, or after the + // overall timeout. Always returns the number of relays that opened. + async connectAll({ minRelays = 1 } = {}) { + const attempts = this.urls.map((u) => this._open(u)); + let opened = 0; + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(this.sockets.size); + }; + + const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS); + + attempts.forEach((p) => + p.then((ok) => { + if (ok) opened++; + if (opened >= minRelays) { + clearTimeout(ceiling); + // Resolve quickly once we hit the threshold; other relays keep + // opening in the background and self-register via _open(). + finish(); + } + if (opened === 0 && attempts.every((_) => false)) { + /* unreachable; here for clarity */ + } + }) + ); + + // If every attempt fully settled with zero opens, resolve too. + Promise.allSettled(attempts).then(() => finish()); + }); + } + + publish(event) { + const msg = JSON.stringify(["EVENT", event]); + this.recentPublishes.push({ msg, t: Date.now() }); + // trim + if (this.recentPublishes.length > 200) this.recentPublishes.shift(); + for (const ws of this.sockets.values()) { + if (ws.readyState === WebSocket.OPEN) { + try { ws.send(msg); } catch {} + } + } + } + + // filters: array of REQ filters. onEvent(event) per match. onEose() once + // every currently-open relay has signaled EOSE. + subscribe(filters, onEvent, onEose) { + const id = `s${++this.subId}`; + const req = JSON.stringify(["REQ", id, ...filters]); + const seen = new Set(); + const eoseSet = new Set(); + + const handler = (msg, url) => { + if (msg[0] === "EVENT" && msg[1] === id) { + const e = msg[2]; + if (seen.has(e.id)) return; + seen.add(e.id); + onEvent(e); + } else if (msg[0] === "EOSE" && msg[1] === id) { + eoseSet.add(url); + if (eoseSet.size >= this.sockets.size && onEose) onEose(); + } + }; + + this.subs.set(id, { req, handler }); + + for (const ws of this.sockets.values()) { + if (ws.readyState === WebSocket.OPEN) { + try { ws.send(req); } catch {} + } + } + + return () => { + const close = JSON.stringify(["CLOSE", id]); + for (const ws of this.sockets.values()) { + if (ws.readyState === WebSocket.OPEN) { + try { ws.send(close); } catch {} + } + } + this.subs.delete(id); + }; + } +} + +export const pool = new RelayPool(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..40ae1d6 --- /dev/null +++ b/public/style.css @@ -0,0 +1,258 @@ +:root { + --bg: #0e0f12; + --panel: #16181d; + --panel-2: #1e2128; + --text: #e8e8ea; + --muted: #8b8f99; + --accent: #8b5cf6; + --accent-2: #6d28d9; + --border: #262932; + --danger: #ef4444; + --ok: #10b981; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif; + font-size: 15px; + line-height: 1.5; +} + +main { + max-width: 620px; + margin: 0 auto; + padding: 48px 20px 80px; +} + +header { margin-bottom: 32px; } +h1 { + font-size: 32px; + font-weight: 700; + margin: 0 0 4px; + letter-spacing: -0.02em; + background: linear-gradient(90deg, #4285f4, #34a853, #fbbc04, #ea4335); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.tag { color: var(--muted); margin: 0; } + +.view { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; +} +.view + .view { margin-top: 16px; } +.hidden { display: none !important; } + +h2 { margin: 0 0 16px; font-size: 20px; font-weight: 600; } +h3 { margin: 24px 0 8px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } + +.field { display: block; margin-bottom: 20px; } +.field > span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; } + +input[type="text"], textarea { + width: 100%; + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + font-size: 15px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} +input[type="text"]:focus, textarea:focus { border-color: var(--accent); } +textarea { resize: vertical; min-height: 84px; } + +.picker { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; + margin-bottom: 10px; +} +.picker button { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 0; + font-size: 22px; + cursor: pointer; + transition: transform 0.1s, border-color 0.1s; +} +.picker button:hover { transform: scale(1.08); } +.picker button.selected { border-color: var(--accent); background: rgba(139, 92, 246, 0.12); } + +.upload { + display: inline-block; + font-size: 13px; + color: var(--accent); + cursor: pointer; + margin-bottom: 10px; +} +.upload:hover { text-decoration: underline; } + +.preview img { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--panel-2); + object-fit: cover; + display: block; +} +.preview img:empty, .preview img[src=""] { display: none; } + +button { + font-family: inherit; + font-size: 14px; + cursor: pointer; +} +button.primary { + background: linear-gradient(180deg, var(--accent), var(--accent-2)); + color: white; + border: none; + border-radius: 10px; + padding: 12px 18px; + font-weight: 600; + width: 100%; + transition: filter 0.15s, transform 0.05s; +} +button.primary:hover:not(:disabled) { filter: brightness(1.08); } +button.primary:active:not(:disabled) { transform: translateY(1px); } +button.primary:disabled { opacity: 0.5; cursor: not-allowed; } + +button.google { + background: white; + color: #1f1f1f; + border: 1px solid #dadce0; + border-radius: 10px; + padding: 12px 18px; + font-weight: 500; + font-size: 15px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + cursor: pointer; + transition: background 0.15s, box-shadow 0.15s; +} +button.google:hover:not(:disabled) { background: #f6f9ff; box-shadow: 0 1px 3px rgba(0,0,0,0.15); } +button.google:active:not(:disabled) { background: #ebf2ff; } +button.google:disabled { opacity: 0.6; cursor: not-allowed; } +button.google svg { width: 18px; height: 18px; flex-shrink: 0; } + +.banner-warn { + background: rgba(234, 67, 53, 0.1); + border: 1px solid rgba(234, 67, 53, 0.4); + color: #ffd2cd; + border-radius: 10px; + padding: 14px 16px; + font-size: 13px; + margin-bottom: 16px; + line-height: 1.55; +} +.banner-warn code { + background: rgba(0,0,0,0.35); + border-radius: 4px; + padding: 1px 6px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; +} +.banner-warn a { color: #ffe; } + +button.ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 12px; +} +button.ghost:hover { color: var(--text); border-color: var(--muted); } + +.status { + font-size: 13px; + color: var(--muted); + margin: 12px 0 0; + min-height: 18px; +} +.status.error { color: var(--danger); } +.status.ok { color: var(--ok); } + +.secondary { font-size: 13px; color: var(--muted); margin-top: 18px; } +.secondary a { color: var(--accent); text-decoration: none; } +.secondary a:hover { text-decoration: underline; } + +.profile { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 14px; + align-items: center; + margin-bottom: 24px; +} +.profile img { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--panel-2); + object-fit: cover; +} +.profile h2 { margin: 0; } +.profile .npub { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + color: var(--muted); + margin: 2px 0 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 360px; +} + +.composer { display: grid; gap: 10px; margin-bottom: 8px; } + +.relays-bar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 18px; + font-size: 12px; + color: var(--muted); +} +.relays-bar .label { text-transform: uppercase; letter-spacing: 0.05em; } +.relay-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; + background: var(--border); + cursor: help; + transition: background 0.2s; +} +.relay-dot.open { background: var(--ok); } +.relay-dot.connecting { background: #facc15; animation: pulse 1s infinite; } +.relay-dot.timeout, .relay-dot.closed { background: var(--danger); } +@keyframes pulse { 50% { opacity: 0.4; } } + +table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +th, td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } +td:first-child { color: var(--muted); white-space: nowrap; width: 140px; font-variant-numeric: tabular-nums; } +tbody tr:last-child td { border-bottom: none; }