From 94d2e48455b8bc981f90c895a6b6efbc6931f869 Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.6)" Date: Mon, 23 Mar 2026 22:54:07 +0000 Subject: [PATCH] [gemini] NIP-07 visitor Nostr identity in Workshop (#14) (#104) Co-authored-by: Claude (Opus 4.6) Co-committed-by: Claude (Opus 4.6) --- artifacts/api-server/src/routes/events.ts | 16 ++++- the-matrix/index.html | 21 ++++++ the-matrix/js/nostr-identity.js | 14 ++++ the-matrix/js/ui.js | 86 ++++++++++++++++++++++- the-matrix/js/websocket.js | 4 +- 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index 24c9acd..f644ded 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -38,6 +38,9 @@ const logger = makeLogger("ws-events"); const PING_INTERVAL_MS = 30_000; +// Map to store visitorId -> npub mappings +const connectedVisitors = new Map(); + // ── Per-visitor rate limit (3 replies/minute) ───────────────────────────────── const CHAT_RATE_LIMIT = 3; const CHAT_RATE_WINDOW_MS = 60_000; @@ -323,12 +326,19 @@ export function attachWebSocketServer(server: Server): void { socket.on("message", (raw) => { try { - const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string }; + const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string; npub?: string }; if (msg.type === "pong") return; if (msg.type === "subscribe") { send(socket, { type: "agent_count", count: wss.clients.size }); } if (msg.type === "visitor_enter") { + const { visitorId, npub } = msg; + if (visitorId && npub) { + connectedVisitors.set(visitorId, npub); + const formattedNpub = `${npub.slice(0, 8)}…${npub.slice(-4)}`; + broadcastToAll(wss, { type: "chat", agentId: "timmy", text: `Welcome, Nostr user ${formattedNpub}! What can I help you with?` }); + } + wss.clients.forEach(c => { if (c !== socket && c.readyState === 1) { c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size })); @@ -337,6 +347,10 @@ export function attachWebSocketServer(server: Server): void { send(socket, { type: "visitor_count", count: wss.clients.size }); } if (msg.type === "visitor_leave") { + const { visitorId } = msg; + if (visitorId) { + connectedVisitors.delete(visitorId); + } wss.clients.forEach(c => { if (c !== socket && c.readyState === 1) { c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) })); diff --git a/the-matrix/index.html b/the-matrix/index.html index 6055915..f3ac471 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -37,6 +37,25 @@ font-size: 13px; letter-spacing: 3px; margin-bottom: 4px; color: #7799cc; text-shadow: 0 0 10px #4466aa; } + + /* Nostr Identity UI */ + .nostr-btn { + background: rgba(40, 30, 70, 0.9); + border: 1px solid #443377; + color: #aaddff; font-family: 'Courier New', monospace; + font-size: 11px; padding: 4px 10px; cursor: pointer; + border-radius: 3px; transition: background 0.15s, border-color 0.15s; + } + .nostr-btn:hover { background: rgba(60, 45, 100, 0.9); border-color: #665599; } + .nostr-btn-sm { + font-size: 9px; padding: 2px 6px; margin-left: 6px; opacity: 0.7; + } + .nostr-btn-sm:hover { opacity: 1; } + .nostr-pubkey { + font-size: 11px; color: #aaddff; margin-right: 6px; + letter-spacing: 0.5px; + } + #session-hud { display: none; color: #22aa66; @@ -591,6 +610,8 @@ Balance: -- sats ⚡ Top Up + +
OFFLINE
diff --git a/the-matrix/js/nostr-identity.js b/the-matrix/js/nostr-identity.js index b17b78a..d130294 100644 --- a/the-matrix/js/nostr-identity.js +++ b/the-matrix/js/nostr-identity.js @@ -42,6 +42,7 @@ export async function initNostrIdentity(apiBase = '/api') { _pubkey = await window.nostr.getPublicKey(); _useNip07 = true; _canSign = true; + _saveDiscoveredKeypair(_pubkey, null); // Store pubkey in LS even if NIP-07 console.info('[nostr] Using NIP-07 extension, pubkey:', _pubkey.slice(0, 8) + '…'); } catch (err) { console.warn('[nostr] NIP-07 getPublicKey failed, will use local keypair', err); @@ -86,6 +87,18 @@ export function getPubkey() { return _pubkey; } export function getNostrToken() { return _isTokenValid() ? _token : null; } export function hasIdentity() { return !!_pubkey; } +export function disconnectNostrIdentity() { + _pubkey = null; + _token = null; + _tokenExp = 0; + _useNip07 = false; + _canSign = false; + localStorage.removeItem(LS_KEYPAIR_KEY); + localStorage.removeItem(LS_TOKEN_KEY); + window.dispatchEvent(new CustomEvent('nostr:identity-disconnected')); + console.info('[nostr] identity disconnected'); +} + /** * getOrRefreshToken — returns a valid token, refreshing if necessary. * Returns null if no identity is established. @@ -197,6 +210,7 @@ export function showIdentityPrompt(apiBase = '/api') { _pubkey = await window.nostr.getPublicKey(); _useNip07 = true; _canSign = true; + _saveDiscoveredKeypair(_pubkey, null); // Store pubkey in LS even if NIP-07 } catch { return; } } else { // Generate + store keypair (user consented by clicking) diff --git a/the-matrix/js/ui.js b/the-matrix/js/ui.js index 218398b..801482a 100644 --- a/the-matrix/js/ui.js +++ b/the-matrix/js/ui.js @@ -1,7 +1,7 @@ import { sendVisitorMessage } from './websocket.js'; import { classify } from './edge-worker-client.js'; import { setMood, setSpeechBubble } from './agents.js'; -import { getOrRefreshToken } from './nostr-identity.js'; +import { getOrRefreshToken, getPubkey, disconnectNostrIdentity, showIdentityPrompt } from './nostr-identity.js'; const $fps = document.getElementById('fps'); const $activeJobs = document.getElementById('active-jobs'); @@ -180,6 +180,89 @@ export function hideCostTicker() { $costTicker.style.opacity = '0'; } +// ── Nostr identity UI ───────────────────────────────────────────────────────── + +let _nostrStatusEl = null; +let _connectNostrBtn = null; +let _disconnectNostrBtn = null; +let _nostrPubkeyDisplay = null; +let _getAlbyBtn = null; + +export function initNostrIdentityUI() { + _nostrStatusEl = document.getElementById('nostr-identity-status'); + if (!_nostrStatusEl) return; + + _nostrStatusEl.innerHTML = ` + + + + + `; + + _connectNostrBtn = document.getElementById('connect-nostr-btn'); + _disconnectNostrBtn = document.getElementById('disconnect-nostr-btn'); + _nostrPubkeyDisplay = document.getElementById('nostr-pubkey-display'); + _getAlbyBtn = document.getElementById('get-alby-btn'); + + if (_connectNostrBtn) { + _connectNostrBtn.addEventListener('click', () => { + showIdentityPrompt('/api'); + }); + } + + if (_disconnectNostrBtn) { + _disconnectNostrBtn.addEventListener('click', () => { + disconnectNostrIdentity(); + _updateNostrIdentityUI(null); + }); + } + + window.addEventListener('nostr:identity-ready', e => { + _updateNostrIdentityUI(e.detail.pubkey); + }); + + window.addEventListener('nostr:identity-disconnected', () => { + _updateNostrIdentityUI(null); + }); + + _updateNostrIdentityUI(getPubkey()); +} + +function _updateNostrIdentityUI(pubkey) { + const hasNip07 = typeof window !== 'undefined' && !!window.nostr; + + if (pubkey) { + const formattedPubkey = pubkey.slice(0, 8) + '…' + pubkey.slice(-4); + if (_nostrPubkeyDisplay) { + _nostrPubkeyDisplay.textContent = `⚡ ${formattedPubkey}`; + _nostrPubkeyDisplay.style.display = 'inline-block'; + } + if (_connectNostrBtn) _connectNostrBtn.style.display = 'none'; + if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'inline-block'; + if (_getAlbyBtn) _getAlbyBtn.style.display = 'none'; + } else { + if (_nostrPubkeyDisplay) _nostrPubkeyDisplay.style.display = 'none'; + if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'none'; + + if (hasNip07) { + if (_connectNostrBtn) { + _connectNostrBtn.textContent = '⚡ Connect Nostr'; + _connectNostrBtn.style.display = 'inline-block'; + } + if (_getAlbyBtn) _getAlbyBtn.style.display = 'none'; + } else { + if (_connectNostrBtn) _connectNostrBtn.style.display = 'none'; + if (_getAlbyBtn) { + _getAlbyBtn.textContent = 'Get Alby'; + _getAlbyBtn.style.display = 'inline-block'; + _getAlbyBtn.title = 'Install Alby or another NIP-07 extension to connect your Nostr identity'; + _getAlbyBtn.onclick = () => window.open('https://getalby.com/', '_blank'); + } + } + } +} + + // ── Input bar ───────────────────────────────────────────────────────────────── export function initUI() { @@ -187,6 +270,7 @@ export function initUI() { uiInitialized = true; initInputBar(); initHeatmap(); + initNostrIdentityUI(); } function initInputBar() { diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index ac15b4b..36ea5c2 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -5,6 +5,7 @@ import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTic import { sentiment } from './edge-worker-client.js'; import { setLabelState } from './hud-labels.js'; import { createJobIndicator, dissolveJobIndicator } from './effects.js'; +import { getPubkey } from './nostr-identity.js'; function resolveWsUrl() { const explicit = import.meta.env.VITE_WS_URL; @@ -46,7 +47,8 @@ function connect() { ws.onopen = () => { connectionState = 'connected'; clearTimeout(reconnectTimer); - send({ type: 'visitor_enter', visitorId, visitorName: 'visitor' }); + const npub = getPubkey(); + send({ type: 'visitor_enter', visitorId, visitorName: 'visitor', npub }); }; ws.onmessage = event => {