Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295// Orchestrator: owns the buddy state map and the firehose connection, wires the// DOM, and delegates the heavy lifting — rendering to buddy-list.js, IM windows// to chat.js, presence rules to status.js, sounds to sound.js.
import { searchActors, resolveActor, getMutuals, getProfiles } from './api.js';import { attachTypeahead } from './typeahead.js';import { Jetstream, MAX_DIDS } from './jetstream.js';import { stateFor, isActiveAction } from './status.js';import { renderBuddyList } from './buddy-list.js';import { makeDraggable } from './drag.js';import { fmtTime } from './util.js';import { openAbout, closeAbout } from './about.js';import { backfillLastSeen } from './last-seen.js';import * as snd from './sound.js';import * as chat from './chat.js';
const ACTIVITY_CAP = 80; // rolling per-buddy live action buffer (for chat windows)const $ = (id) => document.getElementById(id);
// --- state -------------------------------------------------------------------let windowMs = 5 * 60 * 1000; // online = authored within this; idle = only liked; else offlineconst buddies = new Map(); // did -> { did, profile, lastSeen, lastActive, lastAction, state, activity[] }const groupsOpen = { online: true, idle: true, offline: false };const flashing = new Set(); // dids currently flashing a "came online" highlightlet jet = null;let me = null; // { did, handle }let connState = 'connecting'; // connecting | live | downlet renderQueued = false;let backfillCtrl = null; // aborts the last-seen backfill on sign-off
// --- firehose -> state -------------------------------------------------------function onEvent({ did, timeUs, collection, operation, rkey, record, seeding }) { const b = buddies.get(did); if (!b) return; const tMs = Math.floor(timeUs / 1000);
// Buffer the raw action for chat windows (creates only — deletes aren't worth // narrating). Newest pushed last; capped. Authoring actions also bump the // separate `lastActive` clock that drives the online (vs idle) tier. if (operation === 'create') { const ev = { did, timeMs: tMs, collection, operation, rkey, record }; b.activity.push(ev); if (b.activity.length > ACTIVITY_CAP) b.activity.shift(); if (isActiveAction(collection) && tMs > (b.lastActive || 0)) b.lastActive = tMs; if (!seeding) chat.notify(did, ev); // live-append to an open window }
if (tMs <= (b.lastSeen || 0)) return; const prev = b.state; b.lastSeen = tMs; b.lastAction = collection; b.lastSeenApprox = false; // we now have a real action, not just a rev timestamp b.state = stateFor(b, windowMs); // Door OPEN only for a genuine flip TO online (they authored) on a LIVE event. if (!seeding && prev !== 'online' && b.state === 'online') { snd.doorOpen(); flash(did); } queueRender();}
// Periodic sweep: ages buddies out (online->idle->offline) and chimes the door// shut when someone goes fully offline.function sweep() { let changed = false; for (const b of buddies.values()) { const next = stateFor(b, windowMs); if (next !== b.state) { if (b.state === 'online' && next !== 'online') snd.doorClose(); b.state = next; changed = true; } } if (changed) queueRender(); updateClock();}
function flash(did) { flashing.add(did); setTimeout(() => { flashing.delete(did); queueRender(); }, 2500);}
// --- rendering ---------------------------------------------------------------function queueRender() { if (renderQueued) return; renderQueued = true; requestAnimationFrame(() => { renderQueued = false; renderBuddyList($('list'), $('count'), buddies, { groupsOpen, flashing }); });}
function updateClock() { $('clock').textContent = fmtTime(Date.now()); }
// Reflect the global sound flag in both controls (in-app button + login checkbox).function syncSoundUI() { const on = snd.isEnabled(); const btn = $('mute'); btn.classList.toggle('on', on); btn.classList.toggle('off', !on); btn.innerHTML = on ? '🔊 Sound' : '🔇 Muted'; btn.title = on ? 'Door sounds: on' : 'Door sounds: off'; $('snd-enabled').checked = on;}
// Center a floating .window in the viewport with explicit px coords (clearing// the CSS transform), so it's ready to be dragged without jumping.function centerWindow(el) { el.style.transform = 'none'; el.style.left = `${Math.max(8, (window.innerWidth - el.offsetWidth) / 2)}px`; el.style.top = `${Math.max(8, Math.min(48, (window.innerHeight - el.offsetHeight) / 4))}px`;}
// --- sign-on -----------------------------------------------------------------async function signOn(handleInput) { snd.unlock(); // user gesture -> audio allowed const status = $('status'); $('signon').disabled = true; $('handle').disabled = true; try { status.textContent = 'Resolving handle…'; const did = await resolveActor(handleInput); me = { did, handle: handleInput.replace(/^@/, '') }; chat.setSelf(did); // scope IM windows to interactions with me localStorage.setItem('buddylist:handle', me.handle);
status.textContent = 'Loading buddy list…'; const mutuals = await getMutuals(did, { onProgress: (n) => { status.textContent = `Loading buddy list… (${n})`; }, }); if (!mutuals.length) throw new Error('No mutuals found for that account.');
status.textContent = `Found ${mutuals.length} buddies. Loading profiles…`; const dids = mutuals.map((m) => m.did); const profiles = await getProfiles(dids); for (const m of mutuals) { buddies.set(m.did, { did: m.did, profile: profiles[m.did] || m, lastSeen: 0, lastActive: 0, lastAction: null, state: 'offline', lastSeenApprox: false, // true once filled from the rev backfill (action type unknown) activity: [], }); }
$('login').hidden = true; $('app').hidden = false; centerWindow($('app')); syncSoundUI(); $('me-handle').textContent = `@${me.handle}`; if (dids.length > MAX_DIDS) { $('note').hidden = false; $('note').textContent = `Tracking the ${MAX_DIDS} most-recent of ${dids.length} mutuals (firehose limit).`; } queueRender(); startStream(dids); startBackfill(); } catch (e) { status.textContent = `⚠ ${e.message}`; $('signon').disabled = false; $('handle').disabled = false; }}
// --- firehose connection -----------------------------------------------------function startStream(dids) { if (jet) jet.stop(); setConn('connecting'); jet = new Jetstream({ dids, lookbackMs: windowMs, onEvent, onStatus: setConn, onSeeded: () => { setConn('live'); queueRender(); }, }); jet.start();}
// Background-fill "last seen" for buddies we have no live/replay data for (the// offline group). Uses each repo's latest-commit rev; staggered so it trickles.function startBackfill() { if (backfillCtrl) backfillCtrl.abort(); backfillCtrl = new AbortController(); const dids = [...buddies.values()].filter((b) => !b.lastSeen).map((b) => b.did); backfillLastSeen(dids, { signal: backfillCtrl.signal, onResult: (did, ms) => { const b = buddies.get(did); if (!b || b.lastSeen) return; // a live event filled it in the meantime b.lastSeen = ms; b.lastSeenApprox = true; // we know WHEN, not WHAT they did b.state = stateFor(b, windowMs); queueRender(); }, });}
function setConn(state) { connState = state; const el = $('conn'); if (!el) return; const labels = { connecting: 'connecting…', live: 'connected', down: 'reconnecting…' }; el.className = `conn-dot ${state}`; el.title = labels[state] || state;}
// Snapshot for the About/status window.function getStatus() { if (!me) return null; let online = 0, idle = 0, offline = 0; for (const b of buddies.values()) { if (b.state === 'online') online++; else if (b.state === 'idle') idle++; else offline++; } return { handle: me.handle, conn: connState, online, idle, offline, total: buddies.size };}
function setWindow(min) { windowMs = min * 60 * 1000; for (const b of buddies.values()) b.state = stateFor(b, windowMs); if (me) startStream([...buddies.keys()]); // reconnect so replay reseeds the new window queueRender();}
// Sign off: tear down the connection + windows and return to the login screen.function signOff() { if (jet) { jet.stop(); jet = null; } if (backfillCtrl) { backfillCtrl.abort(); backfillCtrl = null; } chat.closeAll(); chat.setSelf(null); closeAbout(); buddies.clear(); me = null; connState = 'connecting'; $('app').hidden = true; $('login').hidden = false; $('signon').disabled = false; $('handle').disabled = false; $('status').textContent = ''; $('note').hidden = true; syncSoundUI(); centerWindow($('login')); $('handle').focus();}
// --- DOM wiring --------------------------------------------------------------attachTypeahead($('handle'), $('ta-list'), { search: (q) => searchActors(q), onPick: () => $('signon').focus(),});
const trySignOn = () => { const v = $('handle').value.trim(); if (v) signOn(v); };$('signon').addEventListener('click', trySignOn);$('handle').addEventListener('keydown', (e) => { if (e.key === 'Enter' && $('ta-list').hidden) trySignOn();});
// Both windows float and drag by their title bar, like the IM windows.makeDraggable($('login'), $('login').querySelector('.titlebar'));makeDraggable($('app'), $('app').querySelector('.titlebar'));centerWindow($('login'));
const saved = localStorage.getItem('buddylist:handle');if (saved) $('handle').value = saved;
$('list').addEventListener('click', (e) => { const hd = e.target.closest('.group-hd'); if (hd) { const key = hd.parentElement.dataset.group; groupsOpen[key] = !groupsOpen[key]; return queueRender(); } const row = e.target.closest('.buddy'); if (row?.dataset.did) chat.openChat(buddies.get(row.dataset.did));});
$('app-close').addEventListener('click', signOff);$('about-btn').addEventListener('click', () => openAbout(getStatus));
$('mute').addEventListener('click', () => { snd.setEnabled(!snd.isEnabled()); syncSoundUI(); });
// Sign-on checkbox for door sounds (shares the global enabled flag).$('snd-enabled').addEventListener('change', (e) => { snd.setEnabled(e.target.checked); if (e.target.checked) snd.unlock();});
$('window').addEventListener('change', (e) => setWindow(+e.target.value));
setInterval(sweep, 5000);updateClock();setInterval(updateClock, 30000);