/** * The Live lane (DESIGN.md §12.6): Twitch chat on the evidence. * * One hub per room keeps the chat log, the simulated crowd, the reaction * tallies, corroborations and the Clerk's timer, so closing and reopening a * room feels like walking back into a party that kept going. Views attach to * a hub: the room sheet's Live lane and the Moment player's overlay. * * Real people: the person's own lines, and lines from other tabs over * BroadcastChannel. Everything else is simulated and labelled as such. */ import { icon } from './icons.js'; import { $, $$, h, esc, haptic, toast, reducedMotion, emitter } from './dom.js'; import { fmtKm, utc, distKm } from './bands.js'; import { createChatSim, viewerCount, worldPoint, chatGenre, momentChat, personaOf, makeMsg, PERSONAS, } from './chatSim.js'; import { REACT, REACT_KINDS, EMOTES, EMOTE_CODES, reactSvg, richText, } from './emotes.js'; import { simTier, tierOf, hashStr } from './signal.js'; import { summarize } from './clerk.js'; import { get, put } from '../capture/db.js'; const CLERK_FIRST_MS = 45e3; const CLERK_EVERY_MS = 3 * 60e3; const PROMOTE_AT = 6; // Signal-weighted corroboration that promotes a line const MAX_LOG = 400; const MAX_DOM = 160; /* ---------------- Globe reactions (feature-detected) ---------------- */ export function globeReact(globe, p) { const fn = globe?.react || globe?.raw?.react; if (typeof fn !== 'function') return false; try { fn.call(globe?.react ? globe : globe.raw, p); return true; } catch { return false; } } function globeHeat(globe, pts) { const fn = globe?.attentionHeat || globe?.raw?.attentionHeat; if (typeof fn !== 'function') return; try { fn.call(globe?.attentionHeat ? globe : globe.raw, pts); } catch { /* optional */ } } /** A rough home from the time zone, when location is not shared. */ const TZ = { 'America/Los_Angeles': [34.05, -118.24], 'America/Denver': [39.74, -104.99], 'America/Chicago': [41.88, -87.63], 'America/New_York': [40.71, -74.01], 'America/Toronto': [43.65, -79.38], 'America/Sao_Paulo': [-23.55, -46.63], 'Europe/London': [51.51, -0.13], 'Europe/Paris': [48.86, 2.35], 'Europe/Berlin': [52.52, 13.4], 'Asia/Tokyo': [35.68, 139.69], 'Asia/Kolkata': [19.08, 72.88], 'Asia/Singapore': [1.35, 103.82], 'Australia/Sydney': [-33.87, 151.21], }; export function guessLoc() { try { const z = Intl.DateTimeFormat().resolvedOptions().timeZone; const v = TZ[z]; if (v) return { lat: v[0], lon: v[1], guessed: true }; } catch { /* ignore */ } return { lat: 37.77, lon: -122.42, guessed: true }; } /* ---------------- The hub ---------------- */ /** * @param {object} core social core (globe, rt, rooms, signal, needs, ev) * @param {object} s situation (or a Moment adapted to one) */ export function createRoomHub(core, s) { const ev = emitter(); const id = String(s.id); const log = []; let n = 0; let views = 0; let stopTimer = 0; let clerkTimer = 0; let lastClerkAt = 0; const clerk = []; // Tallies start where a room this size would be after a few minutes. const v0 = viewerCount(s); const reactions = { heart: Math.round(v0 * 0.21), fire: Math.round(v0 * 0.05), eyes: Math.round(v0 * 0.12), wow: Math.round(v0 * 0.08), }; let windowReactions = { heart: 0, fire: 0, eyes: 0, wow: 0 }; let viewers = viewerCount(s); const corroBy = new Map(); // msg id → Set(handles) let autoPromoted = 0; let firstEyes = null; const genre = chatGenre(s); const sim = createChatSim(s, { viewers, onMessage: (m) => push(m), onBackfill: (list) => list.forEach((m) => push(m, { quiet: true })), onReactions: (kind, count, burst) => crowd(kind, count, burst), onViewers: (v) => { viewers = v; ev.emit('viewers', v); }, onBurst: () => ev.emit('burst'), }); function push(m, { quiet = false } = {}) { if (!m || !m.text) return null; if (log.some((x) => x.id === m.id)) return null; n += 1; const msg = { corro: 0, ...m, n }; log.push(msg); if (log.length > MAX_LOG) log.splice(0, log.length - MAX_LOG); // Simulated crowd corroborates evidence lines over the next seconds. if (msg.sim && msg.evidence && !quiet) simCorroborate(msg); ev.emit('msg', { msg, quiet }); return msg; } function simCorroborate(msg) { let k = 0; const tick = () => { if (!sim.running || k > 6) return; k += 1; const pool = [ ...PERSONAS.local, ...PERSONAS.photo, ...PERSONAS.pilot, ...PERSONAS.wx, ...PERSONAS.newbie, ]; const p = pool[Math.floor(Math.random() * pool.length)]; if (p) addCorro(msg, p.handle, simTier(p.handle, p.floor).weight); setTimeout(tick, 1800 + Math.random() * 3200); }; setTimeout(tick, 2500 + Math.random() * 2500); } function addCorro(msg, handle, w = 1) { const set = corroBy.get(msg.id) || new Set(); if (set.has(handle)) return false; set.add(handle); corroBy.set(msg.id, set); msg.corro = Math.round(((msg.corro || 0) + w) * 10) / 10; msg.corroN = set.size; ev.emit('update', msg); if ( msg.sim && msg.evidence && msg.corro >= PROMOTE_AT && !msg.promoted && autoPromoted < 1 ) { autoPromoted += 1; promote(msg, { auto: true }).catch(() => {}); } return true; } /* ---- reactions ---- */ function at(lonlat, jitter = 0) { return { lon: lonlat.lon + (Math.random() - 0.5) * jitter, lat: lonlat.lat + (Math.random() - 0.5) * jitter, }; } function crowd(kind, count, burst) { reactions[kind] += count; windowReactions[kind] += count; // Locals near the situation, the rest of the world wherever it is. const local = Math.round(count * 0.35); if (local && Number.isFinite(s.lat)) globeReact(core.globe, { ...at(s, burst ? 0.12 : 0.05), kind, count: local, }); let rest = count - local; for (let i = 0; i < 3 && rest > 0; i += 1) { const c = i === 2 ? rest : Math.ceil(rest / 2); rest -= c; globeReact(core.globe, { ...worldPoint(), kind, count: c }); } ev.emit('react', { kind, count, crowd: true, burst }); } function react(kind, { remote = false } = {}) { reactions[kind] += 1; windowReactions[kind] += 1; const loc = remote ? worldPoint() : core.myLoc(); globeReact(core.globe, { lon: loc.lon, lat: loc.lat, kind, count: 1 }); if (!remote) core.rt.react(id, kind); ev.emit('react', { kind, count: 1, self: !remote, remote }); } /* ---- the person ---- */ function meMsg(text) { const score = core.signal.cached; return { id: `me-${core.rt.tab}-${Date.now().toString(36)}`, handle: core.rt.me.handle, text, at: Date.now(), self: true, tier: tierOf(score), witness: !!core.myWitness?.(id), first: firstEyes?.self || false, }; } function send(text) { const t = String(text || '') .trim() .slice(0, 300); if (!t) return null; const m = push(meMsg(t)); if (m) { core.rt.chat(id, { ...m, self: false }); sim.hear(t, core.rt.me.handle); } return m; } function corroborate(msg) { const w = tierOf(core.signal.cached).weight; const ok = addCorro(msg, core.rt.me.handle, w); if (ok) core.rt.corro(id, msg.id, w); return ok; } /** Bridge: a Live line becomes a Record post. */ async function promote(msg, { auto = false } = {}) { if (msg.promoted) return msg.promoted; const eligible = msg.evidence || (msg.corro || 0) >= 3; if (!eligible && !auto) return null; // The same words promoted twice are one record. const existing = (await core.rooms.room(s)).posts.find( (p) => p.promotedFrom && p.promotedFrom.text === msg.text, ); if (existing) { msg.promoted = existing.id; ev.emit('update', msg); return existing.id; } const persona = personaOf(msg.handle); const kind = msg.evidence ? 'witness' : /\?\s*$/.test(msg.text) ? 'question' : 'context'; const post = await core.rooms.post(s, { id: `${id}~promo~${hashStr(`${msg.handle}|${msg.text}`).toString(36)}`, kind, text: msg.text, at: msg.at, author: msg.self ? undefined : { handle: msg.handle, sim: !!msg.sim, example: !!msg.sim, domain: persona?.domain || msg.domain || undefined, local: !!(persona?.local || msg.local), }, evidence: msg.evidence ? { sealed: true, wasThere: Number.isFinite(msg.evidence.km) ? msg.evidence.km < 5 : true, distanceKm: msg.evidence.km ?? null, example: !!msg.sim, } : {}, votes: { up: msg.corroN || 0, dn: 0 }, promotedFrom: { msgId: msg.id, n: msg.n, handle: msg.handle, text: msg.text, at: msg.at, corro: msg.corro || 0, corroN: msg.corroN || 0, auto, sim: !!msg.sim, }, }); msg.promoted = post.id; ev.emit('update', msg); push({ id: `sys-promo-${msg.id}`, system: true, handle: 'Record', text: `${auto ? 'Promoted by corroboration' : 'Promoted'}: ${msg.handle}’s ${msg.evidence ? 'sealed capture' : 'line'} is now in the Record (${(msg.corro || 0).toFixed(1)} Signal-weighted).`, at: Date.now(), ref: post.id, }); if (msg.self) core.signal.award({ id: `promoted:${post.id}`, rule: 'promoted', text: `Your chat line was promoted to the Record in ${s.title}`, ref: { room: id, post: post.id }, }); core.ev.emit('promote', { room: id, post, msg }); return post.id; } /* ---- clerk ---- */ async function runClerk({ backdate = 0 } = {}) { const since = lastClerkAt || 0; const lines = log.filter((m) => m.at > since && !m.system); const sum = await summarize(lines.slice(-120), { reactions: windowReactions, viewers, }); if (!sum) return null; if (backdate) sum.at -= backdate; lastClerkAt = Date.now(); windowReactions = { heart: 0, fire: 0, eyes: 0, wow: 0 }; sum.room = id; clerk.unshift(sum); clerk.splice(6); put('kv', { id: `clerk:${id}`, list: clerk }).catch(() => {}); push({ id: `sys-clerk-${sum.id}`, system: true, clerk: true, handle: 'Clerk', text: `Posted “What chat is saying” to the Record, citing ${sum.quotes.length} lines.`, at: Date.now(), ref: sum.id, }); ev.emit('clerk', sum); core.ev.emit('clerk', sum); return sum; } function scheduleClerk(ms) { clearTimeout(clerkTimer); clerkTimer = setTimeout(async () => { await runClerk(); scheduleClerk(CLERK_EVERY_MS); }, ms); } /* ---- lifecycle ---- */ let loaded = false; async function load() { if (loaded) return; loaded = true; const rec = await get('kv', `clerk:${id}`).catch(() => null); if (rec?.list) clerk.push(...rec.list.filter((x) => !clerk.some((c) => c.id === x.id))); computeFirst(); } async function computeFirst() { try { const r = await core.rooms.room(s); const sealed = r.posts .filter((p) => p.evidence?.sealed && p.evidence?.wasThere) .sort((a, b) => (a.anchor?.t || a.at) - (b.anchor?.t || b.at)); const p = sealed[0]; firstEyes = p ? { handle: p.author.handle, at: p.anchor?.t || p.at, km: p.evidence.distanceKm, example: !!(p.example || p.author?.example), self: !!p.author?.self, post: p.id, } : null; if (s.momentCaptures?.length) { const fc = s.momentCaptures.find((c) => c.firstEyes) || [...s.momentCaptures].sort((a, b) => a.t - b.t)[0]; if (fc && (!firstEyes || fc.t < firstEyes.at)) firstEyes = { handle: fc.handle, at: fc.t, km: Number.isFinite(fc.lat) ? distKm(fc, s) : null, example: true, }; } ev.emit('first', firstEyes); } catch { /* ignore */ } } function start() { clearTimeout(stopTimer); if (sim.running) return; sim.start(); load().then(async () => { // One summary from what was already said, so the Record has one. if (!clerk.length) await runClerk({ backdate: 95e3 }); scheduleClerk(CLERK_FIRST_MS); }); const pts = []; for (let i = 0; i < 40; i += 1) pts.push({ ...worldPoint(), weight: 0.5 }); if (Number.isFinite(s.lat)) for (let i = 0; i < 20; i += 1) pts.push({ ...at(s, 0.3), weight: 1 }); globeHeat(core.globe, pts); } function stop() { sim.stop(); clearTimeout(clerkTimer); globeHeat(core.globe, null); } return { id, s, genre, log, clerk, reactions, on: ev.on, off: ev.off, push, send, react, corroborate, promote, runClerk, addCorro, get viewers() { return viewers + Math.max(0, core.rt.presence(id).length - 1); }, get firstEyes() { return firstEyes; }, refreshFirst: computeFirst, attach() { views += 1; start(); }, detach() { views = Math.max(0, views - 1); if (!views) { clearTimeout(stopTimer); stopTimer = setTimeout(stop, 15000); } }, burst: () => sim.burst(), corroCount: (mid) => corroBy.get(mid)?.size || 0, meCorroborated: (mid) => !!corroBy.get(mid)?.has(core.rt.me.handle), }; } /* ---------------- Rendering ---------------- */ const fmtN = (v) => v >= 10000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toLocaleString(); export function badgesHtml(m) { const b = []; if (m.mod) b.push(`${icon('lock')}`); if (m.first) b.push( `${icon('eyes')}`, ); if (m.witness || m.evidence) b.push( `${icon('sealFill')}`, ); if (m.domain) b.push( `${esc(m.domain.split(' ')[0].slice(0, 5))}`, ); return b.join(''); } export function lineHtml(m, me) { if (m.system) return `
${icon(m.clerk ? 'sparkles' : 'sendUp')}${esc(m.text)}${m.ref ? `` : ''}
`; const tier = m.tier || simTier(m.handle); const { html, mentionsMe } = richText(m.text, { me }); const cls = [ 'cl', m.self ? 'is-me' : '', m.remote ? 'is-remote' : '', mentionsMe ? 'mentions-me' : '', m.witness || m.evidence ? 'is-witness' : '', m.promoted ? 'is-promoted' : '', m.evidence ? 'has-ev' : '', ] .filter(Boolean) .join(' '); const corro = m.corro ? `${icon('agree')}${m.corro.toFixed(1)}` : ''; return `
${badgesHtml(m)}${esc(m.handle.replace(/^@/, ''))}: ${html}${m.evidence ? `${icon('sealFill')}${Number.isFinite(m.evidence.km) ? esc(fmtKm(m.evidence.km)) : 'SEALED'}` : ''}${corro}${m.promoted ? `${icon('sendUp')}Record` : ''}${m.remote ? 'other tab' : ''}
`; } function actionsHtml(m, hub) { const can = m.evidence || (m.corro || 0) >= 3; const mine = hub.meCorroborated(m.id); return `
${m.sim ? 'Simulated' : m.self ? 'You' : m.remote ? 'Real · other tab' : ''} · ${esc(utc(new Date(m.at)))}${m.tier ? ` · ${esc(m.tier.name)}` : ''}
`; } /** * Mount the Live lane into a sheet body + dock. * @param {object} core * @param {ReturnType} hub * @param {{ body: HTMLElement, dock: HTMLElement, onRecord?(ref), onNeedEyes?(), onCapture?() }} o */ export function mountLiveLane(core, hub, o) { const me = core.rt.me.handle; const root = h(`
`); const dockEl = h(`
${REACT_KINDS.map((k) => ``).join('')}
Simulated chat · demo— watching
`); o.body.appendChild(root); o.dock.appendChild(dockEl); const list = $('.lc-list', root); const pins = $('.lc-pins', root); const floatL = $('.lc-float', root); const more = $('.lc-more', root); const input = $('input', dockEl); const pop = $('.lc-pop', dockEl); const sendBtn = $('.lc-send', dockEl); let stuck = true; let unread = 0; let openId = null; let coolUntil = 0; /* ---- pins: First eyes + Need eyes ---- */ function renderPins() { const f = hub.firstEyes; const need = core.needs?.forRoom?.(hub.id)?.[0]; pins.innerHTML = `${ f ? `
${icon('sealFill')}First eyes ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}${f.example ? 'Example' : ''}
` : `
${icon('seal')}No first eyes yet. The first sealed capture from here gets the byline.
` }${ need ? `` : '' }`; } renderPins(); const offFirst = hub.on('first', renderPins); const offNeed = core.needs?.on?.('change', renderPins); /* ---- lines ---- */ function atBottom() { return list.scrollHeight - list.scrollTop - list.clientHeight < 48; } function scrollDown() { list.scrollTop = list.scrollHeight; } function add(m, { quiet } = {}) { const el = h(lineHtml(m, me)); if (!quiet && !reducedMotion()) el.classList.add('in'); list.appendChild(el); while (list.childElementCount > MAX_DOM) list.firstElementChild.remove(); if (stuck) scrollDown(); else if (!quiet) { unread += 1; more.hidden = false; $('span', more).textContent = `${unread} new message${unread === 1 ? '' : 's'}`; } if (!quiet && el.classList.contains('mentions-me')) haptic('light'); } for (const m of hub.log.slice(-80)) add(m, { quiet: true }); requestAnimationFrame(scrollDown); const offMsg = hub.on('msg', ({ msg, quiet }) => add(msg, { quiet })); const offUpd = hub.on('update', (m) => { const el = list.querySelector(`[data-msg="${CSS.escape(m.id)}"]`); if (!el) return; const wasOpen = openId === m.id; const nel = h(lineHtml(m, me)); el.replaceWith(nel); if (wasOpen) openActions(nel, m); if (m.corro) { const c = $('.cl-corro', nel); c?.classList.add('bump'); } }); list.addEventListener( 'scroll', () => { stuck = atBottom(); if (stuck) { unread = 0; more.hidden = true; } }, { passive: true }, ); more.addEventListener('click', () => { stuck = true; scrollDown(); more.hidden = true; unread = 0; }); /* ---- line actions ---- */ function closeActions() { $$('.cl-acts', list).forEach((x) => x.remove()); $$('.cl.open', list).forEach((x) => x.classList.remove('open')); openId = null; } function openActions(el, m) { closeActions(); openId = m.id; el.classList.add('open'); el.insertAdjacentHTML('afterend', actionsHtml(m, hub)); if (stuck) scrollDown(); else { // Scroll only the chat list (scrollIntoView would move the sheet too). const acts = el.nextElementSibling; const over = acts.getBoundingClientRect().bottom - list.getBoundingClientRect().bottom; if (over > 0) list.scrollTop += over + 8; } } list.addEventListener('click', async (e) => { const go = e.target.closest('[data-go-record]'); if (go) { o.onRecord?.(go.dataset.goRecord); return; } const act = e.target.closest('[data-cl]'); if (act) { const mid = act.closest('.cl-acts').dataset.for; const m = hub.log.find((x) => x.id === mid); if (!m) return; haptic('tick'); if (act.dataset.cl === 'reply') { input.value = `${m.handle} `; input.focus(); closeActions(); } else if (act.dataset.cl === 'corro') { if (hub.corroborate(m)) { toast( `Corroborated · your Signal weighs ×${tierOf(core.signal.cached).weight.toFixed(1)}`, { icon: icon('agree') }, ); } } else if (act.dataset.cl === 'promote') { const ok = await hub.promote(m); if (ok) { haptic('success'); toast('Promoted to the Record', { icon: icon('sendUp') }); closeActions(); } else toast( `Needs a sealed capture or ${3} Signal-weighted corroborations first`, { icon: icon('info') }, ); } return; } const line = e.target.closest('.cl:not(.cl-sys)'); if (line) { const m = hub.log.find((x) => x.id === line.dataset.msg); if (!m) return; if (openId === m.id) closeActions(); else openActions(line, m); } }); /* ---- pins actions ---- */ pins.addEventListener('click', (e) => { const nb = e.target.closest('[data-need]'); if (nb) core.needs?.openDetail?.(nb.dataset.need); }); /* ---- reactions ---- */ function float(kind, { small = false, from } = {}) { if (reducedMotion()) return; const el = h( `${reactSvg(kind)}`, ); floatL.appendChild(el); const dx = (Math.random() - 0.5) * 60; // Rise through the visible chat, not over the room's header. const room = Math.max(120, list.clientHeight + 30); const rise = Math.min( room, small ? 180 + Math.random() * 140 : 260 + Math.random() * 160, ); el.animate( [ { transform: 'translate(0,0) scale(.4)', opacity: 0 }, { transform: `translate(${dx * 0.4}px,-30px) scale(${small ? 0.8 : 1.15})`, opacity: 1, offset: 0.15, }, { transform: `translate(${-dx * 0.5}px,-${rise * 0.6}px) scale(${small ? 0.7 : 1})`, opacity: 0.9, offset: 0.6, }, { transform: `translate(${dx}px,-${rise}px) scale(${small ? 0.5 : 0.8})`, opacity: 0, }, ], { duration: small ? 2200 + Math.random() * 900 : 2600 + Math.random() * 800, easing: 'cubic-bezier(.2,.6,.3,1)', }, ).finished.then( () => el.remove(), () => el.remove(), ); if (floatL.childElementCount > 60) floatL.firstElementChild?.remove(); } const counts = () => REACT_KINDS.forEach((k) => { const el = $(`[data-rn="${k}"]`, dockEl); if (el) el.textContent = hub.reactions[k] ? fmtN(hub.reactions[k]) : ''; }); counts(); const offReact = hub.on('react', ({ kind, count, crowd, self, burst }) => { if (self) { float(kind); if (Math.random() < 0.5) setTimeout(() => float(kind), 90); } else { const k = Math.min(crowd ? (burst ? 5 : 2) : 1, count); for (let i = 0; i < k; i += 1) setTimeout(() => float(kind, { small: true }), i * 120); } counts(); }); let held = 0; dockEl.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-react]'); if (!b) return; // Hold to stream hearts. clearInterval(held); held = setInterval(() => hub.react(b.dataset.react), 160); const up = () => { clearInterval(held); removeEventListener('pointerup', up); removeEventListener('pointercancel', up); }; addEventListener('pointerup', up); addEventListener('pointercancel', up); }); /* ---- viewers + slow mode ---- */ const slow = () => (hub.viewers >= 1000 ? 3 : 0); function meta() { $('[data-viewers]', dockEl).textContent = hub.viewers.toLocaleString(); const sm = slow(); const real = core.rt.presence(hub.id).length - 1; $('[data-slow]', dockEl).textContent = real > 0 ? `${real} real ${real === 1 ? 'person' : 'people'} here (your tabs)` : sm ? `Slow mode ${sm} s` : ''; } meta(); const offView = hub.on('viewers', meta); const offBurst = hub.on('burst', () => root.classList.add('bursting')); const burstOff = setInterval(() => root.classList.remove('bursting'), 6000); /* ---- input, mentions, emotes ---- */ function showPop(kind) { if (kind === 'emotes') { pop.innerHTML = `
${EMOTE_CODES.map((c) => ``).join('')}
`; pop.hidden = false; pop.dataset.kind = 'emotes'; } else pop.hidden = true; } function mentionPop() { const m = /(^|\s)(@[\w.-]*)$/.exec(input.value); if (!m) { if (pop.dataset.kind === 'mention') pop.hidden = true; return; } const q = m[2].toLowerCase(); const seen = []; for (let i = hub.log.length - 1; i >= 0 && seen.length < 5; i -= 1) { const x = hub.log[i]; if (x.system || x.self) continue; if (x.handle.toLowerCase().startsWith(q) && !seen.includes(x.handle)) seen.push(x.handle); } if (!seen.length) { pop.hidden = true; return; } pop.dataset.kind = 'mention'; pop.innerHTML = `
${seen.map((x) => ``).join('')}
`; pop.hidden = false; } input.addEventListener('input', mentionPop); input.addEventListener('focus', () => { stuck = true; scrollDown(); }); dockEl.addEventListener('click', (e) => { const r = e.target.closest('[data-react]'); if (r) { haptic('tick'); hub.react(r.dataset.react); return; } const em = e.target.closest('[data-emote]'); if (em) { input.value = `${input.value.replace(/\s*$/, ' ')}${em.dataset.emote} `.trimStart(); input.focus(); pop.hidden = true; return; } const mn = e.target.closest('[data-mention]'); if (mn) { input.value = input.value.replace(/@[\w.-]*$/, `${mn.dataset.mention} `); input.focus(); pop.hidden = true; return; } const act = e.target.closest('[data-act]')?.dataset.act; if (act === 'emotes') { if (!pop.hidden && pop.dataset.kind === 'emotes') pop.hidden = true; else showPop('emotes'); } else if (act === 'need-eyes') o.onNeedEyes?.(); else if (act === 'capture-reply') o.onCapture?.(); }); $('form', dockEl).addEventListener('submit', (e) => { e.preventDefault(); const now = Date.now(); if (now < coolUntil) { toast(`Slow mode · ${Math.ceil((coolUntil - now) / 1000)} s`, { icon: icon('clock'), }); return; } const m = hub.send(input.value); if (!m) return; input.value = ''; pop.hidden = true; stuck = true; scrollDown(); haptic('tick'); const sm = slow(); if (sm) { coolUntil = now + sm * 1000; sendBtn.style.setProperty('--cool', `${sm}s`); sendBtn.classList.remove('cooling'); void sendBtn.offsetWidth; sendBtn.classList.add('cooling'); setTimeout(() => sendBtn.classList.remove('cooling'), sm * 1000); } }); hub.attach(); return { root, dock: dockEl, input, float, destroy() { [offMsg, offUpd, offReact, offView, offBurst, offFirst, offNeed].forEach( (f) => typeof f === 'function' && f(), ); clearInterval(burstOff); clearInterval(held); hub.detach(); root.remove(); dockEl.remove(); }, }; } /* ---------------- Moment overlay ---------------- */ /** * Twitch-style chat over the Moment player. Replays moment.chat on the * Moment's clock: from 'moment' events ({ type:'chat', item }) when the * engine sends them, and from the clock ('playback' / setTime) otherwise. */ export function mountChatOverlay(core, el, ctx = {}) { const root = h(`
Chat replay · simulated ${REACT_KINDS.map((k) => ``).join('')}
`); el.appendChild(root); const list = $('.co-list', root); const floatL = $('.co-float', root); let moment = null; let timeline = []; let cursor = 0; let lastT = 0; const shown = new Set(); const globe = ctx.globe || core.globe; let seqN = 0; let driven = false; // a player calls push(): ignore the engine's own clock const full = ctx.moment || null; const maxLines = ctx.maxLines || 9; if (maxLines > 9) root.classList.add('co-tall'); function line(m, { quiet = false } = {}) { const key = m.id || `${m.t}|${m.handle}`; if (shown.has(key)) return; shown.add(key); seqN += 1; const persona = personaOf(m.handle); const msg = { id: key, n: seqN, handle: m.handle || '@someone', text: m.text, at: m.t || Date.now(), sim: m.sim !== false, mod: m.badge === 'mod' || !!persona?.mod, witness: m.badge === 'witness' || m.badge === 'first', first: m.badge === 'first', domain: persona?.domain || null, tier: simTier(m.handle, persona?.floor || 0), self: !!m.self, }; const node = h(lineHtml(msg, core.rt.me.handle)); if (!quiet) node.classList.add('in'); list.appendChild(node); while (list.childElementCount > maxLines) list.firstElementChild.remove(); if (maxLines > 9) list.scrollTop = list.scrollHeight; // Lines fade after a while, as on a stream overlay. setTimeout(() => node.classList.add('old'), 9000); // Chat and hearts go together. if (!quiet && Math.random() < 0.6) crowdFloat(); } function crowdFloat(kind) { const k = kind || ['heart', 'eyes', 'wow', 'heart', 'fire'][Math.floor(Math.random() * 5)]; if (!reducedMotion()) { const e2 = h(`${reactSvg(k)}`); floatL.appendChild(e2); const dx = (Math.random() - 0.5) * 50; e2.animate( [ { transform: 'translate(0,0) scale(.4)', opacity: 0 }, { transform: `translate(${dx * 0.3}px,-30px) scale(.9)`, opacity: 1, offset: 0.15, }, { transform: `translate(${dx}px,-220px) scale(.6)`, opacity: 0 }, ], { duration: 2400, easing: 'cubic-bezier(.2,.6,.3,1)' }, ).finished.then( () => e2.remove(), () => e2.remove(), ); } if (moment && Number.isFinite(moment.lat)) globeReact(globe, { lon: moment.lon + (Math.random() - 0.5) * 0.02, lat: moment.lat + (Math.random() - 0.5) * 0.02, kind: k, count: 1 + Math.floor(Math.random() * 3), }); } function setTime(t) { const tt = +t; if (!Number.isFinite(tt) || !timeline.length) return; if (tt < lastT - 1000) { // Seeked backwards: rewind the overlay. list.innerHTML = ''; shown.clear(); cursor = 0; while (cursor < timeline.length && timeline[cursor].t <= tt - 30000) { shown.add(timeline[cursor].id); cursor += 1; } } lastT = tt; let k = 0; while (cursor < timeline.length && timeline[cursor].t <= tt) { const m = timeline[cursor]; cursor += 1; // Big jumps forward show only the last few lines. if (timeline.length - cursor < 400 && k < 12) line(m); else shown.add(m.id); k += 1; } } /** * setMoment(m): a new Moment, or (from a player that drives the overlay) * the same Moment with `chat` holding the lines said so far after a seek. */ function setMoment(m) { const same = moment && m && moment.id === m.id; moment = m; if (!same || !timeline.length) timeline = m ? momentChat(full?.id === m?.id && full.chat?.length ? full : m) : []; list.innerHTML = ''; shown.clear(); cursor = 0; lastT = +m?.time?.start || 0; // Lines already said: show the last few, quietly. const said = Array.isArray(m?.chat) ? m.chat : []; for (const c of said) shown.add(c.id || `${c.t}|${c.handle}`); for (const c of said.slice(maxLines > 9 ? -24 : -6)) { shown.delete(c.id || `${c.t}|${c.handle}`); line(c, { quiet: true }); } if (said.length) lastT = said[said.length - 1].t; } const onMoment = (e) => { if (!e || driven) return; if (e.type === 'chat' && e.item) line({ ...e.item, id: e.item.id || `${e.item.t}|${e.item.handle}` }); else if (Number.isFinite(+e.t) && e.type !== 'chat') setTime(e.t); if (e.type === 'capture') for (let i = 0; i < 6; i += 1) setTimeout(() => crowdFloat('wow'), i * 90); }; const onPlayback = (e) => !driven && e && Number.isFinite(+e.t) && setTime(e.t); const onTime = (e) => !driven && e?.time && setTime(+new Date(e.time)); globe?.on?.('moment', onMoment); globe?.on?.('playback', onPlayback); globe?.on?.('time', onTime); root.addEventListener('click', (e) => { const b = e.target.closest('[data-react]'); if (!b) return; haptic('tick'); crowdFloat(b.dataset.react); const loc = core.myLoc(); globeReact(globe, { lon: loc.lon, lat: loc.lat, kind: b.dataset.react, count: 1, }); }); if (ctx.moment) setMoment(ctx.moment); return { push(msg) { driven = true; line({ ...msg, t: msg.t || Date.now() }); }, setMoment, setTime, destroy() { globe?.off?.('moment', onMoment); globe?.off?.('playback', onPlayback); globe?.off?.('time', onTime); root.remove(); }, }; } export { makeMsg };