Files
timmy-tower/the-matrix/js/session.js
Alexander Whitestone 232a0ed9d2
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
feat: Session Power Meter — 3D balance visualizer (#17)
Add a glowing orb power meter to the Workshop scene that reflects the
session balance in real time.

- power-meter.js: new Three.js module — transparent outer shell,
  inner orb that scales 0→1 with fill fraction, lightning bolt overlay,
  point light and equator ring accent; DOM text label projected above
  the orb shows current sats. Color interpolates red→yellow→cyan.
  Pulses bright on 'fill' event, quick flicker on 'drain'.
- session.js: imports meter helpers; tracks _sessionMax (initial
  deposit); calls setMeterVisible/setMeterBalance in _applySessionUI;
  triggers fill/drain pulses on payment and job deduction; exports
  openSessionPanel() for click-to-open wiring; clears meter on
  _clearSession.
- websocket.js: handles session_balance_update WS event — updates
  fill level and fires pulse.
- interaction.js: adds registerClickTarget(group, callback) — wired
  for both FPS pointer-lock and non-lock modes and short taps.
- main.js: wires initPowerMeter/updatePowerMeter/disposePowerMeter
  into the build/animate/teardown cycle; registers meter as click
  target that opens the session panel.

Fixes #17

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:31:43 -04:00

616 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* session.js — Workshop Session Mode UI (Fund once, ask many)
*
* Flow:
* 1. User clicks "⚡ FUND SESSION" → left panel slides in
* 2. Picks amount (2005000 sats) → POST /api/sessions → deposit invoice
* 3. Pays invoice (stub: "Simulate Payment") → 2 s polling until state=active
* 4. Macaroon + sessionId stored in localStorage; input bar activates (green border)
* 5. Every Enter/Send in the input bar → POST /api/sessions/:id/request
* 6. Timmy's response shown in speech bubble, balance ticks down in HUD
* 7. Balance < 50 sats → low-balance notice; Top Up button reopens panel topup step
* 8. On page reload: localStorage is read, session is validated, UI restored
*/
import { setSpeechBubble, setMood } from './agents.js';
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
import { getOrRefreshToken } from './nostr-identity.js';
import { sentiment } from './edge-worker-client.js';
import { setMeterBalance, triggerMeterPulse, setMeterVisible } from './power-meter.js';
const API = '/api';
const LS_KEY = 'timmy_session_v1';
const POLL_MS = 2000;
const POLL_TIMEOUT = 60_000;
const MIN_BALANCE = 50;
// ── Module state ──────────────────────────────────────────────────────────────
let _panel = null;
let _sessionId = null;
let _macaroon = null;
let _balanceSats = 0;
let _sessionState = null; // null | 'awaiting_payment' | 'active' | 'paused' | 'expired'
let _pollTimer = null;
let _inFlight = false;
let _selectedSats = 500; // deposit amount selection
let _topupSats = 500; // topup amount selection
let _sessionMax = 500; // max sats for this session (initial deposit)
// ── Public API ────────────────────────────────────────────────────────────────
export function initSessionPanel() {
_panel = document.getElementById('session-panel');
if (!_panel) return;
// Buttons
_on('open-session-btn', 'click', _openPanel);
_on('session-close', 'click', _closePanel);
_on('session-create-btn', 'click', _createSession);
_on('session-pay-btn', 'click', _payDeposit);
_on('session-topup-btn', 'click', () => _setStep('topup'));
_on('session-topup-create-btn','click', _createTopup);
_on('session-topup-pay-btn', 'click', _payTopup);
_on('session-back-btn', 'click', () => _setStep('active'));
_on('topup-quick-btn', 'click', () => { _openPanel(); _setStep('topup'); });
_on('session-hud-topup', 'click', (e) => { e.preventDefault(); _openPanel(); _setStep('topup'); });
_on('session-clear-history-btn', 'click', _clearHistory);
// Amount preset buttons — deposit (quick-fill the number input)
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selectedSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-amount-input');
if (inp) inp.value = _selectedSats;
});
});
// Free-text number input — deposit
document.getElementById('session-amount-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-amount-input').value, 10);
if (Number.isFinite(v)) {
_selectedSats = v;
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Amount preset buttons — topup (quick-fill the topup number input)
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_topupSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-topup-input');
if (inp) inp.value = _topupSats;
});
});
// Free-text number input — topup
document.getElementById('session-topup-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-topup-input').value, 10);
if (Number.isFinite(v)) {
_topupSats = v;
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Try to restore from localStorage on init
_tryRestore();
}
export function isSessionActive() {
return _sessionState === 'active' || _sessionState === 'paused';
}
/** Public entry-point used by the 3D power meter click handler. */
export function openSessionPanel() {
_openPanel();
}
// Called by ui.js when user submits the input bar while session is active
export async function sessionSendHandler(text) {
if (!_sessionId || !_macaroon || _inFlight) return;
_inFlight = true;
_setSendBusy(true);
appendSystemMessage(`you: ${text}`);
// Attach Nostr token if available
const nostrToken = await getOrRefreshToken('/api');
const reqHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
};
if (nostrToken) reqHeaders['X-Nostr-Token'] = nostrToken;
try {
const res = await fetch(`${API}/sessions/${_sessionId}/request`, {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify({ request: text }),
});
const data = await res.json();
if (res.status === 402) {
_balanceSats = data.balance ?? 0;
_sessionState = 'paused';
_saveToStorage();
_applySessionUI();
setSpeechBubble('My energy is depleted... top me up to continue!');
appendSystemMessage('⚡ Low balance — tap Top Up');
return;
}
if (res.status === 401) {
// Macaroon rejected — stale session, clear and reset
_clearSession();
appendSystemMessage('Session expired — fund a new one.');
return;
}
if (!res.ok) {
appendSystemMessage('Session error: ' + (data.error || res.status));
return;
}
_balanceSats = data.balanceRemaining ?? 0;
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
_saveToStorage();
_applySessionUI();
triggerMeterPulse('drain');
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
// Sentiment-driven mood on inbound Timmy reply
sentiment(reply).then(s => {
setMood(s.label);
setTimeout(() => setMood(null), 10_000);
}).catch(() => {});
// Update active-step balance if panel is open
_updateActiveStep();
} catch (err) {
appendSystemMessage('Session error: ' + err.message);
} finally {
_inFlight = false;
_setSendBusy(false);
}
}
// ── Panel open/close ──────────────────────────────────────────────────────────
function _openPanel() {
if (!_panel) return;
_panel.classList.add('open');
_clearError();
if (_sessionState === 'active') {
_setStep('active');
_updateActiveStep();
} else if (_sessionState === 'paused') {
_setStep('topup');
} else {
_setStep('fund');
}
}
function _closePanel() {
_panel?.classList.remove('open');
_stopPolling();
}
// ── Create session ────────────────────────────────────────────────────────────
async function _createSession() {
_clearError();
// Read from the number input (may differ from the last preset clicked)
const inp = document.getElementById('session-amount-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _selectedSats = v;
}
if (_selectedSats < 200 || _selectedSats > 10_000) {
_setError('Amount must be 20010,000 sats.');
return;
}
_setStatus('fund', 'creating session…', '#ffaa00');
_btn('session-create-btn', true);
try {
const nostrToken = await getOrRefreshToken('/api');
const createHeaders = { 'Content-Type': 'application/json' };
if (nostrToken) createHeaders['X-Nostr-Token'] = nostrToken;
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: createHeaders,
body: JSON.stringify({ amount_sats: _selectedSats }),
});
const data = await res.json();
if (!res.ok) { _setError(data.error || 'Failed to create session.'); return; }
_sessionId = data.sessionId;
// Show deposit invoice
const inv = data.invoice;
_el('session-invoice-amount').textContent = inv.amountSats + ' sats';
_el('session-invoice-pr').textContent = inv.paymentRequest || '';
const hashEl = _el('session-invoice-hash');
if (hashEl) hashEl.dataset.hash = inv.paymentHash || '';
_setStep('invoice');
_setStatus('invoice', '⚡ Awaiting deposit…', '#ffaa00');
} catch (err) {
_setError('Network error: ' + err.message);
} finally {
_btn('session-create-btn', false);
}
}
// ── Pay deposit (stub) ────────────────────────────────────────────────────────
async function _payDeposit() {
const hash = _el('session-invoice-hash')?.dataset.hash;
if (!hash) { _setError('No payment hash — use real Lightning?'); return; }
_btn('session-pay-btn', true);
_setStatus('invoice', 'Simulating payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Payment simulation failed.'); return; }
_startDepositPolling();
} catch (err) {
_setError('Payment error: ' + err.message);
} finally {
_btn('session-pay-btn', false);
}
}
// ── Deposit polling ───────────────────────────────────────────────────────────
function _startDepositPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
async function poll() {
if (!_sessionId) return;
try {
const nostrToken = await getOrRefreshToken('/api');
const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {};
const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders });
const data = await res.json();
if (data.state === 'active') {
_macaroon = data.macaroon;
_balanceSats = data.balanceSats;
_sessionMax = _balanceSats; // initial deposit = full bar
_sessionState = 'active';
_saveToStorage();
_applySessionUI();
triggerMeterPulse('fill');
_closePanel(); // panel auto-closes; user types in input bar
appendSystemMessage(`Session active — ${_balanceSats} sats`);
return;
}
} catch { /* network hiccup */ }
if (Date.now() > deadline) {
_setError('Timed out waiting for payment.');
return;
}
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Topup ─────────────────────────────────────────────────────────────────────
async function _createTopup() {
if (!_sessionId || !_macaroon) return;
_clearError();
// Read from the topup number input
const inp = document.getElementById('session-topup-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _topupSats = v;
}
if (_topupSats < 200 || _topupSats > 10_000) {
_setError('Topup amount must be 20010,000 sats.');
return;
}
_setStatus('topup', 'creating topup invoice…', '#ffaa00');
_btn('session-topup-create-btn', true);
try {
const nostrToken = await getOrRefreshToken('/api');
const topupHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
};
if (nostrToken) topupHeaders['X-Nostr-Token'] = nostrToken;
const res = await fetch(`${API}/sessions/${_sessionId}/topup`, {
method: 'POST',
headers: topupHeaders,
body: JSON.stringify({ amount_sats: _topupSats }),
});
const data = await res.json();
if (!res.ok) {
// Pending topup already exists — surface it
const pending = data.pendingTopup;
if (pending) {
_el('session-topup-pr').textContent = pending.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = pending.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Previous invoice still pending', '#ffaa00');
return;
}
_setError(data.error || 'Failed to create topup.');
return;
}
const inv = data.topup;
_el('session-topup-pr').textContent = inv.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = inv.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Awaiting topup payment…', '#ffaa00');
} catch (err) {
_setError('Topup error: ' + err.message);
} finally {
_btn('session-topup-create-btn', false);
}
}
async function _payTopup() {
const hash = _el('session-topup-hash')?.dataset.hash;
if (!hash) { _setError('No topup hash.'); return; }
_btn('session-topup-pay-btn', true);
_setStatus('topup', 'Simulating topup payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Topup simulation failed.'); return; }
_startTopupPolling();
} catch (err) {
_setError('Topup payment error: ' + err.message);
} finally {
_btn('session-topup-pay-btn', false);
}
}
function _startTopupPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
const prevBalance = _balanceSats;
async function poll() {
if (!_sessionId) return;
try {
const nostrToken = await getOrRefreshToken('/api');
const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {};
const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders });
const data = await res.json();
if (data.balanceSats > prevBalance || data.state === 'active') {
_balanceSats = data.balanceSats;
_macaroon = data.macaroon || _macaroon;
_sessionState = data.state === 'active' ? 'active' : _sessionState;
_sessionMax = Math.max(_sessionMax, _balanceSats);
_saveToStorage();
_applySessionUI();
triggerMeterPulse('fill');
_setStep('active');
_updateActiveStep();
_setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66');
return;
}
} catch { /* ignore */ }
if (Date.now() > deadline) { _setError('Topup timed out.'); return; }
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Clear history ─────────────────────────────────────────────────────────────
async function _clearHistory() {
if (!_sessionId || !_macaroon) return;
const btn = document.getElementById('session-clear-history-btn');
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/sessions/${_sessionId}/history`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${_macaroon}` },
});
if (res.ok) {
_setStatus('active', '✓ History cleared', '#44dd88');
setTimeout(() => _setStatus('active', '', ''), 2500);
} else {
_setStatus('active', 'Failed to clear history', '#ff6644');
}
} catch (err) {
_setStatus('active', 'Error: ' + err.message, '#ff6644');
} finally {
if (btn) btn.disabled = false;
}
}
// ── Restore from localStorage ─────────────────────────────────────────────────
async function _tryRestore() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const { sessionId, macaroon, balanceSats } = JSON.parse(raw);
if (!sessionId || !macaroon) return;
// Validate the session is still live
const nostrToken = await getOrRefreshToken('/api');
const restoreHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {};
const res = await fetch(`${API}/sessions/${sessionId}`, { headers: restoreHeaders });
if (!res.ok) { localStorage.removeItem(LS_KEY); return; }
const data = await res.json();
if (data.state !== 'active' && data.state !== 'paused') {
localStorage.removeItem(LS_KEY);
return;
}
_sessionId = sessionId;
_macaroon = data.macaroon || macaroon;
_balanceSats = data.balanceSats ?? balanceSats;
_sessionState = data.state;
_saveToStorage();
_applySessionUI();
appendSystemMessage(`Session restored (${_balanceSats} sats)`);
} catch {
// Ignore restore errors silently
}
}
// ── Session UI helpers ────────────────────────────────────────────────────────
function _applySessionUI() {
const anyActive = isSessionActive(); // true for both 'active' and 'paused'
const canSend = _sessionState === 'active';
// Show low-balance overlay whenever session exists but balance is too low
const lowBal = anyActive && _balanceSats < MIN_BALANCE;
// HUD balance: "Balance: X sats ⚡ Top Up"
const $hud = document.getElementById('session-hud');
if ($hud) {
$hud.style.display = anyActive ? '' : 'none';
const $span = document.getElementById('session-hud-balance');
if ($span) $span.textContent = `Balance: ${_balanceSats} sats`;
}
// Top button label
const $btn = document.getElementById('open-session-btn');
if ($btn) {
$btn.textContent = anyActive
? `SESSION: ${_balanceSats}`
: '⚡ FUND SESSION';
$btn.style.background = anyActive ? '#1a4a30' : '';
}
// Input bar green border + placeholder when session can send
setInputBarSessionMode(canSend, 'Ask Timmy (session active)…');
// Route input bar to session handler only when active (not paused)
setSessionSendHandler(canSend ? sessionSendHandler : null);
// Low balance notice above input bar
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = lowBal ? '' : 'none';
// 3D power meter visibility
setMeterVisible(anyActive);
if (anyActive) setMeterBalance(_balanceSats, _sessionMax);
}
function _updateActiveStep() {
const el = document.getElementById('session-active-amount');
if (el) {
el.textContent = _balanceSats + ' sats';
el.style.color = _balanceSats < MIN_BALANCE ? '#ff8844' : '#44dd88';
}
// Reset topup pr row visibility
const prRow = document.getElementById('session-topup-pr-row');
if (prRow) prRow.style.display = 'none';
}
function _clearSession() {
_sessionId = null;
_macaroon = null;
_balanceSats = 0;
_sessionState = null;
_sessionMax = 500;
localStorage.removeItem(LS_KEY);
setMeterVisible(false);
setInputBarSessionMode(false);
setSessionSendHandler(null);
const $hud = document.getElementById('session-hud');
if ($hud) $hud.style.display = 'none';
const $btn = document.getElementById('open-session-btn');
if ($btn) { $btn.textContent = '⚡ FUND SESSION'; $btn.style.background = ''; }
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = 'none';
}
function _saveToStorage() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
sessionId: _sessionId,
macaroon: _macaroon,
balanceSats: _balanceSats,
}));
} catch { /* storage unavailable */ }
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
function _el(id) { return document.getElementById(id); }
function _on(id, ev, fn) {
_el(id)?.addEventListener(ev, fn);
}
function _btn(id, disabled) {
const el = _el(id);
if (el) el.disabled = disabled;
}
function _setSendBusy(busy) {
const btn = document.getElementById('send-btn');
if (btn) btn.disabled = busy;
}
function _setStep(step) {
if (!_panel) return;
_panel.querySelectorAll('[data-session-step]').forEach(el => {
el.style.display = el.dataset.sessionStep === step ? '' : 'none';
});
}
function _setStatus(step, msg, color = '#22aa66') {
const el = document.getElementById(`session-status-${step}`);
if (el) { el.textContent = msg; el.style.color = color; }
}
function _setError(msg) {
const el = _el('session-error');
if (el) el.textContent = msg;
}
function _clearError() {
const el = _el('session-error');
if (el) el.textContent = '';
}
function _stopPolling() {
clearTimeout(_pollTimer);
_pollTimer = null;
}