[claude] Timmy Nostr identity — keypair, zaps, vouching, economic panel (#13) #52
257
app.js
257
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 <text>'); return; }
|
||||
publishNote(content).then(() => addChatMessage('timmy', `📝 Note published: "${content}"`));
|
||||
return;
|
||||
}
|
||||
case 'zap': {
|
||||
// /zap <recipient_hex> <amount> [comment]
|
||||
const [recipient, amountStr, ...commentParts] = args;
|
||||
if (!recipient || !amountStr) { addChatMessage('error', 'Usage: /zap <pubkey_hex> <sats> [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 <pubkey_hex> <badge> [note]
|
||||
const [pubkey, badge, ...noteParts] = args;
|
||||
if (!pubkey || !badge) { addChatMessage('error', 'Usage: /vouch <pubkey_hex> <badge_name> [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 <amount> <from_label> — 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 <text> /zap <key> <sats> [msg] /vouch <key> <badge> [note] /zapin <sats> <from> /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 = `<span class="nostr-tag nostr-tag-${entry.type}">[${tag}]</span>${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;
|
||||
|
||||
55
index.html
55
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"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -80,6 +82,57 @@
|
||||
<div id="agent-log-content" class="agent-log-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Identity Widget (below agent log) -->
|
||||
<div id="nostr-widget" class="nostr-widget">
|
||||
<div class="nostr-widget-header">
|
||||
<span class="nostr-widget-icon">⚡</span>
|
||||
<span class="nostr-widget-title">TIMMY IDENTITY</span>
|
||||
<span id="nostr-status-dot" class="nostr-status-dot"></span>
|
||||
</div>
|
||||
<div id="nostr-npub" class="nostr-npub">npub1...</div>
|
||||
<div id="nostr-activity-feed" class="nostr-activity-feed"></div>
|
||||
<div class="nostr-widget-actions">
|
||||
<button id="nostr-zap-btn" class="nostr-action-btn">⚡ ZAP</button>
|
||||
<button id="nostr-vouch-btn" class="nostr-action-btn">🏅 VOUCH</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zap Modal -->
|
||||
<div id="zap-modal" class="nostr-modal" style="display:none;">
|
||||
<div class="nostr-modal-content">
|
||||
<div class="nostr-modal-header">
|
||||
<span class="nostr-modal-icon">⚡</span>
|
||||
<h3>SEND ZAP</h3>
|
||||
<button class="nostr-modal-close" id="zap-modal-close">✕</button>
|
||||
</div>
|
||||
<label class="nostr-modal-label">Recipient pubkey (hex or npub)</label>
|
||||
<input type="text" id="zap-recipient" class="nostr-modal-input" placeholder="npub1...">
|
||||
<label class="nostr-modal-label">Amount (sats)</label>
|
||||
<input type="number" id="zap-amount" class="nostr-modal-input" placeholder="21" min="1">
|
||||
<label class="nostr-modal-label">Comment</label>
|
||||
<input type="text" id="zap-comment" class="nostr-modal-input" placeholder="Optional note...">
|
||||
<button id="zap-send-btn" class="nostr-modal-submit">⚡ SEND ZAP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vouch Modal -->
|
||||
<div id="vouch-modal" class="nostr-modal" style="display:none;">
|
||||
<div class="nostr-modal-content">
|
||||
<div class="nostr-modal-header">
|
||||
<span class="nostr-modal-icon">🏅</span>
|
||||
<h3>VOUCH FOR CONTRIBUTOR</h3>
|
||||
<button class="nostr-modal-close" id="vouch-modal-close">✕</button>
|
||||
</div>
|
||||
<label class="nostr-modal-label">Contributor pubkey (hex)</label>
|
||||
<input type="text" id="vouch-pubkey" class="nostr-modal-input" placeholder="hex pubkey...">
|
||||
<label class="nostr-modal-label">Badge name</label>
|
||||
<input type="text" id="vouch-badge" class="nostr-modal-input" placeholder="e.g. Sovereign Builder">
|
||||
<label class="nostr-modal-label">Note</label>
|
||||
<input type="text" id="vouch-note" class="nostr-modal-input" placeholder="Optional note...">
|
||||
<button id="vouch-send-btn" class="nostr-modal-submit">🏅 VOUCH</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Chat Interface -->
|
||||
<div id="chat-panel" class="chat-panel">
|
||||
<div class="chat-header">
|
||||
|
||||
168
nostr.js
Normal file
168
nostr.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
183
style.css
183
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 {
|
||||
|
||||
Reference in New Issue
Block a user