import { CLIENT_ID, RELAYS, EXTENSION_ID, EXTENSION_STORE_URL } from "./config.js"; import { signIn, restoreSession, clearSession, isConfigured } from "./goostr.js"; import { pool } from "./relay.js"; import { createBunker } from "./bunker.js"; import { passkeysAvailable } from "./passkey.js"; const $ = (id) => document.getElementById(id); const VIEWS = { loading: ["view-loading"], signin: ["view-signin"], home: ["view-home", "view-connect", "view-key"], }; function show(view) { const on = new Set(VIEWS[view]); for (const ids of Object.values(VIEWS)) for (const id of ids) $(id).classList.toggle("hidden", !on.has(id)); } function setStatus(id, msg, kind = "") { const el = $(id); el.textContent = msg; el.className = "status" + (kind ? " " + kind : ""); } const errMsg = (err) => err?.message || String(err); const ago = (t) => { const s = Math.round((Date.now() - t) / 1000); if (s < 60) return "just now"; if (s < 3600) return `${Math.round(s / 60)}m ago`; if (s < 86400) return `${Math.round(s / 3600)}h ago`; return new Date(t).toLocaleDateString(); }; // Opened by the extension's "Unlock" button: hand the key over once signed in. const extensionUnlockRequested = location.hash === "#extension-unlock"; let session = null; let signer = null; let bunker = 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 { session = await signIn({ clientId: CLIENT_ID, onStatus: (msg) => setStatus("signin-status", msg), // Passkey prompts need a fresh click (Safari), so wait for one. onPasskeyNeeded: () => new Promise((resolve) => { const b = $("signin-passkey"); b.classList.remove("hidden"); b.onclick = () => { b.classList.add("hidden"); resolve(); }; }), }); signer = session.signer; 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 (session.isNewIdentity) { // Brand-new key: nothing can exist on relays yet, so publish the Google // name + picture right away. Late relays get it via the pool's replay. publishProfileEvent(session.profile); } enterHome(); } catch (err) { console.error(err); $("signin-passkey").classList.add("hidden"); setStatus("signin-status", errMsg(err), "error"); $("signin-go").disabled = false; } }); $("sign-out").addEventListener("click", () => { clearSession(); location.replace(location.pathname); }); // ---------- home / live profile / notes ---------- function publishProfileEvent(profile) { const event = signer.sign({ kind: 0, content: JSON.stringify({ name: profile.name, picture: profile.picture }), tags: [], }); pool.publish(event); publishedProfileTs = event.created_at; } function enterHome() { const { profile } = session; $("me-name").textContent = profile.name || "anonymous"; $("me-pic").src = profile.picture || DEFAULT_AVATAR; $("me-npub").textContent = signer.npub; show("home"); pool.onStatus(renderRelayDots); startLiveProfile(profile); loadNotes(); initExtension(); initBunker(); renderKey(); } 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(googleProfile) { // Long-lived sub for the latest kind-0. If a relay has a newer event than // what's on screen, upgrade. let sawOne = false; pool.subscribe( [{ kinds: [0], authors: [signer.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; }, (answered) => { // Fill in a profile only when relays positively report there is none. // A slow relay must never overwrite a profile set in another app. if (sawOne || publishedProfileTs || !googleProfile?.name) return; if (answered >= Math.min(2, pool.sockets.size) && answered > 0) publishProfileEvent(googleProfile); } ); } 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", ""); }, () => { if (notesById.size === 0) setStatus("notes-status", "No notes yet. Publish one above."); } ); } 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; const event = signer.sign({ kind: 1, content: text, tags: [] }); pool.publish(event); $("note-text").value = ""; notesById.set(event.id, event); renderRows([...notesById.values()], $("notes-table").querySelector("tbody")); setStatus("notes-status", ""); }); // ---------- browser extension (NIP-07) ---------- const hasExtensionApi = () => !!globalThis.chrome?.runtime?.sendMessage; async function extensionCall(msg) { if (!EXTENSION_ID || !hasExtensionApi()) return null; try { const r = await chrome.runtime.sendMessage(EXTENSION_ID, msg); if (r?.error) throw new Error(r.error); return r; } catch (err) { if (msg.type === "status") return null; // not installed throw err; } } async function renderExtension() { for (const id of ["ext-install", "ext-connect", "ext-lock", "ext-store", "ext-download", "ext-steps"]) $(id).classList.add("hidden"); if (!globalThis.chrome) { setStatus("ext-status", "This browser can't run the extension. Use the remote signer below instead."); return null; } const st = await extensionCall({ type: "status" }); if (!st) { setStatus("ext-status", "Not installed."); $("ext-install").classList.remove("hidden"); if (EXTENSION_STORE_URL) { $("ext-store").href = EXTENSION_STORE_URL; $("ext-store").classList.remove("hidden"); } else { $("ext-download").classList.remove("hidden"); $("ext-steps").classList.remove("hidden"); } } else if (st.pubkey === signer.pubkey) { setStatus("ext-status", "✓ Installed and signing as you.", "ok"); $("ext-lock").classList.remove("hidden"); } else { setStatus("ext-status", st.pubkey ? "Installed, but holding a different identity." : "Installed and locked."); $("ext-connect").classList.remove("hidden"); } return st; } async function unlockExtension() { await extensionCall({ type: "setKey", sk: session.keyHex(), profile: { name: session.profile.name, picture: session.profile.picture }, }); return renderExtension(); } async function initExtension() { const st = await renderExtension(); if (extensionUnlockRequested) { history.replaceState(null, "", location.pathname); if (!st) return; try { await unlockExtension(); const b = $("ext-unlock-banner"); b.textContent = "✓ The Goostr extension is unlocked. You can close this tab."; b.classList.remove("hidden"); } catch (err) { setStatus("ext-status", errMsg(err), "error"); } } } $("ext-connect").addEventListener("click", () => unlockExtension().catch((err) => setStatus("ext-status", errMsg(err), "error")) ); $("ext-lock").addEventListener("click", async () => { await extensionCall({ type: "lock" }).catch(() => {}); renderExtension(); }); // A page only gets chrome.runtime if a matching extension was installed // before it loaded, so a fresh install needs a reload to be seen. $("ext-recheck").addEventListener("click", () => (hasExtensionApi() ? renderExtension() : location.reload())); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && signer) renderExtension(); }); // ---------- remote signer (NIP-46) ---------- function initBunker() { bunker = createBunker({ signer, pool, relays: RELAYS.slice(0, 3), onChange: renderBunker }); if (localStorage.getItem(bunkerOnKey()) === "1") bunker.start(); renderBunker(); } const bunkerOnKey = () => `goostr.bunker.on.${signer.pubkey}`; function setBunkerOn(on) { try { localStorage.setItem(bunkerOnKey(), on ? "1" : "0"); } catch {} on ? bunker.start() : bunker.stop(); } function renderBunker() { if (!bunker) return; setStatus("bunker-state", bunker.running ? "● Running in this tab" : "Off", bunker.running ? "ok" : ""); $("bunker-toggle").textContent = bunker.running ? "Turn off" : "Turn on"; const clients = Object.entries(bunker.clients); $("bunker-no-clients").classList.toggle("hidden", clients.length > 0); $("bunker-clients").replaceChildren( ...clients.map(([pk, c]) => { const li = document.createElement("li"); const who = document.createElement("span"); who.className = "who"; who.textContent = c.name; who.title = pk; const when = document.createElement("span"); when.className = "when"; when.textContent = `used ${ago(c.lastSeen)}`; const rm = document.createElement("button"); rm.className = "ghost"; rm.textContent = "Remove"; rm.onclick = () => bunker.revoke(pk); li.append(who, when, rm); return li; }) ); $("bunker-no-log").classList.toggle("hidden", bunker.log.length > 0); $("bunker-log").replaceChildren( ...bunker.log.map((e) => { const li = document.createElement("li"); if (!e.ok) li.className = "fail"; const what = document.createElement("span"); what.className = "what"; what.textContent = `${e.who}: ${e.method}${e.detail ? ` (${e.detail})` : ""}`; const when = document.createElement("span"); when.className = "when"; when.textContent = new Date(e.t).toLocaleTimeString(); li.append(what, when); return li; }) ); } $("bunker-toggle").addEventListener("click", () => setBunkerOn(!bunker.running)); $("bunker-new").addEventListener("click", () => { setBunkerOn(true); $("bunker-link").value = bunker.newBunkerUrl(); $("bunker-link-box").classList.remove("hidden"); $("bunker-link").select(); }); $("bunker-copy").addEventListener("click", async () => { await navigator.clipboard.writeText($("bunker-link").value); $("bunker-copy").textContent = "Copied"; setTimeout(() => ($("bunker-copy").textContent = "Copy"), 1500); }); $("nc-go").addEventListener("click", async () => { try { setBunkerOn(true); await bunker.acceptNostrConnect($("nc-link").value); $("nc-link").value = ""; setStatus("bunker-status", "Connected. Switch back to the app.", "ok"); } catch (err) { setStatus("bunker-status", errMsg(err), "error"); } }); // Warn before closing the tab while apps depend on it. addEventListener("beforeunload", (e) => { if (bunker?.running && Object.keys(bunker.clients).length) e.preventDefault(); }); // ---------- key protection, backup, export ---------- function renderKey() { const p = session.protection; $("key-protection").textContent = p.type === "passkey" ? `🔒 Encrypted with a passkey for ${p.rpId}.` : "Stored in your Google Drive app data, not encrypted. Anyone who gets into your Google account can read it."; $("pk-enable").classList.toggle("hidden", p.type === "passkey" || !passkeysAvailable()); $("pk-disable").classList.toggle("hidden", p.type !== "passkey"); if (!passkeysAvailable()) setStatus("pk-status", "This browser doesn't support passkeys."); } $("pk-enable").addEventListener("click", async () => { $("pk-enable").disabled = true; setStatus("pk-status", "Follow your browser's passkey prompt…"); try { await session.enablePasskey(); setStatus("pk-status", "Done. Other devices will ask for this passkey when you sign in.", "ok"); } catch (err) { setStatus("pk-status", errMsg(err), "error"); } $("pk-enable").disabled = false; renderKey(); }); $("pk-disable").addEventListener("click", async () => { if (!confirm("Store the key in Google Drive without passkey encryption again?")) return; try { await session.disablePasskey(); setStatus("pk-status", "Passkey protection removed.", "ok"); } catch (err) { setStatus("pk-status", errMsg(err), "error"); } renderKey(); }); function backupPassword() { const a = $("bk-pw1").value; if (a.length < 8) throw new Error("Use at least 8 characters."); if (a !== $("bk-pw2").value) throw new Error("The passwords don't match."); return a; } $("bk-download").addEventListener("click", () => { try { const pw = backupPassword(); setStatus("bk-status", "Encrypting…"); // Let the status paint before scrypt blocks the thread for a moment. setTimeout(() => { const text = `${session.ncryptsec(pw)}\n\nGoostr backup for ${signer.npub}\nEncrypted with NIP-49. Import it into any NIP-49 signer with your backup password.\n`; const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" })); a.download = `goostr-backup-${signer.npub.slice(0, 14)}.txt`; a.click(); URL.revokeObjectURL(a.href); setStatus("bk-status", "Backup downloaded. Keep the file and the password somewhere safe (separately).", "ok"); }, 30); } catch (err) { setStatus("bk-status", errMsg(err), "error"); } }); $("bk-drive").addEventListener("click", async () => { try { const pw = backupPassword(); setStatus("bk-status", "Saving to Google Drive (Google will ask for file access)…"); const link = await session.backupToDrive(pw); const el = $("bk-status"); el.className = "status ok"; el.textContent = "Saved to your Google Drive. "; if (link) { const a = document.createElement("a"); a.href = link; a.target = "_blank"; a.rel = "noopener"; a.textContent = "Open it"; el.append(a); } } catch (err) { setStatus("bk-status", errMsg(err), "error"); } }); $("nsec-reveal").addEventListener("click", () => { const box = $("nsec-box"); const showing = !box.classList.contains("hidden"); $("nsec-value").textContent = showing ? "" : session.nsec(); box.classList.toggle("hidden", showing); $("nsec-reveal").textContent = showing ? "Reveal nsec" : "Hide nsec"; }); $("nsec-copy").addEventListener("click", async () => { await navigator.clipboard.writeText(session.nsec()); $("nsec-copy").textContent = "Copied"; setTimeout(() => ($("nsec-copy").textContent = "Copy"), 1500); }); // ---------- 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; } session = restoreSession({ clientId: CLIENT_ID }); if (session) { signer = session.signer; show("loading"); $("loading-text").textContent = "Welcome back. Connecting to relays…"; await pool.connectAll({ minRelays: 1 }); enterHome(); return; } if (extensionUnlockRequested) { setStatus("signin-status", "Sign in to unlock the Goostr extension."); } show("signin"); })();