Archived
1
0

feat: add Nostr identity, zap-out, and vouching for Timmy (issue #13)

Implements Timmy as an economic peer with sovereign Nostr presence:

- nostr.js: New module handling keypair lifecycle (localStorage),
  NIP-01 signed notes, NIP-57 zap requests, NIP-58 badge awards,
  relay broadcast, and an economic activity feed
- app.js: Imports nostr module; adds 3D holographic "ECONOMIC" panel
  on the left side of the Nexus showing live npub + activity feed;
  wires /zap, /vouch, /note, /identity, /relays chat commands;
  animates economic panel scanline in game loop
- index.html: Adds Nostr identity HUD widget (top-right) showing npub
  with Zap, Vouch, and ID buttons; Zap modal and Vouch modal for
  point-and-click economic actions; inline module script for modal UX
- style.css: Full design system for .nostr-panel, .nostr-modal,
  form inputs, action buttons, status indicators, and zapFlash
  keyframe animation on incoming zaps

Keypair persists in localStorage (timmy_nostr_privkey_v1).
Broadcasts to damus.io, relay.nostr.band, nos.lol, relay.snort.social.

Fixes #13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 18:39:35 -04:00
parent 3725c933cf
commit 0cae2af835
4 changed files with 1014 additions and 0 deletions

337
app.js
View File

@@ -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;

View File

@@ -99,6 +99,65 @@
<div class="hud-controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <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
View 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
View File

@@ -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;
}