mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
1124 lines
36 KiB
JavaScript
1124 lines
36 KiB
JavaScript
/**
|
||
* 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(`<span class="cb cb-mod" title="Moderator">${icon('lock')}</span>`);
|
||
if (m.first)
|
||
b.push(
|
||
`<span class="cb cb-first" title="First eyes">${icon('eyes')}</span>`,
|
||
);
|
||
if (m.witness || m.evidence)
|
||
b.push(
|
||
`<span class="cb cb-wit" title="Sealed capture nearby">${icon('sealFill')}</span>`,
|
||
);
|
||
if (m.domain)
|
||
b.push(
|
||
`<span class="cb cb-dom tele" title="${esc(m.domain)} credential">${esc(m.domain.split(' ')[0].slice(0, 5))}</span>`,
|
||
);
|
||
return b.join('');
|
||
}
|
||
|
||
export function lineHtml(m, me) {
|
||
if (m.system)
|
||
return `<div class="cl cl-sys ${m.clerk ? 'is-clerk' : ''}" data-msg="${esc(m.id)}"${m.ref ? ` data-ref="${esc(m.ref)}"` : ''}>${icon(m.clerk ? 'sparkles' : 'sendUp')}<span>${esc(m.text)}</span>${m.ref ? `<button type="button" class="cl-go tele" data-go-record="${esc(m.ref)}">Open</button>` : ''}</div>`;
|
||
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
|
||
? `<span class="cl-corro tele" title="Signal-weighted corroboration">${icon('agree')}${m.corro.toFixed(1)}</span>`
|
||
: '';
|
||
return `<div class="${cls}" data-msg="${esc(m.id)}" style="--tc:${tier.color}">
|
||
<span class="cl-n tele" aria-hidden="true">L${m.n}</span>${badgesHtml(m)}<b class="cl-h">${esc(m.handle.replace(/^@/, ''))}</b><span class="cl-c">:</span> <span class="cl-t">${html}</span>${m.evidence ? `<span class="cl-ev tele">${icon('sealFill')}${Number.isFinite(m.evidence.km) ? esc(fmtKm(m.evidence.km)) : 'SEALED'}</span>` : ''}${corro}${m.promoted ? `<span class="cl-rec tele">${icon('sendUp')}Record</span>` : ''}${m.remote ? '<span class="cl-real tele">other tab</span>' : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function actionsHtml(m, hub) {
|
||
const can = m.evidence || (m.corro || 0) >= 3;
|
||
const mine = hub.meCorroborated(m.id);
|
||
return `<div class="cl-acts" data-for="${esc(m.id)}">
|
||
<button type="button" data-cl="reply">${icon('reply')}Reply</button>
|
||
<button type="button" data-cl="corro" aria-pressed="${mine}" ${m.self ? 'disabled' : ''}>${icon('agree')}${mine ? 'Corroborated' : 'Corroborate'}</button>
|
||
<button type="button" data-cl="promote" ${m.promoted ? 'disabled' : ''} class="${can ? 'can' : ''}">${icon('sendUp')}${m.promoted ? 'In the Record' : 'Promote'}</button>
|
||
<span class="cl-who tele">${m.sim ? 'Simulated' : m.self ? 'You' : m.remote ? 'Real · other tab' : ''} · ${esc(utc(new Date(m.at)))}${m.tier ? ` · ${esc(m.tier.name)}` : ''}</span>
|
||
</div>`;
|
||
}
|
||
|
||
/**
|
||
* Mount the Live lane into a sheet body + dock.
|
||
* @param {object} core
|
||
* @param {ReturnType<typeof createRoomHub>} 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(`<div class="lc" data-lane="live" data-genre="${esc(hub.genre)}">
|
||
<div class="lc-pins"></div>
|
||
<div class="lc-list" role="log" aria-label="Live chat" aria-live="off" data-no-drag></div>
|
||
<div class="lc-float" aria-hidden="true"></div>
|
||
<button type="button" class="lc-more tele" hidden>${icon('chevronDown')}<span>New messages</span></button>
|
||
</div>`);
|
||
const dockEl = h(`<div class="lc-dock">
|
||
<div class="lc-pop" hidden></div>
|
||
<div class="lc-react" role="group" aria-label="React">
|
||
${REACT_KINDS.map((k) => `<button type="button" class="lr" data-react="${k}" aria-label="${REACT[k].label}">${REACT[k].svg}<span class="num" data-rn="${k}"></span></button>`).join('')}
|
||
<span class="lc-sp"></span>
|
||
<button type="button" class="lc-ask" data-act="need-eyes">${icon('eyes')}<span>Need eyes</span></button>
|
||
<button type="button" class="lc-cap" data-act="capture-reply" aria-label="Reply with a sealed capture">${icon('viewfinder')}</button>
|
||
</div>
|
||
<form class="lc-input" autocomplete="off">
|
||
<button type="button" class="lc-emo" data-act="emotes" aria-label="Emotes">${EMOTES.SealIt.svg}</button>
|
||
<input type="text" maxlength="300" enterkeyhint="send" placeholder="Send a message" aria-label="Send a message" data-no-drag/>
|
||
<button type="submit" class="lc-send" aria-label="Send">${icon('sendUp')}<i class="lc-cool"></i></button>
|
||
</form>
|
||
<div class="lc-meta tele"><span class="lc-sim" title="Every handle in this chat except yours and other open tabs is fictional">Simulated chat · demo</span><span data-slow></span><span class="lc-view"><i></i><b data-viewers>—</b> watching</span></div>
|
||
</div>`);
|
||
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
|
||
? `<div class="lc-first">${icon('sealFill')}<span><b>First eyes</b> ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}</span>${f.example ? '<em class="badge-example">Example</em>' : ''}</div>`
|
||
: `<div class="lc-first none">${icon('seal')}<span><b>No first eyes yet.</b> The first sealed capture from here gets the byline.</span></div>`
|
||
}${
|
||
need
|
||
? `<button type="button" class="lc-need" data-need="${esc(need.id)}">${icon('eyes')}<span><b>Need eyes</b> “${esc(need.question)}”</span><em class="tele">${esc(need.distLabel || '')}</em></button>`
|
||
: ''
|
||
}`;
|
||
}
|
||
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(
|
||
`<span class="rf ${small ? 'sm' : ''}" style="--x:${from ?? Math.round(Math.random() * 40)}px">${reactSvg(kind)}</span>`,
|
||
);
|
||
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 = `<div class="emo-grid">${EMOTE_CODES.map((c) => `<button type="button" data-emote="${c}" title="${esc(EMOTES[c].hint)}">${EMOTES[c].svg}<span>${c}</span></button>`).join('')}</div>`;
|
||
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 = `<div class="men-list">${seen.map((x) => `<button type="button" data-mention="${esc(x)}" style="--tc:${simTier(x).color}">${esc(x)}</button>`).join('')}</div>`;
|
||
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(`<div class="co" aria-label="Moment chat">
|
||
<div class="co-list" role="log" aria-live="off"></div>
|
||
<div class="co-float" aria-hidden="true"></div>
|
||
<div class="co-bar">
|
||
<span class="co-tag tele">Chat replay · simulated</span>
|
||
${REACT_KINDS.map((k) => `<button type="button" data-react="${k}" aria-label="${REACT[k].label}">${REACT[k].svg}</button>`).join('')}
|
||
</div>
|
||
</div>`);
|
||
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(`<span class="rf sm">${reactSvg(k)}</span>`);
|
||
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 };
|