feat(mobile): Live and Record lanes, Signal, Need eyes and Crews

Every room has a Twitch-style Live lane (simulated crowd chat, emotes,
reactions that also rise from the globe, slow mode, witness glow) and a
Reddit/X-style Record (Signal-weighted corroborate/dispute, threads,
quotes, notes, synthesis). Chat lines with evidence or corroboration
promote to the Record, and a rules-based clerk summarizes chat with
citations. Signal is a receipts ledger; Need eyes requests reward a
sealed answer with a First eyes byline. Crews add persistent groups.
Simulated and example content is labelled throughout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:14 +00:00
1 parent f16a9fd365
commit a6f98ce3c1
22 files changed
+9247 -370

No files matched your search

+651
View File
@@ -0,0 +1,651 @@
/**
* God's Eye social layer (DESIGN.md §12.2, §12.3, §12.6): the mount points
* the shell integrates, and the one shared social core behind them.
*
* configureSocial(ctx) → core (call once from main.js)
* mountRoomsTab(el, ctx) → { refresh(), destroy() }
* openRoom(situationOrMoment, { lane: 'live'|'record', focusPost?, crew?, detent? })
* → Promise<{ sheet, panel, close() }>
* mountYouTab(el, ctx) → { render(), destroy() }
* chatOverlay(el, ctx) → { push(msg), setMoment(moment), setTime(t), destroy() }
* needEyesNear(lonlat, km = 25) → Request[] (sync; open requests first)
* onSocial(event, fn) → unsubscribe ('moment-capture' | 'promote' | 'clerk' | 'signal')
* momentCaptures(momentId) → Promise<Capture[]> (the person's captures posted into a Moment)
*
* ctx (every key optional, read late so it can be filled in after boot):
* { globe, store, rt, rooms, getSituations(), getLocation(), bindScroll(el),
* onCapture(situation, { needEyes?, replyTo? }), onOpenSituation(s),
* onPlayMoment(moment), onShowGlobe(situation), onVerify(bundle),
* onCredits(), watch: { summary()? , open()? }, sheetHost }
* Pass main.js's `rt` and `rooms` so the app keeps one realtime channel.
*/
import { icon } from './icons.js';
import { h, esc, emitter, toast, haptic, store as domStore } from './dom.js';
import { createSheet } from './sheet.js';
import { createRealtime } from './realtime.js';
import { createRooms } from './rooms.js';
import { createSignal, tierOf, RULE, simTier } from './signal.js';
import { createRoomHub, mountChatOverlay, guessLoc } from './liveChat.js';
import { createRoomPanel } from './room.js';
import { createNeeds, celebrate } from './needEyes.js';
import { createCrews } from './crews.js';
import { createComposer } from './composer.js';
import { PERSONAS, viewerCount } from './chatSim.js';
import { BAND_NAME, bandOf, fmtKm, utc, distKm } from './bands.js';
import { get, put } from '../capture/db.js';
/* ---------------- Styles (no index.html change needed) ---------------- */
function ensureCss() {
if (
typeof document === 'undefined' ||
document.getElementById('gev-social-css')
)
return;
const l = document.createElement('link');
l.id = 'gev-social-css';
l.rel = 'stylesheet';
l.href = new URL('../styles/social.css', import.meta.url).href;
document.head.appendChild(l);
}
/* ---------------- Moments ---------------- */
/** The hero Moment when the engine has none (illustrative, labelled). */
function heroFallback() {
const end = Date.now() - 4 * 60e3;
return {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
lon: -73.9857,
lat: 40.7484,
time: { start: end - 18 * 60e3, end },
illustrative: true,
tracks: [
{ id: 'n1', kind: 'helicopter', label: 'News 1', operator: 'News' },
{ id: 'n2', kind: 'helicopter', label: 'News 2', operator: 'News' },
{ id: 'p1', kind: 'helicopter', label: 'Police', operator: 'Police' },
],
captures: [],
chat: [],
posts: [],
sources: [
{ name: 'Illustrative reconstruction', detail: 'Synthetic tracks' },
],
};
}
export function isMoment(t) {
return (
!!t &&
!t.momentPosts &&
!!(t.tracks || t.cameraPath || (t.time && t.captures))
);
}
/** A Moment as a room: the same Live and Record lanes, on its evidence. */
export function momentRoom(m) {
const tracks = m.tracks || [];
const caps = m.captures || [];
const mins = Math.max(
1,
Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3),
);
return {
id: `moment:${m.id}`,
momentId: m.id,
moment: m,
kind: 'circling',
family: 'sky',
band: 'sky',
archetype: 'track',
title: m.title,
place: m.place,
subtitle: m.illustrative
? 'Illustrative reconstruction'
: 'Detected from live ADS-B',
lon: m.lon,
lat: m.lat,
live: !m.illustrative,
illustrative: !!m.illustrative,
updatedAt: +m.time?.end || Date.now(),
time: m.time,
source: m.illustrative ? 'Illustrative reconstruction' : 'ADS-B detection',
severity: 2,
metrics: [
{ label: 'Aircraft circling', value: tracks.length || 3 },
{ label: 'Sealed captures', value: caps.length },
{ label: 'Window', value: mins, unit: 'min' },
],
momentPosts: m.posts?.length ? m.posts : null,
momentCaptures: caps,
camera: m.cameraPath?.[0] || {
lon: m.lon,
lat: m.lat,
height: 2500,
heading: 0,
pitch: -35,
},
layers: [],
};
}
/* ---------------- The core ---------------- */
let core = null;
/**
* The shared social core. The first call builds it; later calls merge ctx.
* @param {object} ctx
*/
export function socialCore(ctx = {}) {
ensureCss();
if (core) {
Object.assign(core.ctx, strip(ctx));
if (ctx.globe && !core.globe) core.globe = ctx.globe;
return core;
}
core = buildCore(ctx);
return core;
}
export const configureSocial = socialCore;
/** Accept the shell's names too (openSituation, openMoment, openWatch). */
const strip = (o) => {
const x = Object.fromEntries(
Object.entries(o || {}).filter(([, v]) => v != null),
);
if (!x.onOpenSituation && x.openSituation)
x.onOpenSituation = x.openSituation;
if (!x.onPlayMoment && x.openMoment) x.onPlayMoment = x.openMoment;
if (!x.watch && x.openWatch) x.watch = { open: x.openWatch };
else if (x.watch && !x.watch.open && x.openWatch)
x.watch = Object.assign(Object.create(x.watch), { open: x.openWatch });
return x;
};
function buildCore(ctx) {
const ev = emitter();
const c = {
ctx: strip(ctx),
ev,
on: ev.on,
off: ev.off,
hubs: new Map(),
};
c.globe = ctx.globe || null;
c.store = ctx.store || domStore;
c.rt = ctx.rt || createRealtime();
c.rooms = ctx.rooms || createRooms({ rt: c.rt });
c.signal = createSignal({ me: c.rt.me });
c.signal.on('change', (x) => ev.emit('signal', x));
c.situations = async () => {
try {
if (c.ctx.getSituations) return (await c.ctx.getSituations()) || [];
if (c.globe?.situations) return (await c.globe.situations()) || [];
} catch {
/* ignore */
}
return [];
};
c.getLocation = () => c.ctx.getLocation?.() || null;
c.myLoc = () => c.getLocation() || c.rt.me.home || guessLoc();
c.hub = (s) => {
if (!s) return null;
const id = String(s.id);
if (!c.hubs.has(id)) c.hubs.set(id, createRoomHub(c, s));
return c.hubs.get(id);
};
c.onCapture = (s, o = {}) => {
roomSheet?.close();
if (c.ctx.onCapture) return c.ctx.onCapture(s, o);
// Fallback: switch to Capture and pre-attach through the shell's handle.
const cap = typeof window !== 'undefined' ? window.__gev?.capture : null;
if (c.ctx.selectTab) {
c.ctx.selectTab('capture');
setTimeout(() => (window.__gev?.capture || cap)?.attachTo?.(s, o), 60);
return null;
}
toast('Open Capture to answer with a sealed photo', {
icon: icon('viewfinder'),
});
return null;
};
/* Moments, feature-detected on the engine. */
const eng = () => c.globe?.raw || c.globe;
c.moments = async () => {
try {
const fn = c.globe?.moments || eng()?.moments;
if (typeof fn === 'function') {
const list = await fn.call(c.globe?.moments ? c.globe : eng());
if (Array.isArray(list) && list.length) return list;
}
} catch {
/* ignore */
}
return [heroFallback()];
};
c.moment = async (id) => {
try {
const fn = c.globe?.moment || eng()?.moment;
if (typeof fn === 'function') {
const m = await fn.call(c.globe?.moment ? c.globe : eng(), id);
if (m) return m;
}
} catch {
/* ignore */
}
return (await c.moments()).find((m) => m.id === id) || heroFallback();
};
/* Where the person has a sealed capture (for the Witness glow). */
const witnessRooms = new Set();
c.rooms
.myPosts()
.then((list) =>
list.forEach((p) => p.evidence?.sealed && witnessRooms.add(p.room)),
)
.catch(() => {});
c.myWitness = (room) => witnessRooms.has(String(room));
/* Simulated corroboration of the person's sealed posts (labelled). */
let simc = {};
get('kv', 'simcorro')
.then((r) => (simc = r?.map || {}))
.catch(() => {});
c.simCorro = (postId) => simc[postId] || [];
async function simulateCorroboration(s, post) {
const cast = [
...PERSONAS.local,
...PERSONAS.wx,
...PERSONAS.pilot,
...PERSONAS.photo,
...PERSONAS.skeptic,
].sort(() => Math.random() - 0.5);
const n = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < n; i += 1) {
const p = cast[i];
setTimeout(
async () => {
const t = simTier(p.handle, p.floor);
simc[post.id] = [
...(simc[post.id] || []),
{ handle: p.handle, w: t.weight, at: Date.now() },
];
await put('kv', { id: 'simcorro', map: simc }).catch(() => {});
const delta = Math.round(RULE.corroborated.delta * t.weight);
await c.signal.award({
id: `corro:${post.id}:${p.handle}`,
rule: 'corroborated',
delta,
text: `${p.handle} (${t.name}, ×${t.weight.toFixed(1)}) corroborated your capture in ${s.title} · simulated`,
ref: { room: String(s.id), post: post.id },
sim: true,
});
ev.emit('simcorro', { room: String(s.id), post: post.id });
if (i === 0)
toast(`${p.handle} corroborated your capture · +${delta} Signal`, {
icon: icon('agree'),
});
},
7000 + i * (3500 + Math.random() * 4000),
);
}
}
/**
* After a capture is posted (attach.js calls this): receipts, First eyes,
* Need eyes fulfilment, the byline moment, and the Moment timeline.
*/
c.afterPost = async (s, post, item = {}, { needEyes = null } = {}) => {
if (!post?.evidence?.sealed) return null;
witnessRooms.add(String(s.id));
const before = await c.signal.score();
const receipts = [];
const add = async (r) => {
const out = await c.signal.award(r);
if (out) receipts.push(out.receipt);
};
const ref = { room: String(s.id), post: post.id };
await add({
id: `sealed:${post.id}`,
rule: 'sealed',
text: `Sealed capture posted to ${s.title}`,
ref,
});
if (post.evidence.wasThere)
await add({
id: `there:${post.id}`,
rule: 'there',
text: `Inside the ${needEyes ? 'request' : 'event'} radius${Number.isFinite(post.evidence.distanceKm) ? ` · ${fmtKm(post.evidence.distanceKm)} ${needEyes ? 'from the spot' : 'away'}` : ''}`,
ref,
});
// First eyes on the room: no earlier sealed capture from inside it.
const r = await c.rooms.room(s).catch(() => null);
const earlier = (r?.posts || []).filter(
(p) => p.id !== post.id && p.evidence?.sealed && p.evidence?.wasThere,
);
const roomFirst = post.evidence.wasThere && !earlier.length;
let km = NaN;
if (Number.isFinite(post.evidence.distanceKm))
km = post.evidence.distanceKm;
else if (needEyes && post.anchor && Number.isFinite(post.anchor.lat))
km = distKm(post.anchor, needEyes);
let needFirst = false;
if (needEyes) {
const live = c.needs.byId(needEyes.id);
needFirst = !live?.fulfilledBy;
await c.needs.fulfil(needEyes, post, { km });
await add({
id: `fulfil:${needEyes.id}`,
rule: 'fulfil',
text: `Fulfilled Need eyes: “${needEyes.question}”`,
ref,
});
}
const first = roomFirst || needFirst;
if (first)
await add({
id: `first:${needEyes ? `need:${needEyes.id}` : s.id}`,
rule: 'first',
text: needEyes
? `First eyes on “${needEyes.question}”`
: `First eyes on ${s.title}`,
ref,
});
// Sensor corroboration: real contacts near the sealed position.
const a = post.anchor;
if (a && Number.isFinite(a.lat) && c.globe?.contactsNear) {
const near = (c.globe.contactsNear(a.lon, a.lat, 8) || []).filter((x) =>
/aircraft|helicopter|ship|vessel/.test(x.kind),
);
if (near.length) {
const air = near.filter((x) =>
/aircraft|helicopter/.test(x.kind),
).length;
await add({
id: `sensor:${post.id}`,
rule: 'sensor',
text: `${air ? `ADS-B: ${air} aircraft` : `AIS: ${near.length} vessels`} within 8 km at the sealed time`,
ref,
source: air ? 'ADS-B' : 'AIS',
});
}
}
const after = await c.signal.score();
const handle = c.rt.me.handle;
const at = post.anchor?.t || post.at;
const byline = `${first ? 'FIRST EYES' : 'SEALED'} · ${handle} · ${utc(new Date(at))}${Number.isFinite(km) ? ` · ${fmtKm(km).toUpperCase()}` : ''}`;
// The Live lane hears about it.
c.hubs.get(String(s.id))?.push({
id: `sys-cap-${post.id}`,
system: true,
handle: 'Record',
text: `${handle} posted a sealed capture${Number.isFinite(km) ? ` from ${fmtKm(km)}` : ''}${first ? ' · First eyes' : ''}.`,
at: Date.now(),
ref: post.id,
});
c.hubs.get(String(s.id))?.refreshFirst();
// A Moment's timeline picks it up.
const mid = s.momentId || activeMomentId();
if (mid) {
const cap = {
id: `capture:${mid}:${post.id}`,
t: at,
lon: a?.lon,
lat: a?.lat,
headingDeg: post.evidence.headingDeg ?? null,
fovDeg: 62,
title: post.text,
handle,
sealed: true,
firstEyes: first,
post: post.id,
self: true,
};
const rec = await get('kv', 'momentCaptures').catch(() => null);
const map = rec?.map || {};
map[mid] = [...(map[mid] || []).filter((x) => x.id !== cap.id), cap];
await put('kv', { id: 'momentCaptures', map }).catch(() => {});
const fn = c.globe?.addMomentCapture || eng()?.addMomentCapture;
if (typeof fn === 'function')
try {
fn.call(c.globe?.addMomentCapture ? c.globe : eng(), mid, cap);
} catch {
/* optional */
}
ev.emit('moment-capture', { momentId: mid, capture: cap });
}
simulateCorroboration(s, post);
const total = receipts.reduce((x, y) => x + (y.delta || 0), 0);
if (needEyes || first)
celebrate({
byline,
first,
need: needEyes,
receipts,
before,
after,
onRecord: () => openRoom(s, { lane: 'record', focusPost: post.id }),
});
else if (total)
toast(`+${total} Signal · ${receipts.length} receipts`, {
icon: icon('sparkles'),
});
return { receipts, before, after, first };
};
function activeMomentId() {
try {
const fn = c.globe?.activeMoment || eng()?.activeMoment;
return typeof fn === 'function'
? fn.call(c.globe?.activeMoment ? c.globe : eng())?.id || null
: null;
} catch {
return null;
}
}
/* Cross-tab traffic into the right hub. */
c.rt.on('chat', ({ room, msg }) => {
const hub = c.hubs.get(String(room));
if (hub && msg) hub.push({ ...msg, tier: msg.tier || tierOf(0) });
});
c.rt.on('react', ({ room, kind, remote }) => {
const hub = c.hubs.get(String(room));
if (hub && ['heart', 'fire', 'eyes', 'wow'].includes(kind))
hub.react(kind, { remote });
});
c.rt.on('corro', ({ room, id, w }) => {
const hub = c.hubs.get(String(room));
const m = hub?.log.find((x) => x.id === id);
if (m) hub.addCorro(m, `tab:${id}:${Math.random()}`, w || 1);
});
c.needs = createNeeds(c);
c.crews = createCrews(c);
c.openRoom = (t, o) => openRoom(t, o);
c.composer = null;
c.getComposer = () => {
// Modal sheets made earlier must still open above the room sheet.
const r = c.composer?.root;
r?.parentElement?.appendChild(r);
return (c.composer =
c.composer ||
createComposer({
rooms: c.rooms,
getTime: () => c.ctx.getTime?.() || null,
getLocation: c.getLocation,
getView: () => null,
onCapture: (s, o) => c.onCapture(s, o),
onPosted: () => {},
}));
};
return c;
}
/* ---------------- openRoom ---------------- */
let roomSheet = null;
let openPanel = null;
/**
* Open a room sheet in a lane. Accepts a Situation or a Moment.
* @param {object} target
* @param {{ lane?: 'live'|'record', focusPost?: string, crew?: object, detent?: string }} o
*/
export async function openRoom(target, o = {}) {
const c = socialCore();
if (!target) return null;
const s = isMoment(target) ? momentRoom(target) : target;
const lane = o.lane === 'record' ? 'record' : 'live';
if (!roomSheet)
roomSheet = createSheet({
host:
c.ctx.sheetHost || document.getElementById('sheets') || document.body,
label: 'Room',
detents: ['medium', 'large'],
dismissible: true,
closeButton: true,
className: 'room-sheet',
heights: {
medium: (H) => Math.round(H * 0.56),
large: (H) => Math.round(H * 0.94),
},
});
const sheet = roomSheet;
openPanel?.destroy();
openPanel = null;
let dock = sheet.sheet.querySelector('.sheet-dock');
if (!dock) {
dock = h('<div class="sheet-dock"></div>');
sheet.sheet.appendChild(dock);
}
dock.innerHTML = '';
sheet.body.innerHTML = '';
const band = bandOf(s);
const hub = c.hub(s);
sheet.sheet.dataset.band = band;
sheet.head.innerHTML = `<div class="rs-top tele" data-band="${band}"><i class="bd"></i><span>${esc(BAND_NAME[band] || 'Room')}</span><span class="rs-live"><i></i>${s.live === false && !s.momentId ? 'Room' : 'Live room'}</span><span>·</span><span class="rs-place">${esc(s.place || '')}</span></div>
<h2 class="sheet-title">${esc(s.title)}</h2>
${o.crew ? `<div class="rs-crew" style="--hue:${o.crew.hue}">${icon('users')}<span>Watching with <b>${esc(o.crew.name)}</b> · ${12 + (o.crew.members % 40)} crew here</span><em class="badge-example">Example</em></div>` : ''}
${s.illustrative ? '<div class="rs-ill tele">Illustrative reconstruction · tracks are synthetic</div>' : ''}`;
const panel = createRoomPanel(c, s, {
body: sheet.body,
dock,
switchHost: sheet.head,
lane,
focusPost: o.focusPost,
onCompose: (x, y) => c.getComposer().open(x, y),
onCapture: (x, y) => {
sheet.close();
c.onCapture(x, y);
},
flyTo: (a) => {
sheet.setDetent('medium');
c.globe?.flyTo?.({
lon: a.lon,
lat: a.lat,
height: 2500,
pitch: -45,
duration: 1.6,
});
},
scrub: null,
onLane: (l) => {
sheet.sheet.dataset.lane = l;
},
});
openPanel = panel;
const detent =
o.detent || (lane === 'live' && c.ctx.onShowGlobe ? 'medium' : 'large');
if (c.ctx.onShowGlobe) c.ctx.onShowGlobe(s);
sheet.open(detent);
const offClose = sheet.on('close', () => {
offClose?.();
if (openPanel === panel) {
panel.destroy();
openPanel = null;
}
});
haptic('light');
void hub;
return { sheet, panel, close: () => sheet.close() };
}
/* ---------------- Tabs ---------------- */
export function mountRoomsTab(el, ctx = {}) {
const c = socialCore(ctx);
let api = null;
const p = import('./roomsTab.js').then(
(m) => (api = m.createRoomsTab(el, c)),
);
return {
refresh: () => p.then(() => api?.refresh()),
destroy: () => p.then(() => api?.destroy()),
};
}
export function mountYouTab(el, ctx = {}) {
const c = socialCore(ctx);
let api = null;
const p = import('./you.js').then((m) => {
api = m.createYou(el, {
bindScroll: c.ctx.bindScroll || (() => {}),
getSituations: c.situations,
onOpenSituation: (s) => c.ctx.onOpenSituation?.(s),
onVerify: (b) => c.ctx.onVerify?.(b),
onCredits: () => c.ctx.onCredits?.(),
rooms: c.rooms,
rt: c.rt,
onOpenRoom: (s, postId) =>
openRoom(s, { lane: 'record', focusPost: postId }),
onLocate: async () => c.ctx.onLocate?.() || c.getLocation(),
core: c,
});
api.render();
return api;
});
return {
render: () => p.then(() => api?.render()),
destroy: () => p.then(() => (el.innerHTML = '')),
};
}
/** Twitch-style chat overlay for the Moment player. */
export function chatOverlay(el, ctx = {}) {
const c = socialCore(ctx);
return mountChatOverlay(c, el, {
globe: ctx.globe || c.globe,
moment: ctx.moment,
});
}
/** Open Need eyes requests near a point (sync; for Today and Capture). */
export function needEyesNear(lonlat, km = 25) {
const c = socialCore();
return c.needs.near(lonlat, km);
}
export function onSocial(event, fn) {
return socialCore().on(event, fn);
}
export async function momentCaptures(momentId) {
const rec = await get('kv', 'momentCaptures').catch(() => null);
return rec?.map?.[momentId] || [];
}
export function openCrew(id, o) {
return socialCore().crews.open(id, o);
}
export function openNeedEyes(situation) {
return socialCore().needs.openCreate(situation);
}
export function signalNow() {
const c = socialCore();
return { score: c.signal.cached, tier: c.signal.tier() };
}
export { celebrate, viewerCount };