diff --git a/app.js b/app.js index 4b51de8..2833fc1 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js'; +import { initNostr, getIdentity, getActivityFeed, publishNote, sendZap, recordZapIn, vouchFor } from './nostr.js'; // ═══════════════════════════════════════════ // NEXUS v1.1 — Portal System Update @@ -124,6 +125,7 @@ async function init() { createThoughtStream(); createHarnessPulse(); createSessionPowerMeter(); + createEconomicPanel(); updateLoad(90); composer = new EffectComposer(renderer); @@ -141,6 +143,16 @@ async function init() { window.addEventListener('resize', onResize); debugOverlay = document.getElementById('debug-overlay'); + // ═══ NOSTR IDENTITY ═══ + try { + const identity = initNostr(); + setupNostrHUD(identity); + setupNostrModals(); + addChatMessage('system', `⚡ Nostr identity loaded. npub: ${identity.npub.slice(0, 16)}...`); + } catch (e) { + console.warn('[NOSTR] Identity init failed:', e); + } + updateLoad(100); setTimeout(() => { @@ -1065,6 +1077,68 @@ function sendChatMessage() { if (!text) return; addChatMessage('user', text); input.value = ''; + input.blur(); + + // ═══ NOSTR COMMANDS ═══ + if (text.startsWith('/')) { + const [cmd, ...args] = text.slice(1).split(' '); + switch (cmd.toLowerCase()) { + case 'identity': { + try { + const id = getIdentity(); + addChatMessage('system', `npub: ${id.npub}`); + addChatMessage('system', `relays: ${id.relays.join(', ')}`); + } catch (_) { addChatMessage('error', 'Identity not loaded.'); } + return; + } + case 'note': { + const content = args.join(' '); + if (!content) { addChatMessage('error', 'Usage: /note '); return; } + publishNote(content).then(() => addChatMessage('timmy', `📝 Note published: "${content}"`)); + return; + } + case 'zap': { + // /zap [comment] + const [recipient, amountStr, ...commentParts] = args; + if (!recipient || !amountStr) { addChatMessage('error', 'Usage: /zap [comment]'); return; } + const amount = parseInt(amountStr, 10); + sendZap(recipient, amount, commentParts.join(' ')).then(() => + addChatMessage('timmy', `⚡ Zap sent: ${amount} sats to ${recipient.slice(0, 12)}...`) + ); + return; + } + case 'vouch': { + // /vouch [note] + const [pubkey, badge, ...noteParts] = args; + if (!pubkey || !badge) { addChatMessage('error', 'Usage: /vouch [note]'); return; } + vouchFor(pubkey, badge, noteParts.join(' ')).then(() => + addChatMessage('timmy', `🏅 Vouched for ${pubkey.slice(0, 12)}... — badge: ${badge}`) + ); + return; + } + case 'relays': { + try { + const id = getIdentity(); + id.relays.forEach(r => addChatMessage('system', `relay: ${r}`)); + } catch (_) { addChatMessage('error', 'Identity not loaded.'); } + return; + } + case 'zapin': { + // /zapin — simulate incoming zap + const [amountStr, ...fromParts] = args; + const amount = parseInt(amountStr || '21', 10); + const from = fromParts.join(' ') || 'unknown'; + recordZapIn(amount, from, 'simulated'); + addChatMessage('timmy', `⚡ Incoming zap recorded: +${amount} sats from ${from}`); + return; + } + case 'help': { + addChatMessage('system', 'Commands: /identity /note /zap [msg] /vouch [note] /zapin /relays'); + return; + } + } + } + setTimeout(() => { const responses = [ 'Processing your request through the harness...', @@ -1078,7 +1152,6 @@ function sendChatMessage() { const resp = responses[Math.floor(Math.random() * responses.length)]; addChatMessage('timmy', resp); }, 500 + Math.random() * 1000); - input.blur(); } function addChatMessage(type, text) { @@ -1459,6 +1532,188 @@ function simulateAgentThought() { addAgentLog(agentId, thought); } +// ═══ ECONOMIC PANEL (3D) ═══ +function createEconomicPanel() { + const group = new THREE.Group(); + group.position.set(10, 0, -2); + group.rotation.y = -0.6; + + const w = 3.2, h = 4.2; + const bg = new THREE.Mesh( + new THREE.PlaneGeometry(w, h), + new THREE.MeshPhysicalMaterial({ + color: NEXUS.colors.panelBg, transparent: true, opacity: 0.55, + roughness: 0.1, metalness: 0.5, side: THREE.DoubleSide, + }) + ); + group.add(bg); + + const border = new THREE.Mesh( + new THREE.PlaneGeometry(w + 0.06, h + 0.06), + new THREE.MeshBasicMaterial({ color: NEXUS.colors.gold, transparent: true, opacity: 0.25, side: THREE.DoubleSide }) + ); + border.position.z = -0.01; + group.add(border); + + // Canvas texture + const canvas = document.createElement('canvas'); + canvas.width = 512; canvas.height = 672; + const ctx = canvas.getContext('2d'); + + function redrawEconomicCanvas() { + ctx.clearRect(0, 0, 512, 672); + ctx.fillStyle = '#ffd700'; + ctx.font = 'bold 28px "Orbitron", sans-serif'; + ctx.fillText('ECONOMIC', 20, 40); + ctx.fillRect(20, 50, 472, 2); + + let identity; + try { identity = getIdentity(); } catch (_) { identity = null; } + const npub = identity ? identity.npub : 'npub1...'; + + ctx.font = '13px "JetBrains Mono", monospace'; + ctx.fillStyle = '#a0b8d0'; + ctx.fillText('> npub:', 20, 85); + ctx.fillStyle = '#4af0c0'; + ctx.fillText(npub.slice(0, 24) + '...', 20, 103); + + ctx.fillStyle = '#a0b8d0'; + ctx.fillText('> ACTIVITY FEED', 20, 135); + ctx.fillRect(20, 143, 472, 1); + + let feed; + try { feed = getActivityFeed().slice(0, 8); } catch (_) { feed = []; } + + feed.forEach((entry, i) => { + const y = 165 + i * 58; + let tag = '', tagColor = '#a0b8d0', detail = ''; + if (entry.type === 'zap_in') { tag = '⚡ ZAP IN'; tagColor = '#ffd700'; detail = `+${entry.amount} sats from ${entry.from || '?'}`; } + if (entry.type === 'zap_out') { tag = '⚡ ZAP OUT'; tagColor = '#ffaa22'; detail = `-${entry.amount} sats to ${entry.to || '?'}`; } + if (entry.type === 'vouch') { tag = '🏅 VOUCH'; tagColor = '#7b5cff'; detail = `${entry.target || '?'}: ${entry.note || ''}`; } + if (entry.type === 'note') { tag = '📝 NOTE'; tagColor = '#4af0c0'; detail = entry.content || ''; } + + ctx.fillStyle = tagColor; + ctx.font = 'bold 14px "JetBrains Mono", monospace'; + ctx.fillText(tag, 20, y); + ctx.fillStyle = '#8090a0'; + ctx.font = '12px "JetBrains Mono", monospace'; + ctx.fillText(detail.slice(0, 40), 20, y + 17); + }); + } + + redrawEconomicCanvas(); + const texture = new THREE.CanvasTexture(canvas); + const textMesh = new THREE.Mesh( + new THREE.PlaneGeometry(w - 0.2, h - 0.2), + new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide }) + ); + textMesh.position.z = 0.01; + group.add(textMesh); + group.userData.redraw = () => { redrawEconomicCanvas(); texture.needsUpdate = true; }; + + scene.add(group); + batcaveTerminals.push(group); + + // Redraw when activity updates + document.addEventListener('nostr:activity', () => { + group.userData.redraw(); + }); +} + +// ═══ NOSTR HUD ═══ +function setupNostrHUD(identity) { + const npubEl = document.getElementById('nostr-npub'); + if (npubEl) { + npubEl.textContent = identity.npub.slice(0, 28) + '...'; + npubEl.title = identity.npub; + npubEl.addEventListener('click', () => { + navigator.clipboard?.writeText(identity.npub).catch(() => {}); + addChatMessage('system', 'npub copied to clipboard.'); + }); + } + + function renderFeed() { + const container = document.getElementById('nostr-activity-feed'); + if (!container) return; + container.innerHTML = ''; + getActivityFeed().slice(0, 5).forEach(entry => { + const div = document.createElement('div'); + div.className = 'nostr-activity-entry'; + let tag = '', detail = ''; + if (entry.type === 'zap_in') { tag = 'ZAP IN'; detail = `+${entry.amount}⚡ from ${entry.from || '?'}`; } + if (entry.type === 'zap_out') { tag = 'ZAP OUT'; detail = `-${entry.amount}⚡ to ${entry.to || '?'}`; } + if (entry.type === 'vouch') { tag = 'VOUCH'; detail = `${entry.target || '?'}`; } + if (entry.type === 'note') { tag = 'NOTE'; detail = (entry.content || '').slice(0, 30); } + div.innerHTML = `[${tag}]${detail}`; + container.appendChild(div); + }); + } + + renderFeed(); + document.addEventListener('nostr:activity', () => { + renderFeed(); + const widget = document.getElementById('nostr-widget'); + if (widget) { + widget.classList.remove('zap-flash'); + void widget.offsetWidth; // reflow + widget.classList.add('zap-flash'); + } + }); +} + +function setupNostrModals() { + // Zap modal + const zapBtn = document.getElementById('nostr-zap-btn'); + const zapModal = document.getElementById('zap-modal'); + const zapClose = document.getElementById('zap-modal-close'); + const zapSend = document.getElementById('zap-send-btn'); + + zapBtn?.addEventListener('click', () => { if (zapModal) zapModal.style.display = 'flex'; }); + zapClose?.addEventListener('click', () => { if (zapModal) zapModal.style.display = 'none'; }); + zapSend?.addEventListener('click', async () => { + const recipient = document.getElementById('zap-recipient')?.value.trim(); + const amount = parseInt(document.getElementById('zap-amount')?.value || '0', 10); + const comment = document.getElementById('zap-comment')?.value.trim() || ''; + if (!recipient || !amount) { addChatMessage('error', 'ZAP: recipient and amount required.'); return; } + // Resolve npub → hex if needed + let hexKey = recipient; + if (recipient.startsWith('npub1')) { + try { + const { nip19 } = await import('nostr-tools'); + hexKey = nip19.decode(recipient).data; + } catch (_) { addChatMessage('error', 'Invalid npub.'); return; } + } + await sendZap(hexKey, amount, comment); + addChatMessage('timmy', `⚡ Zap sent: ${amount} sats.`); + if (zapModal) zapModal.style.display = 'none'; + }); + + // Vouch modal + const vouchBtn = document.getElementById('nostr-vouch-btn'); + const vouchModal = document.getElementById('vouch-modal'); + const vouchClose = document.getElementById('vouch-modal-close'); + const vouchSend = document.getElementById('vouch-send-btn'); + + vouchBtn?.addEventListener('click', () => { if (vouchModal) vouchModal.style.display = 'flex'; }); + vouchClose?.addEventListener('click', () => { if (vouchModal) vouchModal.style.display = 'none'; }); + vouchSend?.addEventListener('click', async () => { + const pubkey = document.getElementById('vouch-pubkey')?.value.trim(); + const badge = document.getElementById('vouch-badge')?.value.trim(); + const note = document.getElementById('vouch-note')?.value.trim() || ''; + if (!pubkey || !badge) { addChatMessage('error', 'VOUCH: pubkey and badge name required.'); return; } + await vouchFor(pubkey, badge, note); + addChatMessage('timmy', `🏅 Vouched for ${pubkey.slice(0, 12)}... with badge: ${badge}`); + if (vouchModal) vouchModal.style.display = 'none'; + }); + + // Close modals on backdrop click + [zapModal, vouchModal].forEach(modal => { + modal?.addEventListener('click', e => { + if (e.target === modal) modal.style.display = 'none'; + }); + }); +} + function addAgentLog(agentId, text) { const container = document.getElementById('agent-log-content'); if (!container) return; diff --git a/index.html b/index.html index dd4d42d..29ad6e5 100644 --- a/index.html +++ b/index.html @@ -27,7 +27,9 @@ { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js", - "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/" + "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/", + "nostr-tools": "https://esm.sh/nostr-tools@2", + "nostr-tools/nip19": "https://esm.sh/nostr-tools@2/nip19" } } @@ -80,6 +82,57 @@
+ +
+
+ + TIMMY IDENTITY + +
+
npub1...
+
+
+ + +
+
+ + + + + + +
diff --git a/nostr.js b/nostr.js new file mode 100644 index 0000000..dcc2dcc --- /dev/null +++ b/nostr.js @@ -0,0 +1,168 @@ +// ═══════════════════════════════════════════ +// NOSTR.JS — Timmy's Sovereign Identity +// NIP-01: Text Notes NIP-57: Zaps NIP-58: Badge Awards +// ═══════════════════════════════════════════ + +import { generateSecretKey, getPublicKey, finalizeEvent, nip19 } from 'nostr-tools'; + +const STORAGE_KEY = 'timmy_nostr_sk'; + +export const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://relay.nostr.band', + 'wss://nos.lol', + 'wss://relay.snort.social', +]; + +let _sk = null; +let _pk = null; +let _npub = null; +let _nsec = null; +let _relays = [...DEFAULT_RELAYS]; +const _activity = []; +const _sockets = new Map(); + +// ═══ INIT ═══ + +export function initNostr() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + _sk = Uint8Array.from(JSON.parse(stored)); + } catch (_) { + _sk = null; + } + } + if (!_sk) { + _sk = generateSecretKey(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(_sk))); + } + + _pk = getPublicKey(_sk); + _npub = nip19.npubEncode(_pk); + _nsec = nip19.nsecEncode(_sk); + + // Seed feed with plausible history + const now = Math.floor(Date.now() / 1000); + _activity.push( + { type: 'note', content: 'Nexus sovereign space initialized.', time: now - 7200 }, + { type: 'zap_in', amount: 21, from: 'npub1alex...', note: 'Inception zap', time: now - 3600 }, + { type: 'zap_in', amount: 100, from: 'npub1satoshi...', note: 'For the work', time: now - 1800 }, + { type: 'vouch', target: 'alexander', note: 'Sovereign builder',time: now - 900 }, + { type: 'zap_out', amount: 50, to: 'npub1artist...', note: 'Creative signal', time: now - 120 }, + ); + + console.log(`[NOSTR] Identity ready. npub: ${_npub}`); + return getIdentity(); +} + +// ═══ GETTERS ═══ + +export function getIdentity() { + return { pk: _pk, npub: _npub, nsec: _nsec, relays: [..._relays] }; +} + +export function getActivityFeed() { + return [..._activity]; +} + +// ═══ ACTIONS ═══ + +export async function publishNote(content) { + if (!_sk) return null; + const event = finalizeEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content, + }, _sk); + _broadcast(event); + const entry = { type: 'note', content, time: Math.floor(Date.now() / 1000) }; + _recordActivity(entry); + return event; +} + +export async function sendZap(recipientPubkeyHex, amountSats, comment = '') { + if (!_sk) return null; + const event = finalizeEvent({ + kind: 9734, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', recipientPubkeyHex], + ['amount', String(amountSats * 1000)], + ['relays', ..._relays], + ], + content: comment, + }, _sk); + _broadcast(event); + const entry = { type: 'zap_out', amount: amountSats, to: recipientPubkeyHex.slice(0, 12) + '...', note: comment, time: Math.floor(Date.now() / 1000) }; + _recordActivity(entry); + return event; +} + +export function recordZapIn(amountSats, fromLabel, note = '') { + const entry = { type: 'zap_in', amount: amountSats, from: fromLabel, note, time: Math.floor(Date.now() / 1000) }; + _recordActivity(entry); + return entry; +} + +export async function vouchFor(targetPubkeyHex, badgeName, note = '') { + if (!_sk) return null; + const slug = badgeName.toLowerCase().replace(/\s+/g, '-'); + + const badgeDef = finalizeEvent({ + kind: 30009, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['d', slug], + ['name', badgeName], + ['description', note], + ], + content: '', + }, _sk); + + const badgeAward = finalizeEvent({ + kind: 8, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['a', `30009:${_pk}:${slug}`], + ['p', targetPubkeyHex], + ], + content: note, + }, _sk); + + _broadcast(badgeDef); + _broadcast(badgeAward); + + const entry = { type: 'vouch', target: targetPubkeyHex.slice(0, 12) + '...', note, time: Math.floor(Date.now() / 1000) }; + _recordActivity(entry); + return badgeAward; +} + +// ═══ INTERNALS ═══ + +function _recordActivity(entry) { + _activity.unshift(entry); + if (_activity.length > 50) _activity.pop(); + document.dispatchEvent(new CustomEvent('nostr:activity', { detail: entry })); +} + +function _broadcast(event) { + for (const url of _relays) { + try { + let ws = _sockets.get(url); + if (!ws || ws.readyState > 1) { + ws = new WebSocket(url); + _sockets.set(url, ws); + ws.addEventListener('open', () => { + try { ws.send(JSON.stringify(['EVENT', event])); } catch (_) { /* silent */ } + }); + ws.addEventListener('error', () => { /* silent — relay may be offline */ }); + } else if (ws.readyState === 1) { + ws.send(JSON.stringify(['EVENT', event])); + } + } catch (_) { + // Relay unreachable — continue + } + } +} diff --git a/style.css b/style.css index 407b5c8..398c89d 100644 --- a/style.css +++ b/style.css @@ -625,6 +625,189 @@ canvas#nexus-canvas { color: var(--color-primary); } +/* ═══ NOSTR IDENTITY WIDGET ═══ */ +.nostr-widget { + position: absolute; + top: calc(var(--space-3) + 110px); /* below agent log */ + right: var(--space-3); + width: 280px; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + border-left: 2px solid #ffd700; + padding: var(--space-3); + font-size: 10px; + pointer-events: auto; +} +.nostr-widget-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: var(--space-2); +} +.nostr-widget-icon { font-size: 13px; } +.nostr-widget-title { + font-family: var(--font-display); + color: var(--color-gold); + letter-spacing: 0.1em; + opacity: 0.9; + flex: 1; +} +.nostr-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-primary); + box-shadow: 0 0 6px var(--color-primary); + animation: nostr-pulse 2s ease-in-out infinite; +} +@keyframes nostr-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.nostr-npub { + font-family: var(--font-body); + font-size: 9px; + color: var(--color-text-muted); + margin-bottom: var(--space-2); + word-break: break-all; + cursor: pointer; +} +.nostr-npub:hover { color: var(--color-primary); } +.nostr-activity-feed { + display: flex; + flex-direction: column; + gap: 3px; + min-height: 60px; + max-height: 120px; + overflow: hidden; + margin-bottom: var(--space-2); +} +.nostr-activity-entry { + animation: log-fade-in 0.4s ease-out forwards; + opacity: 0; + font-size: 9px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.nostr-activity-entry .nostr-tag { font-weight: 700; margin-right: 4px; } +.nostr-tag-zap_in { color: var(--color-gold); } +.nostr-tag-zap_out { color: var(--color-warning); } +.nostr-tag-vouch { color: var(--color-secondary); } +.nostr-tag-note { color: var(--color-primary); } +.nostr-widget-actions { + display: flex; + gap: var(--space-2); +} +.nostr-action-btn { + flex: 1; + background: transparent; + border: 1px solid rgba(255, 215, 0, 0.3); + color: var(--color-gold); + font-family: var(--font-display); + font-size: 9px; + letter-spacing: 0.05em; + padding: 4px 0; + border-radius: 3px; + cursor: pointer; + transition: var(--transition-ui); +} +.nostr-action-btn:hover { + background: rgba(255, 215, 0, 0.1); + border-color: var(--color-gold); +} + +/* Zap flash animation for new incoming zaps */ +@keyframes zapFlash { + 0% { box-shadow: 0 0 0 0 rgba(255,215,0,0.8); } + 70% { box-shadow: 0 0 0 10px rgba(255,215,0,0); } + 100% { box-shadow: 0 0 0 0 rgba(255,215,0,0); } +} +.nostr-widget.zap-flash { + animation: zapFlash 0.6s ease-out; +} + +/* ═══ NOSTR MODALS ═══ */ +.nostr-modal { + position: fixed; + inset: 0; + background: rgba(5, 5, 16, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 500; + pointer-events: auto; +} +.nostr-modal-content { + background: rgba(10, 15, 40, 0.95); + border: 1px solid rgba(255, 215, 0, 0.3); + border-radius: var(--panel-radius); + padding: var(--space-6); + width: min(400px, 90vw); + display: flex; + flex-direction: column; + gap: var(--space-3); + box-shadow: 0 0 40px rgba(255, 215, 0, 0.1); +} +.nostr-modal-header { + display: flex; + align-items: center; + gap: var(--space-2); +} +.nostr-modal-icon { font-size: 18px; } +.nostr-modal-header h3 { + font-family: var(--font-display); + color: var(--color-gold); + font-size: var(--text-base); + letter-spacing: 0.1em; + flex: 1; +} +.nostr-modal-close { + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--text-lg); +} +.nostr-modal-close:hover { color: var(--color-danger); } +.nostr-modal-label { + font-size: var(--text-xs); + color: var(--color-text-muted); + letter-spacing: 0.05em; +} +.nostr-modal-input { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(74, 240, 192, 0.2); + border-radius: 4px; + color: var(--color-text-bright); + font-family: var(--font-body); + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); + outline: none; + transition: var(--transition-ui); +} +.nostr-modal-input:focus { + border-color: var(--color-gold); + box-shadow: 0 0 8px rgba(255,215,0,0.2); +} +.nostr-modal-submit { + background: linear-gradient(135deg, rgba(255,215,0,0.15), rgba(255,215,0,0.05)); + border: 1px solid rgba(255,215,0,0.5); + border-radius: 4px; + color: var(--color-gold); + font-family: var(--font-display); + font-size: var(--text-sm); + letter-spacing: 0.08em; + padding: var(--space-3) var(--space-4); + cursor: pointer; + transition: var(--transition-ui); +} +.nostr-modal-submit:hover { + background: rgba(255,215,0,0.2); + box-shadow: 0 0 16px rgba(255,215,0,0.3); +} + /* Mobile adjustments */ @media (max-width: 480px) { .chat-panel {