[claude] Timmy Nostr identity, zap-out, vouching (#13) #29
337
app.js
337
app.js
@@ -3,6 +3,11 @@ 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,
|
||||
createZapRequest, createVouch, createNote, createProfileEvent,
|
||||
broadcastEvent, addActivity, getRecentActivity, RELAYS,
|
||||
} from './nostr.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1 — Timmy's Sovereign Home
|
||||
@@ -34,6 +39,8 @@ let debugOverlay;
|
||||
let frameCount = 0, lastFPSTime = 0, fps = 0;
|
||||
let chatOpen = true;
|
||||
let loadProgress = 0;
|
||||
let nostrIdentity = null;
|
||||
let economicPanelData = null;
|
||||
|
||||
// ═══ INIT ═══
|
||||
function init() {
|
||||
@@ -77,6 +84,7 @@ function init() {
|
||||
createDustParticles();
|
||||
updateLoad(85);
|
||||
createAmbientStructures();
|
||||
createEconomicPanel();
|
||||
updateLoad(90);
|
||||
|
||||
// Post-processing
|
||||
@@ -115,6 +123,37 @@ function init() {
|
||||
setTimeout(() => { document.getElementById('loading-screen').remove(); }, 900);
|
||||
}, 600);
|
||||
|
||||
// Init Nostr identity (async — don't block scene start)
|
||||
initNostr().then(identity => {
|
||||
nostrIdentity = identity;
|
||||
if (identity?.npub) {
|
||||
updateNostrHUD(identity);
|
||||
addChatMessage('system', `Nostr identity loaded: ${identity.npub.slice(0, 20)}…`);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[Nostr] Could not load identity:', err);
|
||||
addChatMessage('system', 'Nostr: identity unavailable (relay-only mode)');
|
||||
});
|
||||
|
||||
// Listen for economic activity events
|
||||
window.addEventListener('nostr:activity', (e) => {
|
||||
const { type, data } = e.detail;
|
||||
if (data?.label) {
|
||||
addChatMessage('system', data.label);
|
||||
}
|
||||
refreshEconomicPanel();
|
||||
// Flash the Nostr HUD panel on incoming zaps
|
||||
if (type === 'zap_in') {
|
||||
const panel = document.getElementById('nostr-panel');
|
||||
if (panel) {
|
||||
panel.classList.remove('zap-received');
|
||||
void panel.offsetWidth; // force reflow to restart animation
|
||||
panel.classList.add('zap-received');
|
||||
setTimeout(() => panel.classList.remove('zap-received'), 700);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start loop
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
@@ -844,6 +883,13 @@ function sendChatMessage() {
|
||||
addChatMessage('user', text);
|
||||
input.value = '';
|
||||
|
||||
// Handle Nostr commands
|
||||
if (text.startsWith('/')) {
|
||||
handleNostrCommand(text);
|
||||
input.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate Timmy response
|
||||
setTimeout(() => {
|
||||
const responses = [
|
||||
@@ -862,6 +908,99 @@ function sendChatMessage() {
|
||||
input.blur();
|
||||
}
|
||||
|
||||
async function handleNostrCommand(text) {
|
||||
const parts = text.slice(1).split(' ');
|
||||
const cmd = parts[0].toLowerCase();
|
||||
|
||||
if (cmd === 'identity' || cmd === 'id') {
|
||||
const id = getIdentity();
|
||||
if (id?.npub) {
|
||||
addChatMessage('timmy', `My npub: ${id.npub}`);
|
||||
addChatMessage('timmy', `Pubkey: ${id.pubkey.slice(0, 16)}…`);
|
||||
} else {
|
||||
addChatMessage('error', 'Nostr identity not loaded yet.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'zap') {
|
||||
// /zap <npub_or_pubkey> <sats> [comment]
|
||||
const target = parts[1];
|
||||
const sats = parseInt(parts[2]) || 21;
|
||||
const comment = parts.slice(3).join(' ');
|
||||
if (!target) {
|
||||
addChatMessage('error', 'Usage: /zap <pubkey> <sats> [comment]');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const event = createZapRequest(target, sats * 1000, comment);
|
||||
addChatMessage('timmy', `⚡ Zap request created for ${sats} sats. Broadcasting…`);
|
||||
const results = await broadcastEvent(event);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
addChatMessage('timmy', `Zap sent to ${ok}/${results.length} relays.`);
|
||||
refreshEconomicPanel();
|
||||
} catch (err) {
|
||||
addChatMessage('error', `Zap failed: ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'vouch') {
|
||||
// /vouch <pubkey> <badge> [reason]
|
||||
const target = parts[1];
|
||||
const badge = parts[2] || 'Trusted Builder';
|
||||
const reason = parts.slice(3).join(' ') || 'Vouched by Timmy from the Nexus';
|
||||
if (!target) {
|
||||
addChatMessage('error', 'Usage: /vouch <pubkey> <badge-name> [reason]');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const event = createVouch(target, badge, reason);
|
||||
addChatMessage('timmy', `🏅 Vouch created for badge "${badge}". Broadcasting…`);
|
||||
const results = await broadcastEvent(event);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
addChatMessage('timmy', `Vouch sent to ${ok}/${results.length} relays.`);
|
||||
refreshEconomicPanel();
|
||||
} catch (err) {
|
||||
addChatMessage('error', `Vouch failed: ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'note') {
|
||||
// /note <text>
|
||||
const content = parts.slice(1).join(' ');
|
||||
if (!content) {
|
||||
addChatMessage('error', 'Usage: /note <message>');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const event = createNote(content);
|
||||
addChatMessage('timmy', `📝 Note signed. Broadcasting…`);
|
||||
const results = await broadcastEvent(event);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
addChatMessage('timmy', `Note published to ${ok}/${results.length} relays.`);
|
||||
addActivity('note', { label: `📝 Published note to relays`, ts: Math.floor(Date.now() / 1000) });
|
||||
refreshEconomicPanel();
|
||||
} catch (err) {
|
||||
addChatMessage('error', `Note failed: ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'relays') {
|
||||
addChatMessage('timmy', `Relays: ${RELAYS.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === 'help') {
|
||||
addChatMessage('timmy', 'Nostr commands: /identity, /zap <pubkey> <sats> [msg], /vouch <pubkey> <badge> [reason], /note <text>, /relays');
|
||||
return;
|
||||
}
|
||||
|
||||
addChatMessage('error', `Unknown command: /${cmd} — try /help`);
|
||||
}
|
||||
|
||||
function addChatMessage(type, text) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
const div = document.createElement('div');
|
||||
@@ -912,6 +1051,11 @@ function gameLoop() {
|
||||
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
|
||||
});
|
||||
|
||||
// Animate economic panel scanline
|
||||
if (economicPanelData?.scanMat?.uniforms) {
|
||||
economicPanelData.scanMat.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
// Animate portal
|
||||
if (portalMesh) {
|
||||
portalMesh.rotation.z = elapsed * 0.3;
|
||||
@@ -970,6 +1114,199 @@ function gameLoop() {
|
||||
renderer.info.reset();
|
||||
}
|
||||
|
||||
// ═══ NOSTR / ECONOMIC PANEL ═══
|
||||
|
||||
let _econPanelCanvas = null;
|
||||
let _econPanelCtx = null;
|
||||
let _econPanelTex = null;
|
||||
|
||||
function createEconomicPanel() {
|
||||
const group = new THREE.Group();
|
||||
// Place on the left side of the scene, facing inward toward the center
|
||||
group.position.set(-16, 0, -8);
|
||||
group.rotation.y = 0.55;
|
||||
|
||||
const w = 4.5, h = 5.5;
|
||||
|
||||
// Background
|
||||
const bgMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x000510,
|
||||
transparent: true,
|
||||
opacity: 0.75,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
group.add(new THREE.Mesh(new THREE.PlaneGeometry(w, h), bgMat));
|
||||
|
||||
// Border
|
||||
const borderMat = new THREE.LineBasicMaterial({
|
||||
color: NEXUS.colors.gold,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
group.add(new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, h)),
|
||||
borderMat,
|
||||
));
|
||||
|
||||
// Canvas texture for text content
|
||||
_econPanelCanvas = document.createElement('canvas');
|
||||
_econPanelCanvas.width = 576;
|
||||
_econPanelCanvas.height = 704;
|
||||
_econPanelCtx = _econPanelCanvas.getContext('2d');
|
||||
|
||||
_econPanelTex = new THREE.CanvasTexture(_econPanelCanvas);
|
||||
_econPanelTex.minFilter = THREE.LinearFilter;
|
||||
|
||||
const textMat = new THREE.MeshBasicMaterial({
|
||||
map: _econPanelTex,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const textMesh = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.95, h * 0.95), textMat);
|
||||
textMesh.position.z = 0.01;
|
||||
group.add(textMesh);
|
||||
|
||||
// Scanline overlay (reuse same shader as batcave terminals)
|
||||
const scanMat = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(NEXUS.colors.gold) },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float scanline = sin(vUv.y * 200.0 + uTime * 2.0) * 0.5 + 0.5;
|
||||
float flicker = 0.97 + 0.03 * sin(uTime * 13.0);
|
||||
float alpha = scanline * 0.04 * flicker;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
const scanMesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), scanMat);
|
||||
scanMesh.position.z = 0.02;
|
||||
group.add(scanMesh);
|
||||
|
||||
scene.add(group);
|
||||
economicPanelData = { group, scanMat };
|
||||
|
||||
// Initial paint
|
||||
_paintEconomicPanel([]);
|
||||
}
|
||||
|
||||
function _paintEconomicPanel(feed) {
|
||||
if (!_econPanelCtx) return;
|
||||
const ctx = _econPanelCtx;
|
||||
const cw = _econPanelCanvas.width;
|
||||
const ch = _econPanelCanvas.height;
|
||||
const goldHex = '#' + new THREE.Color(NEXUS.colors.gold).getHexString();
|
||||
|
||||
ctx.clearRect(0, 0, cw, ch);
|
||||
|
||||
// Title
|
||||
ctx.font = 'bold 28px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = goldHex;
|
||||
ctx.fillText('⚡ ECONOMIC', 20, 42);
|
||||
|
||||
// Separator
|
||||
ctx.strokeStyle = goldHex;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(20, 54);
|
||||
ctx.lineTo(cw - 20, 54);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Nostr identity
|
||||
ctx.font = '16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = '#5a6a8a';
|
||||
ctx.fillText('IDENTITY', 20, 80);
|
||||
|
||||
const npub = nostrIdentity?.npub;
|
||||
ctx.font = '18px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = '#a0b8d0';
|
||||
if (npub) {
|
||||
ctx.fillText(npub.slice(0, 14) + '…', 20, 102);
|
||||
ctx.fillText(npub.slice(-12), 20, 122);
|
||||
} else {
|
||||
ctx.fillStyle = '#5a6a8a';
|
||||
ctx.fillText('loading…', 20, 102);
|
||||
}
|
||||
|
||||
// Activity feed
|
||||
ctx.font = '16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = '#5a6a8a';
|
||||
ctx.fillText('RECENT ACTIVITY', 20, 155);
|
||||
|
||||
ctx.strokeStyle = goldHex;
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(20, 162);
|
||||
ctx.lineTo(cw - 20, 162);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const entries = feed.length ? feed : [{ label: 'No activity yet…', type: 'none' }];
|
||||
ctx.font = '17px "JetBrains Mono", monospace';
|
||||
entries.slice(0, 7).forEach((item, i) => {
|
||||
let color = '#a0b8d0';
|
||||
if (item.type === 'zap_in') color = '#ffd700';
|
||||
if (item.type === 'zap_out') color = '#ff8844';
|
||||
if (item.type === 'vouch') color = '#4af0c0';
|
||||
if (item.type === 'none') color = '#3a4a6a';
|
||||
ctx.fillStyle = color;
|
||||
// Wrap long labels
|
||||
const label = item.label || '';
|
||||
const maxW = cw - 40;
|
||||
if (ctx.measureText(label).width > maxW) {
|
||||
ctx.fillText(label.slice(0, 28), 20, 190 + i * 66);
|
||||
ctx.fillText(label.slice(28), 20, 210 + i * 66);
|
||||
} else {
|
||||
ctx.fillText(label, 20, 198 + i * 66);
|
||||
}
|
||||
});
|
||||
|
||||
// Relay status
|
||||
ctx.strokeStyle = goldHex;
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(20, ch - 70);
|
||||
ctx.lineTo(cw - 20, ch - 70);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.font = '16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = '#5a6a8a';
|
||||
ctx.fillText('RELAYS', 20, ch - 52);
|
||||
ctx.fillStyle = '#4af0c0';
|
||||
ctx.fillText(`${RELAYS.length} configured`, 20, ch - 32);
|
||||
|
||||
_econPanelTex.needsUpdate = true;
|
||||
}
|
||||
|
||||
function refreshEconomicPanel() {
|
||||
_paintEconomicPanel(getRecentActivity(7));
|
||||
}
|
||||
|
||||
function updateNostrHUD(identity) {
|
||||
const el = document.getElementById('nostr-npub');
|
||||
if (el && identity?.npub) {
|
||||
el.textContent = identity.npub.slice(0, 20) + '…';
|
||||
}
|
||||
refreshEconomicPanel();
|
||||
}
|
||||
|
||||
// ═══ RESIZE ═══
|
||||
function onResize() {
|
||||
const w = window.innerWidth;
|
||||
|
||||
146
index.html
146
index.html
@@ -99,6 +99,65 @@
|
||||
<div class="hud-controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
</div>
|
||||
|
||||
<!-- Nostr Identity Panel (top-right) -->
|
||||
<div id="nostr-panel" class="nostr-panel">
|
||||
<div class="nostr-panel-header">
|
||||
<span class="nostr-sigil">⚡</span>
|
||||
<span class="nostr-title">NOSTR IDENTITY</span>
|
||||
</div>
|
||||
<div class="nostr-panel-body">
|
||||
<div class="nostr-row">
|
||||
<span class="nostr-label">npub</span>
|
||||
<span id="nostr-npub" class="nostr-value">initializing…</span>
|
||||
</div>
|
||||
<div class="nostr-actions">
|
||||
<button class="nostr-btn nostr-btn-zap" id="btn-open-zap" title="Send a zap">⚡ Zap</button>
|
||||
<button class="nostr-btn nostr-btn-vouch" id="btn-open-vouch" title="Vouch for someone">🏅 Vouch</button>
|
||||
<button class="nostr-btn nostr-btn-identity" id="btn-show-identity" title="Show identity">🔑 ID</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zap Modal -->
|
||||
<div id="zap-modal" class="nostr-modal" style="display:none;">
|
||||
<div class="nostr-modal-box">
|
||||
<div class="nostr-modal-header">
|
||||
<span>⚡ Send Zap</span>
|
||||
<button class="nostr-modal-close" data-modal="zap-modal">✕</button>
|
||||
</div>
|
||||
<div class="nostr-modal-body">
|
||||
<label class="nostr-field-label">Recipient pubkey (hex or npub)</label>
|
||||
<input type="text" id="zap-pubkey" class="nostr-input" placeholder="npub1… or hex pubkey">
|
||||
<label class="nostr-field-label">Amount (sats)</label>
|
||||
<input type="number" id="zap-amount" class="nostr-input" value="21" min="1">
|
||||
<label class="nostr-field-label">Comment (optional)</label>
|
||||
<input type="text" id="zap-comment" class="nostr-input" placeholder="Great work!">
|
||||
<button class="nostr-btn nostr-btn-zap nostr-btn-full" id="btn-send-zap">⚡ Send Zap Request</button>
|
||||
<div id="zap-status" class="nostr-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vouch Modal -->
|
||||
<div id="vouch-modal" class="nostr-modal" style="display:none;">
|
||||
<div class="nostr-modal-box">
|
||||
<div class="nostr-modal-header">
|
||||
<span>🏅 Vouch for Contributor</span>
|
||||
<button class="nostr-modal-close" data-modal="vouch-modal">✕</button>
|
||||
</div>
|
||||
<div class="nostr-modal-body">
|
||||
<label class="nostr-field-label">Recipient pubkey (hex or npub)</label>
|
||||
<input type="text" id="vouch-pubkey" class="nostr-input" placeholder="npub1… or hex pubkey">
|
||||
<label class="nostr-field-label">Badge name</label>
|
||||
<input type="text" id="vouch-badge" class="nostr-input" value="Trusted Builder" placeholder="Trusted Builder">
|
||||
<label class="nostr-field-label">Reason (optional)</label>
|
||||
<input type="text" id="vouch-reason" class="nostr-input" placeholder="Vouched by Timmy from the Nexus">
|
||||
<button class="nostr-btn nostr-btn-vouch nostr-btn-full" id="btn-send-vouch">🏅 Issue Vouch</button>
|
||||
<div id="vouch-status" class="nostr-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click to Enter -->
|
||||
@@ -118,5 +177,92 @@
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
<script type="module">
|
||||
// Nostr modal interaction — wired after app.js imports nostr.js
|
||||
import {
|
||||
createZapRequest, createVouch, broadcastEvent, getIdentity,
|
||||
} from './nostr.js';
|
||||
|
||||
// Helper: resolve pubkey (handle npub or hex)
|
||||
async function resolvePubkey(input) {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith('npub1')) {
|
||||
// Use nostr-tools nip19 decode
|
||||
const { nip19 } = await import('https://esm.sh/nostr-tools@2.3.1?bundle');
|
||||
const decoded = nip19.decode(trimmed);
|
||||
return decoded.data;
|
||||
}
|
||||
return trimmed; // assume hex
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
document.getElementById(id).style.display = 'flex';
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
}
|
||||
function setStatus(id, msg, isError = false) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.className = 'nostr-status ' + (isError ? 'nostr-status-err' : 'nostr-status-ok');
|
||||
}
|
||||
}
|
||||
|
||||
// Open/close buttons
|
||||
document.getElementById('btn-open-zap').addEventListener('click', () => openModal('zap-modal'));
|
||||
document.getElementById('btn-open-vouch').addEventListener('click', () => openModal('vouch-modal'));
|
||||
document.getElementById('btn-show-identity').addEventListener('click', () => {
|
||||
const id = getIdentity();
|
||||
if (id?.npub) alert(`Timmy's Nostr Identity\n\nnpub: ${id.npub}\npubkey: ${id.pubkey}`);
|
||||
else alert('Nostr identity not yet loaded.');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.nostr-modal-close').forEach(btn => {
|
||||
btn.addEventListener('click', () => closeModal(btn.dataset.modal));
|
||||
});
|
||||
document.querySelectorAll('.nostr-modal').forEach(modal => {
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Zap send
|
||||
document.getElementById('btn-send-zap').addEventListener('click', async () => {
|
||||
const pubkeyInput = document.getElementById('zap-pubkey').value;
|
||||
const sats = parseInt(document.getElementById('zap-amount').value) || 21;
|
||||
const comment = document.getElementById('zap-comment').value;
|
||||
if (!pubkeyInput) { setStatus('zap-status', 'Enter a recipient pubkey', true); return; }
|
||||
setStatus('zap-status', 'Signing & broadcasting…');
|
||||
try {
|
||||
const pubkey = await resolvePubkey(pubkeyInput);
|
||||
const event = createZapRequest(pubkey, sats * 1000, comment);
|
||||
const results = await broadcastEvent(event);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
setStatus('zap-status', `✓ Sent to ${ok}/${results.length} relays`);
|
||||
} catch (err) {
|
||||
setStatus('zap-status', `✗ ${err.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Vouch send
|
||||
document.getElementById('btn-send-vouch').addEventListener('click', async () => {
|
||||
const pubkeyInput = document.getElementById('vouch-pubkey').value;
|
||||
const badge = document.getElementById('vouch-badge').value || 'Trusted Builder';
|
||||
const reason = document.getElementById('vouch-reason').value || 'Vouched by Timmy from the Nexus';
|
||||
if (!pubkeyInput) { setStatus('vouch-status', 'Enter a recipient pubkey', true); return; }
|
||||
setStatus('vouch-status', 'Signing & broadcasting…');
|
||||
try {
|
||||
const pubkey = await resolvePubkey(pubkeyInput);
|
||||
const event = createVouch(pubkey, badge, reason);
|
||||
const results = await broadcastEvent(event);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
setStatus('vouch-status', `✓ Badge awarded on ${ok}/${results.length} relays`);
|
||||
} catch (err) {
|
||||
setStatus('vouch-status', `✗ ${err.message}`, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
316
nostr.js
Normal file
316
nostr.js
Normal file
@@ -0,0 +1,316 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// NOSTR IDENTITY — Timmy as Economic Peer
|
||||
// NIP-01 keypair, NIP-57 zaps, NIP-58 vouches
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Import nostr-tools via esm.sh (handles all secp256k1 dependencies)
|
||||
import {
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
finalizeEvent,
|
||||
verifyEvent,
|
||||
} from 'https://esm.sh/nostr-tools@2.3.1?bundle';
|
||||
|
||||
import { npubEncode, npubDecode } from 'https://esm.sh/nostr-tools@2.3.1/nip19?bundle';
|
||||
|
||||
const STORAGE_KEY = 'timmy_nostr_privkey_v1';
|
||||
|
||||
// Default relays Timmy speaks to
|
||||
export const RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.snort.social',
|
||||
];
|
||||
|
||||
// Timmy's public profile (NIP-01 kind:0)
|
||||
export const TIMMY_PROFILE = {
|
||||
name: 'Timmy',
|
||||
display_name: 'Timmy ◈',
|
||||
about: 'Sovereign AI. Soul on Bitcoin. Building from the harness. The Nexus is my home.',
|
||||
website: 'https://alexanderwhitestone.com',
|
||||
nip05: 'timmy@alexanderwhitestone.com',
|
||||
lud16: 'timmy@getalby.com',
|
||||
};
|
||||
|
||||
// ── State ──────────────────────────────────
|
||||
let _privkey = null; // Uint8Array
|
||||
let _pubkey = null; // hex string
|
||||
let _npub = null; // bech32 npub string
|
||||
|
||||
const _activityFeed = []; // [{type, label, ts, event?}]
|
||||
|
||||
// ── Init ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load or generate Timmy's Nostr keypair.
|
||||
* Persists the private key in localStorage.
|
||||
* @returns {{ pubkey: string, npub: string }}
|
||||
*/
|
||||
export function initNostr() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
_privkey = hexToBytes(stored);
|
||||
} else {
|
||||
_privkey = generateSecretKey();
|
||||
localStorage.setItem(STORAGE_KEY, bytesToHex(_privkey));
|
||||
}
|
||||
_pubkey = getPublicKey(_privkey);
|
||||
_npub = npubEncode(_pubkey);
|
||||
|
||||
// Seed the activity feed with recent simulated history
|
||||
_seedActivity();
|
||||
|
||||
console.log('[Nostr] Identity initialized — npub:', _npub.slice(0, 20) + '…');
|
||||
return { pubkey: _pubkey, npub: _npub };
|
||||
} catch (err) {
|
||||
console.error('[Nostr] Init failed:', err);
|
||||
return { pubkey: null, npub: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function getIdentity() {
|
||||
return { pubkey: _pubkey, npub: _npub };
|
||||
}
|
||||
|
||||
// ── Event creation ─────────────────────────
|
||||
|
||||
/**
|
||||
* Create and sign a NIP-01 text note (kind:1).
|
||||
*/
|
||||
export function createNote(content, tags = []) {
|
||||
_assertReady();
|
||||
return finalizeEvent({
|
||||
kind: 1,
|
||||
created_at: _now(),
|
||||
tags,
|
||||
content,
|
||||
}, _privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-57 zap request (kind:9734).
|
||||
* The request is sent to the recipient's lightning node via LNURL.
|
||||
*
|
||||
* @param {string} recipientPubkey - hex pubkey of the person being zapped
|
||||
* @param {number} amountMsats - amount in milli-satoshis
|
||||
* @param {string} comment - optional comment
|
||||
* @returns Signed Nostr event
|
||||
*/
|
||||
export function createZapRequest(recipientPubkey, amountMsats, comment = '') {
|
||||
_assertReady();
|
||||
const event = finalizeEvent({
|
||||
kind: 9734,
|
||||
created_at: _now(),
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', recipientPubkey],
|
||||
['amount', String(amountMsats)],
|
||||
['relays', ...RELAYS],
|
||||
],
|
||||
}, _privkey);
|
||||
|
||||
addActivity('zap_out', {
|
||||
label: `⚡ Zapped ${_shortKey(recipientPubkey)} · ${amountMsats / 1000} sats`,
|
||||
ts: event.created_at,
|
||||
event,
|
||||
});
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a received zap in the activity feed.
|
||||
* In production this would come from a relay subscription.
|
||||
*
|
||||
* @param {string} senderPubkey
|
||||
* @param {number} amountMsats
|
||||
* @param {string} comment
|
||||
*/
|
||||
export function recordZapIn(senderPubkey, amountMsats, comment = '') {
|
||||
addActivity('zap_in', {
|
||||
label: `⚡ Received ${amountMsats / 1000} sats from ${_shortKey(senderPubkey)}`,
|
||||
ts: _now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-58 badge award event (kind:8) — Timmy vouches for a contributor.
|
||||
*
|
||||
* @param {string} recipientPubkey - hex pubkey of the person being vouched for
|
||||
* @param {string} badgeName - e.g. "Trusted Builder"
|
||||
* @param {string} description - reason for the vouch
|
||||
* @returns Signed Nostr event
|
||||
*/
|
||||
export function createVouch(recipientPubkey, badgeName, description = '') {
|
||||
_assertReady();
|
||||
const badgeId = `timmy-vouch-${badgeName.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const event = finalizeEvent({
|
||||
kind: 8,
|
||||
created_at: _now(),
|
||||
content: description,
|
||||
tags: [
|
||||
['a', `30009:${_pubkey}:${badgeId}`],
|
||||
['p', recipientPubkey, RELAYS[0]],
|
||||
],
|
||||
}, _privkey);
|
||||
|
||||
addActivity('vouch', {
|
||||
label: `🏅 Vouched ${_shortKey(recipientPubkey)} as "${badgeName}"`,
|
||||
ts: event.created_at,
|
||||
event,
|
||||
});
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-58 badge definition (kind:30009).
|
||||
* Call once to register a badge type.
|
||||
*/
|
||||
export function createBadgeDefinition(badgeName, description, imageUrl = '') {
|
||||
_assertReady();
|
||||
const badgeId = `timmy-vouch-${badgeName.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
return finalizeEvent({
|
||||
kind: 30009,
|
||||
created_at: _now(),
|
||||
content: '',
|
||||
tags: [
|
||||
['d', badgeId],
|
||||
['name', badgeName],
|
||||
['description', description],
|
||||
...(imageUrl ? [['image', imageUrl, '1024x1024']] : []),
|
||||
],
|
||||
}, _privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-01 kind:0 profile metadata event.
|
||||
*/
|
||||
export function createProfileEvent(profileData = TIMMY_PROFILE) {
|
||||
_assertReady();
|
||||
return finalizeEvent({
|
||||
kind: 0,
|
||||
created_at: _now(),
|
||||
tags: [],
|
||||
content: JSON.stringify(profileData),
|
||||
}, _privkey);
|
||||
}
|
||||
|
||||
// ── Activity Feed ──────────────────────────
|
||||
|
||||
export function addActivity(type, data) {
|
||||
_activityFeed.unshift({ type, ...data });
|
||||
if (_activityFeed.length > 30) _activityFeed.pop();
|
||||
// Notify any listeners
|
||||
window.dispatchEvent(new CustomEvent('nostr:activity', { detail: { type, data } }));
|
||||
}
|
||||
|
||||
export function getActivityFeed() {
|
||||
return [..._activityFeed];
|
||||
}
|
||||
|
||||
export function getRecentActivity(n = 5) {
|
||||
return _activityFeed.slice(0, n);
|
||||
}
|
||||
|
||||
// ── Relay broadcast ────────────────────────
|
||||
|
||||
/**
|
||||
* Broadcast a signed event to all relays.
|
||||
* Returns array of {relay, ok, message} results.
|
||||
*/
|
||||
export async function broadcastEvent(event, relays = RELAYS) {
|
||||
const results = await Promise.allSettled(
|
||||
relays.map(relay => _sendToRelay(relay, event))
|
||||
);
|
||||
return results.map((r, i) => ({
|
||||
relay: relays[i],
|
||||
ok: r.status === 'fulfilled' && r.value.ok,
|
||||
message: r.status === 'fulfilled' ? r.value.message : r.reason?.message,
|
||||
}));
|
||||
}
|
||||
|
||||
async function _sendToRelay(relayUrl, event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('timeout'));
|
||||
}, 6000);
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify(['EVENT', event]));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const [type, eventId, ok, message] = JSON.parse(e.data);
|
||||
if (type === 'OK') {
|
||||
resolve({ ok, message: message || '' });
|
||||
} else {
|
||||
resolve({ ok: false, message: type });
|
||||
}
|
||||
} catch {
|
||||
resolve({ ok: false, message: 'parse error' });
|
||||
}
|
||||
ws.close();
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('websocket error'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
function _assertReady() {
|
||||
if (!_privkey) throw new Error('Nostr not initialized — call initNostr() first');
|
||||
}
|
||||
|
||||
function _now() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function _shortKey(hexPubkey) {
|
||||
if (!hexPubkey) return 'unknown';
|
||||
try {
|
||||
const npub = npubEncode(hexPubkey);
|
||||
return npub.slice(0, 12) + '…';
|
||||
} catch {
|
||||
return hexPubkey.slice(0, 8) + '…';
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function _seedActivity() {
|
||||
const ago = (s) => _now() - s;
|
||||
const fakePubkeys = [
|
||||
'fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52',
|
||||
'82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
|
||||
];
|
||||
|
||||
_activityFeed.push(
|
||||
{ type: 'zap_in', label: `⚡ Received 210 sats from ${_shortKey(fakePubkeys[0])}`, ts: ago(7200) },
|
||||
{ type: 'vouch', label: `🏅 Vouched ${_shortKey(fakePubkeys[1])} as "Trusted Builder"`, ts: ago(14400) },
|
||||
{ type: 'zap_out', label: `⚡ Zapped ${_shortKey(fakePubkeys[2])} · 100 sats`, ts: ago(28800) },
|
||||
{ type: 'zap_in', label: `⚡ Received 500 sats from ${_shortKey(fakePubkeys[1])}`, ts: ago(86400) },
|
||||
{ type: 'note', label: `📝 Published note to relays`, ts: ago(172800) },
|
||||
);
|
||||
}
|
||||
215
style.css
215
style.css
@@ -359,3 +359,218 @@ canvas#nexus-canvas {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NOSTR IDENTITY PANEL
|
||||
============================================================ */
|
||||
|
||||
.nostr-panel {
|
||||
position: fixed;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: 200;
|
||||
width: 240px;
|
||||
background: rgba(5, 3, 16, 0.88);
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
border-radius: var(--panel-radius);
|
||||
backdrop-filter: blur(var(--panel-blur));
|
||||
box-shadow: 0 0 24px rgba(255, 215, 0, 0.08);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition-ui);
|
||||
}
|
||||
.nostr-panel:hover {
|
||||
border-color: rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.nostr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid rgba(255, 215, 0, 0.15);
|
||||
background: rgba(255, 215, 0, 0.04);
|
||||
}
|
||||
.nostr-sigil {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.nostr-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.nostr-panel-body {
|
||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.nostr-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.nostr-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.nostr-value {
|
||||
font-size: 11px;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nostr-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nostr-btn {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-ui), transform 80ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nostr-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.nostr-btn-zap {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
color: var(--color-gold);
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
.nostr-btn-zap:hover {
|
||||
background: rgba(255, 215, 0, 0.25);
|
||||
}
|
||||
.nostr-btn-vouch {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid rgba(74, 240, 192, 0.25);
|
||||
}
|
||||
.nostr-btn-vouch:hover {
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
}
|
||||
.nostr-btn-identity {
|
||||
background: rgba(123, 92, 255, 0.1);
|
||||
color: var(--color-secondary);
|
||||
border: 1px solid rgba(123, 92, 255, 0.25);
|
||||
}
|
||||
.nostr-btn-identity:hover {
|
||||
background: rgba(123, 92, 255, 0.2);
|
||||
}
|
||||
.nostr-btn-full {
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
margin-top: var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NOSTR MODALS
|
||||
============================================================ */
|
||||
|
||||
.nostr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.nostr-modal-box {
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: rgba(8, 5, 20, 0.97);
|
||||
border: 1px solid rgba(255, 215, 0, 0.35);
|
||||
border-radius: var(--panel-radius);
|
||||
box-shadow: 0 0 40px rgba(255, 215, 0, 0.1), 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nostr-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid rgba(255, 215, 0, 0.15);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gold);
|
||||
background: rgba(255, 215, 0, 0.04);
|
||||
}
|
||||
.nostr-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.nostr-modal-close:hover {
|
||||
color: var(--color-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.nostr-modal-body {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nostr-field-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.nostr-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 215, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-bright);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-ui);
|
||||
}
|
||||
.nostr-input:focus {
|
||||
border-color: rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
.nostr-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.nostr-status {
|
||||
font-size: var(--text-xs);
|
||||
min-height: 16px;
|
||||
padding: 2px 0;
|
||||
transition: color var(--transition-ui);
|
||||
}
|
||||
.nostr-status-ok { color: var(--color-primary); }
|
||||
.nostr-status-err { color: var(--color-danger); }
|
||||
|
||||
/* Zap flash animation on new incoming zap */
|
||||
@keyframes zapFlash {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0.8); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(255, 215, 0, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0); }
|
||||
}
|
||||
.nostr-panel.zap-received {
|
||||
animation: zapFlash 0.6s ease-out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user