LAT — LON —
@@ -161,7 +162,7 @@ export function createCapture(
let dpr = 1;
let lastCapture = null;
let netOffset = null;
- let target = null; // { s, replyTo } — the room this capture answers
+ let target = null; // { s, replyTo, needEyes } — the room this capture answers
let live = null; // { session, id, t0, pin, frameTimer }
const VFOV = 62;
const MODES = ['look', 'witness', 'live', 'upload'];
@@ -222,6 +223,7 @@ export function createCapture(
function enter() {
active = true;
resize();
+ setTimeout(() => needsLoop(), 0);
let seen = false;
try {
seen = sessionStorage.getItem(GATE_KEY) === '1';
@@ -1001,7 +1003,7 @@ export function createCapture(
Heading ${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}
Media ${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB
-
${icon('sendUp')}${target ? 'Post to the room' : 'Post to a room'} ${icon('seal')}Verify Keep private
`,
+
${icon(target?.needEyes ? 'eyes' : 'sendUp')}${target?.needEyes ? 'Answer the request' : target ? 'Post to the room' : 'Post to a room'} ${icon('seal')}Verify Keep private
`,
);
}
@@ -1038,7 +1040,11 @@ export function createCapture(
headingDeg: bundle.session?.heading?.deg ?? null,
segments: bundle.session?.segments?.length || 0,
},
- { preset: target?.s || null, replyTo: target?.replyTo || null },
+ {
+ preset: target?.s || null,
+ replyTo: target?.replyTo || null,
+ needEyes: target?.needEyes || null,
+ },
);
}
@@ -1333,7 +1339,11 @@ export function createCapture(
isVideo,
headingDeg: exif?.headingDeg ?? null,
},
- { preset: target?.s || null, replyTo: target?.replyTo || null },
+ {
+ preset: target?.s || null,
+ replyTo: target?.replyTo || null,
+ needEyes: target?.needEyes || null,
+ },
);
};
}
@@ -1347,16 +1357,77 @@ export function createCapture(
}
/* ---------- reply target ---------- */
- function attachTo(s, { replyTo = null } = {}) {
- target = s ? { s, replyTo } : null;
+ function attachTo(s, { replyTo = null, needEyes = null } = {}) {
+ target = s ? { s, replyTo, needEyes } : null;
targetChip.hidden = !target;
+ targetChip.classList.toggle('is-need', !!needEyes);
if (target) {
- targetChip.innerHTML = `${icon('reply')}
Answering in ${esc(s.title)} ${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''} ${icon('close')}`;
- if (mode === 'look') setMode('witness');
+ targetChip.innerHTML = needEyes
+ ? `${icon('eyes')}
Need eyes · ${esc(needEyes.question.length > 46 ? `${needEyes.question.slice(0, 45)}…` : needEyes.question)} ${icon('close')}`
+ : `${icon('reply')}
Answering in ${esc(s.title)} ${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''} ${icon('close')}`;
+ if (mode === 'look' || mode === 'upload') setMode('witness');
}
+ renderNeeds();
}
targetChip.addEventListener('click', () => attachTo(null));
+ /* ---------- Need eyes near you (top of Capture) ---------- */
+ const needsEl = $('.cap-needs', view);
+ let needs = [];
+ let needsOpen = false;
+ async function refreshNeeds() {
+ try {
+ const m = await import('./social.js');
+ const o = observer();
+ const ll = o.simulated ? getLocation?.() || m.socialCore().myLoc() : o;
+ needs = m
+ .needEyesNear({ lat: ll.lat, lon: ll.lon }, 30)
+ .filter((r) => r.status === 'open');
+ } catch {
+ needs = [];
+ }
+ renderNeeds();
+ }
+ function renderNeeds() {
+ const list = needs.filter((r) => r.id !== target?.needEyes?.id);
+ needsEl.hidden = !list.length || mode === 'live';
+ if (needsEl.hidden) return;
+ const r0 = list[0];
+ needsEl.innerHTML = `
${icon('eyes')}${list.length} Need eyes near you “${esc(r0.question.length > 44 ? `${r0.question.slice(0, 43)}…` : r0.question)}” · ${esc(r0.distLabel || '')} ${icon(needsOpen ? 'chevronUp' : 'chevronDown')} ${
+ needsOpen
+ ? `
${list
+ .slice(0, 4)
+ .map(
+ (r) =>
+ `“${esc(r.question)}” ${esc(r.distLabel || '')} · ${esc(r.by)} · +40 SIGNAL${r.example ? ' · EXAMPLE' : ''} Answer `,
+ )
+ .join('')}
`
+ : ''
+ }`;
+ }
+ needsEl.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-cn]');
+ if (!b) return;
+ haptic('tick');
+ if (b.dataset.cn === 'toggle') {
+ needsOpen = !needsOpen;
+ renderNeeds();
+ return;
+ }
+ const r = needs.find((x) => x.id === b.dataset.id);
+ if (!r) return;
+ needsOpen = false;
+ attachTo(r.situation, { needEyes: r });
+ showStatus('Seal a photo from inside the radius to answer', 3200);
+ });
+ let needsTimer = 0;
+ const needsLoop = () => {
+ clearTimeout(needsTimer);
+ if (!active) return;
+ refreshNeeds();
+ needsTimer = setTimeout(needsLoop, 30000);
+ };
+
return {
enter,
leave,
diff --git a/mobile/ui/chatSim.js b/mobile/ui/chatSim.js
new file mode 100644
index 000000000..fb32fb6fc
--- /dev/null
+++ b/mobile/ui/chatSim.js
@@ -0,0 +1,1089 @@
+/**
+ * Simulated Live chat (DESIGN.md §12.6, §12.8 "Honest labels").
+ *
+ * A believable crowd for the demo: per-genre pools written like real people
+ * on the ground and in the hobby (pilots, locals, weather nerds, skeptics,
+ * jokers, moderators), realistic pacing with bursts, and replies to what the
+ * person types. Every handle is fictional and every message is flagged
+ * `sim: true`; the lane that shows them is labelled "Simulated chat · demo".
+ * Numbers quoted in messages come only from the situation's live metrics.
+ */
+
+import { fmtNum } from './dom.js';
+import { headlineOf, fmtKm, cardinal } from './bands.js';
+import { simTier, hashStr } from './signal.js';
+
+/* ---------------- Personas (fictional) ---------------- */
+
+export const PERSONAS = {
+ mod: [
+ { handle: '@deskmod_rae', mod: true, floor: 900 },
+ { handle: '@nightdesk_mod', mod: true, floor: 760 },
+ ],
+ pilot: [
+ { handle: '@flaps30', domain: 'Pilot', floor: 420 },
+ { handle: '@heavy_tess', domain: 'Pilot', floor: 640 },
+ { handle: '@atc_listener', floor: 220 },
+ ],
+ wx: [
+ { handle: '@wxdan', domain: 'Met', floor: 520 },
+ { handle: '@cape_and_shear', floor: 300 },
+ { handle: '@radar_rina', domain: 'Met', floor: 820 },
+ ],
+ local: [
+ { handle: '@onthe5th', local: true, floor: 140 },
+ { handle: '@harborkid_22', local: true, floor: 90 },
+ { handle: '@ridge_rd_amy', local: true, floor: 260 },
+ { handle: '@corner_deli_mo', local: true, floor: 40 },
+ ],
+ skeptic: [
+ { handle: '@actually_tho', floor: 380 },
+ { handle: '@sourcepls', floor: 560 },
+ ],
+ joker: [
+ { handle: '@lagspike', floor: 0 },
+ { handle: '@pigeonwatch', floor: 60 },
+ { handle: '@buffering_bev', floor: 10 },
+ ],
+ orbit: [
+ { handle: '@tle_tom', floor: 700 },
+ { handle: '@pass_predictor', floor: 330 },
+ ],
+ sea: [{ handle: '@bridgewing_ko', domain: 'Mariner', floor: 610 }],
+ medic: [
+ { handle: '@medic_off_shift', domain: 'First responder', floor: 480 },
+ ],
+ scanner: [
+ { handle: '@scanner_sal', floor: 350 },
+ { handle: '@freq_hopper', floor: 180 },
+ ],
+ photo: [{ handle: '@longlens_ben', floor: 290 }],
+ newbie: [
+ { handle: '@first_time_here', floor: 0 },
+ { handle: '@lurker9', floor: 0 },
+ { handle: '@k8_mobile', floor: 20 },
+ { handle: '@just_scrolling', floor: 0 },
+ ],
+};
+const ALL = Object.entries(PERSONAS).flatMap(([role, list]) =>
+ list.map((p) => ({ ...p, role })),
+);
+export const personaOf = (handle) => ALL.find((p) => p.handle === handle);
+
+/** Where watchers are (for "hi from …" lines and crowd reactions). */
+export const WORLD = [
+ ['Lisbon', 38.72, -9.14, 3],
+ ['Manila', 14.6, 120.98, 6],
+ ['Lagos', 6.52, 3.38, 5],
+ ['São Paulo', -23.55, -46.63, 6],
+ ['Mumbai', 19.08, 72.88, 7],
+ ['Jakarta', -6.2, 106.85, 6],
+ ['Tokyo', 35.68, 139.69, 7],
+ ['Seoul', 37.57, 126.98, 5],
+ ['London', 51.51, -0.13, 6],
+ ['Berlin', 52.52, 13.4, 4],
+ ['Toronto', 43.65, -79.38, 4],
+ ['Chicago', 41.88, -87.63, 4],
+ ['Mexico City', 19.43, -99.13, 6],
+ ['Sydney', -33.87, 151.21, 4],
+ ['Nairobi', -1.29, 36.82, 3],
+ ['Cairo', 30.04, 31.24, 5],
+ ['Istanbul', 41.01, 28.98, 5],
+ ['Denver', 39.74, -104.99, 3],
+ ['Seattle', 47.61, -122.33, 3],
+ ['Austin', 30.27, -97.74, 3],
+ ['Houston', 29.76, -95.37, 4],
+ ['Miami', 25.76, -80.19, 3],
+ ['New York', 40.71, -74.01, 7],
+ ['Los Angeles', 34.05, -118.24, 6],
+ ['Paris', 48.86, 2.35, 5],
+ ['Madrid', 40.42, -3.7, 4],
+ ['Warsaw', 52.23, 21.01, 3],
+ ['Helsinki', 60.17, 24.94, 2],
+ ['Bangkok', 13.76, 100.5, 5],
+ ['Singapore', 1.35, 103.82, 4],
+ ['Taipei', 25.03, 121.57, 4],
+ ['Auckland', -36.85, 174.76, 2],
+ ['Bogotá', 4.71, -74.07, 4],
+ ['Johannesburg', -26.2, 28.05, 3],
+ ['Dubai', 25.2, 55.27, 3],
+ ['Reykjavík', 64.15, -21.94, 1],
+];
+const WORLD_W = WORLD.reduce((a, w) => a + w[3], 0);
+export function worldPoint(rand = Math.random) {
+ let x = rand() * WORLD_W;
+ for (const w of WORLD) {
+ x -= w[3];
+ if (x <= 0)
+ return {
+ city: w[0],
+ lat: w[1] + (rand() - 0.5) * 1.6,
+ lon: w[2] + (rand() - 0.5) * 1.6,
+ };
+ }
+ return { city: WORLD[0][0], lat: WORLD[0][1], lon: WORLD[0][2] };
+}
+
+/* ---------------- Genre ---------------- */
+
+export function chatGenre(s) {
+ const k =
+ `${s?.kind || ''} ${s?.archetype || ''} ${s?.title || ''}`.toLowerCase();
+ if (s?.momentId || /circling|moment|climber/.test(k)) return 'circling';
+ if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone';
+ if (/quake/.test(k)) return 'quake';
+ if (/fire|volcano/.test(k)) return 'fire';
+ if (/lightning|rain|storm|radar/.test(k)) return 'storm';
+ if (/squawk/.test(k)) return 'squawk';
+ if (/gnss|jam/.test(k)) return 'gnss';
+ if (/launch/.test(k)) return 'launch';
+ if (/iss|pass|aurora|orbit|starlink/.test(k)) return 'orbit';
+ if (/camera|cctv|eye/.test(k)) return 'eye';
+ if (/queue|strait|port|ship|vessel/.test(k)) return 'sea';
+ if (/heli|rotor/.test(k)) return 'heli';
+ if (/airport|military|jet|flight|tanker/.test(k)) return 'air';
+ return 'default';
+}
+
+/* ---------------- Metric helpers ---------------- */
+
+function fmtMetric(m) {
+ if (!m) return '';
+ const v =
+ typeof m.value === 'number'
+ ? fmtNum(m.value, m.value % 1 ? 1 : 0)
+ : String(m.value ?? '');
+ return `${v}${m.unit ? ` ${m.unit}` : ''}`.trim();
+}
+function vars(s) {
+ const ms = s.metrics || [];
+ const hl = headlineOf(s);
+ const find = (k) =>
+ ms.find((m) =>
+ String(m.label).toLowerCase().startsWith(String(k).toLowerCase()),
+ );
+ return {
+ place: s.place || s.title || 'there',
+ title: s.title || '',
+ m0: fmtMetric(ms[0]),
+ m1: fmtMetric(ms[1]),
+ m2: fmtMetric(ms[2]),
+ l0: String(ms[0]?.label || '').toLowerCase(),
+ l1: String(ms[1]?.label || '').toLowerCase(),
+ hl: hl ? `${hl.value}${hl.unit ? ` ${hl.unit}` : ''}` : '',
+ hll: hl?.label || '',
+ dir: cardinal(s.media?.headingDeg) || '',
+ find: (k) => fmtMetric(find(k)),
+ };
+}
+
+/** "{m0}" style templates; returns '' if any placeholder is empty. */
+export function fill(t, V, extra = {}) {
+ let bad = false;
+ const out = t.replace(/\{([\w:]+)\}/g, (_, key) => {
+ let v = '';
+ if (key.startsWith('m:')) v = V.find(key.slice(2));
+ else v = extra[key] ?? V[key] ?? '';
+ if (!v) bad = true;
+ return v;
+ });
+ return bad ? '' : out;
+}
+
+/* ---------------- Pools ---------------- */
+// [role, text]. Text may use {place} {title} {m0} {m1} {hl} {hll} {m:Label}.
+
+const COMMON = [
+ ['newbie', 'first time in a live room, this is wild'],
+ ['newbie', 'how is everyone seeing this so fast'],
+ ['newbie', 'what does the gold ring next to some names mean?'],
+ [
+ 'mod',
+ 'Gold ring = that person has a sealed capture near this. Their chat glows, it doesn’t mean they’re right SealIt',
+ ],
+ ['joker', 'my boss thinks I’m in a spreadsheet rn'],
+ ['joker', 'chat moving faster than my wifi'],
+ [
+ 'skeptic',
+ 'reminder that chat is not a source. Record lane or it didn’t happen',
+ ],
+ ['newbie', 'is the map live or a replay?'],
+ [
+ 'mod',
+ 'Map is live. Anything with a seal is a real capture; anything that says Example or Simulated is demo content',
+ ],
+ ['local', 'refreshing this instead of sleeping, as one does'],
+ [
+ 'photo',
+ 'if anyone nearby has a long lens, the Need eyes pins are where it’s useful',
+ ],
+ ['newbie', 'o7 from the night shift'],
+ ['joker', 'Receipts or it’s fan fiction'],
+];
+
+const GREET = [
+ 'hi from {city}',
+ 'watching from {city} o7',
+ '{city} checking in',
+ 'evening from {city}',
+ 'morning from {city}, coffee + this',
+];
+
+const POOLS = {
+ circling: [
+ [
+ 'scanner',
+ 'NYPD aviation on the Special Ops freq, they’re holding over 34th',
+ ],
+ [
+ 'local',
+ 'I can see two of them from my office, both orbiting tight around the top of the building',
+ ],
+ [
+ 'pilot',
+ 'that orbit radius looks like ~500 m, classic news-helo orbit for a stable shot',
+ ],
+ [
+ 'local',
+ 'there are people on the observation deck pointing UP. something is on the mast',
+ ],
+ [
+ 'photo',
+ 'from 5th Ave with a 400mm you can see two figures above the 86th floor deck EyesUp',
+ ],
+ ['skeptic', 'two figures according to who? show me a sealed capture'],
+ [
+ 'local',
+ 'sealed a clip from the 34th St side, posting to Record SealIt',
+ { evidence: 0.25 },
+ ],
+ ['joker', 'King Kong reboot looking real low budget'],
+ [
+ 'pilot',
+ 'police heli is lower, around 1,000 ft, news birds stacked above at 1,500',
+ ],
+ ['scanner', 'ESU requested to the 86th floor, that’s rope rescue'],
+ [
+ 'mod',
+ 'Please don’t share names of anyone on the building. Slow mode is on.',
+ ],
+ ['newbie', 'why are there 3 helicopters for 2 people'],
+ [
+ 'pilot',
+ 'one police, two news. the news ones are there for the shot, not the rescue',
+ ],
+ ['local', 'crowd forming on 34th, cops pushing everyone back to Broadway'],
+ [
+ 'skeptic',
+ 'the climbers thing is unconfirmed until someone up close seals it. could be maintenance crew',
+ ],
+ ['local', 'maintenance doesn’t wave a banner HeliSpin'],
+ [
+ 'photo',
+ 'banner says something, can’t read it, the wind keeps flipping it',
+ ],
+ ['scanner', 'they’re calling it a “protest climb” on the radio now'],
+ ['joker', 'imagine commuting to work and it’s this'],
+ [
+ 'local',
+ 'i’m at 1 Penn, 30th floor, perfect angle. sealing now',
+ { evidence: 0.6 },
+ ],
+ ],
+ cyclone: [
+ [
+ 'wx',
+ 'eyewall replacement explains why the peak dipped but the wind field got wider',
+ ],
+ [
+ 'wx',
+ 'sustained {m:Wind} is still a serious storm, don’t let the dip fool you',
+ ],
+ ['local', 'power flickering here twice in the last ten minutes'],
+ [
+ 'local',
+ 'outer bands hit the waterfront, chairs on the pier are sliding StormMode',
+ ],
+ ['skeptic', 'whoever is posting Cat 5 clips: that’s from 2013'],
+ [
+ 'wx',
+ 'pressure {m:Pressure} — watch whether it falls again after the EWRC',
+ ],
+ ['newbie', 'is it going to hit Manila?'],
+ ['wx', 'the cone is wide this far out. look at the cone, not the line'],
+ [
+ 'mod',
+ 'If you’re in the warning area: follow PAGASA, not chat. We’re a window, not an alert service.',
+ ],
+ ['joker', 'StormMode activated, snacks acquired'],
+ [
+ 'local',
+ 'my uncle in Aparri says the sea is already over the seawall',
+ { evidence: null },
+ ],
+ ['sea', 'ships are leaving the anchorage, that’s a tell'],
+ ['photo', 'IR loop is gorgeous, eye is clearing again EyesUp'],
+ [
+ 'skeptic',
+ '“my uncle says” is not evidence, but a sealed photo from him would be',
+ ],
+ [
+ 'local',
+ 'sealed a clip of the harbour from the 3rd floor, posting',
+ { evidence: 3.8 },
+ ],
+ ],
+ storm: [
+ ['wx', '{m0} in the last hour, that’s a very electric line'],
+ [
+ 'local',
+ 'constant flashes to the south, thunder every couple seconds StormMode',
+ ],
+ ['local', 'gutters already overflowing on my street'],
+ ['wx', 'the core is moving with the line, maybe 40 min to the coast'],
+ ['newbie', 'is it safe to drive home now?'],
+ [
+ 'medic',
+ 'if you can wait 30–40 min, wait. most crashes in these are in the first wave of rain',
+ ],
+ [
+ 'skeptic',
+ 'that “tornado” photo going around is from Oklahoma last spring',
+ ],
+ [
+ 'photo',
+ 'got a lightning shot from the parking garage, 8 s exposure',
+ { evidence: 2.1 },
+ ],
+ ['joker', 'my dog has filed a formal complaint'],
+ ['wx', 'peak density {m1}, wow'],
+ ['local', 'power just went out on our block'],
+ [
+ 'mod',
+ 'Flood photos: please post them to the Record with a seal so the room can use them',
+ ],
+ [
+ 'local',
+ 'underpass on 7th is a lake, cars turning around',
+ { evidence: 0.8 },
+ ],
+ ],
+ quake: [
+ ['local', 'long rolling one, maybe 20 seconds, 6th floor'],
+ ['local', 'glasses rattled, nothing fell here'],
+ ['wx', 'depth {m:Depth} — shallow ones feel sharper'],
+ ['newbie', 'tsunami??'],
+ [
+ 'mod',
+ 'Tsunami advisories come from the national warning centre. Not from chat. Link is in the Record.',
+ ],
+ [
+ 'skeptic',
+ 'first automatic solutions often fix depth at 10 km, wait for the reviewed one',
+ ],
+ ['local', 'aftershock just now, small, felt it in the chair'],
+ ['joker', 'my cat knew 10 seconds before. cats are the real sensors'],
+ ['medic', 'check gas lines if you’re in an older building'],
+ ['local', 'sealed a clip of the hallway lights swinging', { evidence: 12 }],
+ [
+ 'skeptic',
+ 'magnitude keeps getting revised, quote the latest from the brief',
+ ],
+ ],
+ fire: [
+ ['local', 'ash on the cars this morning, sky orange to the east'],
+ ['local', 'smoke is laying right down the valley'],
+ ['wx', 'wind is pushing the plume east, look at the wind lens'],
+ ['newbie', 'how many fires is that?'],
+ [
+ 'wx',
+ 'each hotspot is a ~375 m pixel, {m0} hotspots isn’t that many separate fires',
+ ],
+ [
+ 'skeptic',
+ 'the “fire jumped the river” post is not on the perimeter layer',
+ ],
+ ['medic', 'close windows, N95 if you have one, AQI will spike tonight'],
+ [
+ 'photo',
+ 'sealed a photo from the ridge road, glow over the second ridge',
+ { evidence: 6.5 },
+ ],
+ ['joker', 'my car is now beige, it was black'],
+ [
+ 'mod',
+ 'Evacuation info: county alerts only. Don’t relay road closures from chat as fact.',
+ ],
+ ],
+ squawk: [
+ ['pilot', '7700 is a general emergency. medical diversions use it too'],
+ [
+ 'pilot',
+ '{m:Altitude} and {m:Speed}, looks like a controlled descent to me',
+ ],
+ ['atc', 'they asked for vectors to the nearest suitable, sounded calm'],
+ ['newbie', 'hijack??'],
+ ['skeptic', 'not a hijack. that would be 7500. please stop'],
+ ['scanner', 'tower has fire trucks positioned, that’s standard'],
+ [
+ 'local',
+ 'heard it low over the harbour, gear down, engines sounded normal',
+ ],
+ ['joker', 'pilots when the coffee machine breaks: 7700'],
+ [
+ 'pilot',
+ 'track turned north a few min ago, lining up for the long runway',
+ ],
+ ['mod', 'Please don’t speculate about passengers. Facts in the Record.'],
+ ],
+ gnss: [
+ [
+ 'pilot',
+ 'flew through there last week, GPS dropped, we were on inertial for ~20 min',
+ ],
+ ['newbie', 'does this affect phones?'],
+ [
+ 'wx',
+ 'the layer is aircraft self-reporting nav accuracy, {m0} aircraft, not phones',
+ ],
+ [
+ 'skeptic',
+ 'calling everything jamming overstates it, some are equipment faults',
+ ],
+ ['sea', 'ships near the coast report the same pattern at night'],
+ ['joker', 'my phone thinks I’m in the sea again'],
+ ['mod', 'Stick to what the data shows. No attribution games in Live.'],
+ ],
+ orbit: [
+ [
+ 'orbit',
+ 'pass is ~6 min horizon to horizon, look west-southwest at the start',
+ ],
+ ['orbit', 'it’s brighter than anything but the Moon tonight EyesUp'],
+ ['newbie', 'how do I know which dot it is?'],
+ ['orbit', 'it doesn’t blink and it moves steadily, planes blink'],
+ ['local', 'clouds here, praying for a gap'],
+ ['photo', 'tripod out, going for a 6 s exposure streak'],
+ ['joker', 'waving at the astronauts o7'],
+ ['skeptic', 'the viral fireball clip is a reentry from last year'],
+ ['local', 'SAW IT. so bright. sealed photo', { evidence: 0.4 }],
+ ],
+ launch: [
+ ['orbit', 'weather is the only thing that can scrub this now'],
+ ['local', 'causeway is packed, cars parked on the shoulder for a mile'],
+ ['newbie', 'can you see it from Houston?'],
+ ['orbit', 'not the launch, maybe the plume if it’s clear'],
+ ['joker', 'LiftOff LiftOff LiftOff'],
+ ['skeptic', 'T-0 is a window, not a promise'],
+ [
+ 'photo',
+ 'set up at the beach with 600mm, sealed test shot of the pad',
+ { evidence: 9 },
+ ],
+ ['mod', 'Reminder: respect the exclusion zone. No drone footage please.'],
+ ],
+ eye: [
+ ['local', 'walking past now, way busier than the frame shows'],
+ ['local', 'line out the door at the corner'],
+ [
+ 'skeptic',
+ 'the camera faces {dir}, the “crowd heading to the square” is walking away from it',
+ ],
+ ['newbie', 'is this actually live?'],
+ [
+ 'mod',
+ 'Frame refreshes every ~15 s from the city’s public camera. Faces are not identifiable at this res, keep it that way.',
+ ],
+ ['joker', 'i’m the pixel in the red jacket. hi mom'],
+ ['local', 'lane closure still on, cones all the way to the light'],
+ [
+ 'photo',
+ 'sealed a photo from across the street, matches the frame',
+ { evidence: 0.08 },
+ ],
+ ],
+ sea: [
+ [
+ 'sea',
+ 'anchored in the waiting area since morning, told to hold 12 more hours',
+ ],
+ ['sea', 'mostly scheduling, convoys run at set times'],
+ ['newbie', 'why so many ships?'],
+ ['skeptic', 'some of those are bunkering, not queuing'],
+ ['joker', 'this is the DMV of oceans'],
+ ['sea', 'AIS will show them moving in a column when the convoy starts'],
+ ],
+ heli: [
+ ['pilot', 'that’s a training pattern, same circuit every 6–7 min'],
+ ['local', 'they do this all day, you stop hearing it'],
+ ['newbie', 'is something wrong?'],
+ ['pilot', 'nope, autorotation practice. looks scary, is routine'],
+ ['joker', 'HeliSpin HeliSpin HeliSpin'],
+ ['local', 'one just did a hover check right over the field'],
+ ['mod', 'Alumni crew is in here tonight o7'],
+ ],
+ air: [
+ [
+ 'pilot',
+ 'those racetracks are holding patterns, controllers stack arrivals',
+ ],
+ ['local', 'watching from the fence, one every couple minutes'],
+ ['skeptic', 'not a record day, {m0} is high but normal peak'],
+ ['atc', 'they just switched runways, that’s why the holds'],
+ ['joker', 'plane spotters are the original live chat'],
+ [
+ 'photo',
+ 'sealed a clip with the approach lights in frame',
+ { evidence: 1.4 },
+ ],
+ ],
+ default: [
+ ['local', 'here now, what I see matches the map'],
+ ['newbie', 'is this normal for this time of day?'],
+ ['skeptic', '{m0} is within the usual range, don’t overread it'],
+ ['joker', 'chat is the real content'],
+ ],
+};
+
+/** Burst lines: short, loud, many people at once. */
+const BURST = {
+ circling: [
+ 'THEY’RE MOVING',
+ 'one of them just climbed higher',
+ 'ESU on the roof now',
+ 'EyesUp EyesUp',
+ 'banner is out!!',
+ 'HeliSpin',
+ 'oh no oh no',
+ 'that’s so high',
+ 'did you see that',
+ 'they waved',
+ ],
+ cyclone: [
+ 'eye clearing!!',
+ 'StormMode',
+ 'new IR frame is insane',
+ 'that gust',
+ 'whoa',
+ 'pressure dropping again?',
+ ],
+ storm: [
+ 'THAT FLASH',
+ 'StormMode',
+ 'lightning right over us',
+ 'whoa',
+ 'power just blinked',
+ 'that one shook the windows',
+ ],
+ quake: [
+ 'AFTERSHOCK',
+ 'felt that',
+ 'here too',
+ 'whoa',
+ 'that was bigger',
+ 'everyone ok?',
+ ],
+ fire: [
+ 'new hotspot!',
+ 'wind shifted',
+ 'whoa',
+ 'that plume',
+ 'eyes on the ridge EyesUp',
+ ],
+ squawk: [
+ 'ON FINAL',
+ 'gear down',
+ 'touchdown?',
+ 'they’re down!!',
+ 'o7',
+ 'o7 crew',
+ ],
+ gnss: ['new cell lit up', 'whoa', 'spreading north'],
+ orbit: [
+ 'THERE IT IS',
+ 'EyesUp EyesUp',
+ 'so bright',
+ 'o7 ISS',
+ 'saw it!!',
+ 'wow',
+ ],
+ launch: [
+ 'LiftOff',
+ 'LIFTOFF',
+ 'go go go',
+ 'LiftOff LiftOff',
+ 'max q',
+ 'beautiful',
+ ],
+ eye: [
+ 'crowd surge',
+ 'whoa',
+ 'here comes the parade',
+ 'EyesUp',
+ 'look at the corner',
+ ],
+ sea: ['convoy moving', 'here they go', 'whoa'],
+ heli: ['autorotation!', 'HeliSpin', 'nice flare', 'o7'],
+ air: ['go around!', 'whoa', 'heavy on final', 'nice landing'],
+ default: ['whoa', 'did you see that', 'EyesUp', 'wow'],
+};
+
+/** Reaction mix per genre (weights for heart, fire, eyes, wow). */
+const MIX = {
+ circling: [3, 1, 5, 4],
+ cyclone: [3, 2, 3, 3],
+ storm: [2, 4, 2, 4],
+ quake: [4, 0, 3, 2],
+ fire: [3, 4, 2, 1],
+ squawk: [6, 0, 4, 1],
+ gnss: [1, 1, 4, 2],
+ orbit: [5, 1, 3, 5],
+ launch: [3, 6, 2, 5],
+ eye: [4, 2, 4, 2],
+ sea: [2, 1, 3, 2],
+ heli: [3, 2, 3, 3],
+ air: [3, 1, 3, 2],
+ default: [3, 1, 3, 2],
+};
+export function reactionKind(genre, rand = Math.random) {
+ const w = MIX[genre] || MIX.default;
+ const kinds = ['heart', 'fire', 'eyes', 'wow'];
+ let x = rand() * w.reduce((a, b) => a + b, 0);
+ for (let i = 0; i < 4; i += 1) {
+ x -= w[i];
+ if (x <= 0) return kinds[i];
+ }
+ return 'heart';
+}
+
+/* ---------------- Replies to the person ---------------- */
+
+const REPLIES = [
+ {
+ re: /\b(hi|hey|hello|yo|sup|first)\b/i,
+ lines: [
+ ['local', 'welcome {user} o7'],
+ [
+ 'mod',
+ 'Welcome {user}. Live is fast, the Record is where evidence goes.',
+ ],
+ ['newbie', 'hey {user}'],
+ ],
+ },
+ {
+ re: /\b(fake|real|hoax|cgi|staged|lie|lying)\b/i,
+ lines: [
+ [
+ 'skeptic',
+ '{user} fair question. sealed captures only, check the Record lane',
+ ],
+ [
+ 'mod',
+ '{user} If you think something is wrong, post a Correction in the Record. Corroborated corrections raise your Signal.',
+ ],
+ ['local', '{user} i’m literally here lol'],
+ ],
+ },
+ {
+ re: /\b(safe|danger|ok|okay|scared|worried|evacuat)/i,
+ lines: [
+ [
+ 'medic',
+ '{user} follow local officials for safety calls, chat can’t see your street',
+ ],
+ [
+ 'mod',
+ '{user} Nothing in this room is an alert. If you’re close, local channels first.',
+ ],
+ ],
+ },
+ {
+ re: /\b(seal|sealed|capture|photo|pic|video|clip)\b/i,
+ lines: [
+ ['photo', '{user} SealIt pls, the room needs eyes from the ground'],
+ [
+ 'local',
+ '{user} there’s a Need eyes pin near the scene if you’re close',
+ ],
+ ],
+ },
+ {
+ re: /\b(heli|helicopter|chopper|rotor)/i,
+ lines: [
+ [
+ 'pilot',
+ '{user} rotorcraft orbiting low and slow, the track lens shows the circles clearly',
+ ],
+ ['scanner', '{user} one of them is police, the others are news'],
+ ],
+ },
+ {
+ re: /\b(iss|satellite|starlink|orbit)\b/i,
+ lines: [
+ [
+ 'orbit',
+ '{user} if it doesn’t blink and moves steady, that’s a satellite',
+ ],
+ ],
+ },
+ {
+ re: /\b(rain|flood|storm|lightning|wind|thunder)\b/i,
+ lines: [
+ [
+ 'wx',
+ '{user} radar lens shows the core, the lightning dots are the last 15 min',
+ ],
+ ['local', '{user} it’s really coming down here'],
+ ],
+ },
+ {
+ re: /\b(where|location|place|address)\b/i,
+ lines: [
+ ['local', '{user} {place}, tap the room title for the map'],
+ [
+ 'mod',
+ '{user} Exact spots of people stay private. The situation pin is on the globe.',
+ ],
+ ],
+ },
+ {
+ re: /\b(lol|lmao|haha|rofl|💀)\b/i,
+ lines: [
+ ['joker', '{user} LMAO'],
+ ['joker', '{user} chat is undefeated'],
+ ],
+ },
+ {
+ re: /\b(how many|how much|number|count|stats?)\b/i,
+ lines: [
+ ['wx', '{user} brief says {m0}{sep1}{m1}'],
+ [
+ 'skeptic',
+ '{user} numbers are in the Brief with their source and age, chat rounds things',
+ ],
+ ],
+ },
+ {
+ re: /\?$/,
+ lines: [
+ ['local', '{user} good q, nobody’s confirmed yet'],
+ [
+ 'skeptic',
+ '{user} post it as a Question in the Record, they float until answered',
+ ],
+ [
+ 'mod',
+ '{user} Questions in the Record stay pinned until someone answers with evidence.',
+ ],
+ ],
+ },
+];
+const GENERIC_REPLY = [
+ ['local', '{user} yeah'],
+ ['newbie', '{user} same'],
+ ['joker', '{user} real'],
+ ['local', '{user} 100%'],
+ ['skeptic', '{user} source?'],
+];
+
+/* ---------------- The simulator ---------------- */
+
+let seq = 0;
+export function makeMsg(persona, text, extra = {}) {
+ const tier = simTier(persona.handle, persona.floor || 0);
+ return {
+ id: `sim-${Date.now().toString(36)}-${(seq += 1)}`,
+ handle: persona.handle,
+ text,
+ at: Date.now(),
+ sim: true,
+ mod: !!persona.mod,
+ domain: persona.domain || null,
+ local: !!persona.local,
+ tier,
+ ...extra,
+ };
+}
+
+function pick(list, rand = Math.random) {
+ return list[Math.floor(rand() * list.length)];
+}
+function personaFor(role, rand) {
+ if (role === 'atc') return { ...PERSONAS.pilot[2], role };
+ const list = PERSONAS[role] || PERSONAS.newbie;
+ return { ...pick(list, rand), role };
+}
+
+/**
+ * @param {object} s situation
+ * @param {{ onMessage(msg), onReactions?(kind, n, burst), viewers?: number,
+ * me?: () => string, rand?: () => number }} o
+ */
+export function createChatSim(s, o) {
+ const rand = o.rand || Math.random;
+ const genre = chatGenre(s);
+ const V = vars(s);
+ const pool = [...(POOLS[genre] || POOLS.default), ...COMMON];
+ const used = new Set();
+ let timer = 0;
+ let burstUntil = 0;
+ let nextBurst = Date.now() + 9000 + rand() * 12000;
+ let running = false;
+ let viewers = o.viewers || viewerCount(s);
+ const witnessHandles = new Set(); // sim chatters with sealed captures nearby
+
+ function line() {
+ // Prefer unused lines; recycle once the pool is spent.
+ let cand = pool.filter((_, i) => !used.has(i));
+ if (cand.length < 3) {
+ used.clear();
+ cand = pool;
+ }
+ for (let tries = 0; tries < 6; tries += 1) {
+ const idx = Math.floor(rand() * pool.length);
+ if (used.has(idx) && tries < 5) continue;
+ const [role, t, meta] = pool[idx];
+ const text = fill(t, V);
+ if (!text) {
+ used.add(idx);
+ continue;
+ }
+ used.add(idx);
+ const p = personaFor(role, rand);
+ const extra = {};
+ if (meta && meta.evidence != null) {
+ extra.evidence = { sealed: true, km: meta.evidence, example: true };
+ witnessHandles.add(p.handle);
+ }
+ if (witnessHandles.has(p.handle)) extra.witness = true;
+ return makeMsg(p, text, extra);
+ }
+ return null;
+ }
+
+ function greet() {
+ const w = worldPoint(rand);
+ const p = personaFor(pick(['newbie', 'local', 'joker', 'newbie']), rand);
+ // Greeters are from elsewhere: never mark them local.
+ return makeMsg(
+ { ...p, local: false },
+ fill(pick(GREET, rand), V, { city: w.city }),
+ {
+ from: w,
+ },
+ );
+ }
+
+ function burstLine() {
+ const list = BURST[genre] || BURST.default;
+ const p = personaFor(
+ pick(['newbie', 'local', 'joker', 'photo', 'newbie', 'local'], rand),
+ rand,
+ );
+ let text = pick(list, rand);
+ if (rand() < 0.25) text = text.toUpperCase();
+ return makeMsg(
+ p,
+ text,
+ witnessHandles.has(p.handle) ? { witness: true } : {},
+ );
+ }
+
+ function tick() {
+ if (!running) return;
+ const now = Date.now();
+ if (now > nextBurst) {
+ burstUntil = now + 3200 + rand() * 2500;
+ nextBurst = now + 22000 + rand() * 26000;
+ o.onBurst?.(genre);
+ }
+ const bursting = now < burstUntil;
+ // Messages per second scales gently with viewers; bursts ×4.
+ const base = Math.min(2.4, Math.max(0.35, viewers / 1400));
+ const rate = bursting ? base * 4 + 1.5 : base;
+ const r = rand();
+ const msg = bursting
+ ? r < 0.75
+ ? burstLine()
+ : line()
+ : r < 0.14
+ ? greet()
+ : line();
+ if (msg) o.onMessage(msg);
+ // Reactions from the crowd, in proportion to viewers.
+ if (o.onReactions) {
+ const n = Math.max(
+ 0,
+ Math.round((viewers / 900) * (bursting ? 6 : 1) * (0.4 + rand())),
+ );
+ if (n) o.onReactions(reactionKind(genre, rand), n, bursting);
+ }
+ // Viewer count random walk, ±2 %.
+ viewers = Math.max(12, Math.round(viewers * (1 + (rand() - 0.48) * 0.02)));
+ o.onViewers?.(viewers);
+ const gap = (1000 / rate) * (0.35 + rand() * 1.3);
+ timer = setTimeout(tick, gap);
+ }
+
+ /** Someone (the person) said something: a few sim people answer. */
+ function hear(text, handle) {
+ const t = String(text || '');
+ const rule = REPLIES.find((r) => r.re.test(t));
+ const lines = rule ? rule.lines : GENERIC_REPLY;
+ const n = rule ? 1 + (rand() < 0.45 ? 1 : 0) : rand() < 0.55 ? 1 : 0;
+ const chosen = [...lines].sort(() => rand() - 0.5).slice(0, n);
+ chosen.forEach(([role, tpl], i) => {
+ const txt = fill(tpl, V, {
+ user: handle,
+ sep1: V.m1 ? ' and ' : '',
+ });
+ if (!txt) return;
+ setTimeout(
+ () =>
+ running &&
+ o.onMessage(
+ makeMsg(personaFor(role, rand), txt, { replyTo: handle }),
+ ),
+ 1400 + i * 1700 + rand() * 1600,
+ );
+ });
+ // A mention of evidence draws corroborating eyes.
+ return chosen.length;
+ }
+
+ return {
+ genre,
+ start() {
+ if (running) return;
+ running = true;
+ // Backfill a little history so the lane is never empty.
+ const back = [];
+ for (let i = 0; i < 14; i += 1) {
+ const m = i % 5 === 2 ? greet() : line();
+ if (m) {
+ m.at = Date.now() - (14 - i) * (2500 + rand() * 4000);
+ back.push(m);
+ }
+ }
+ o.onBackfill?.(back);
+ timer = setTimeout(tick, 600);
+ },
+ stop() {
+ running = false;
+ clearTimeout(timer);
+ },
+ hear,
+ burst() {
+ nextBurst = 0;
+ },
+ get viewers() {
+ return viewers;
+ },
+ set viewers(v) {
+ viewers = v;
+ },
+ get running() {
+ return running;
+ },
+ };
+}
+
+/** A stable, plausible viewer count for a situation. */
+export function viewerCount(s) {
+ if (!s) return 0;
+ const h = hashStr(s.id);
+ const g = chatGenre(s);
+ const base =
+ {
+ circling: 4800,
+ cyclone: 2600,
+ launch: 3900,
+ squawk: 1800,
+ storm: 1300,
+ quake: 1500,
+ orbit: 1100,
+ eye: 640,
+ fire: 900,
+ }[g] || 380;
+ return Math.round(base * (0.75 + (h % 500) / 1000) + (s.severity || 0) * 120);
+}
+
+/**
+ * A Moment's chat on its own clock. Uses moment.chat when the engine
+ * provides it; otherwise writes one from the genre pool across the window.
+ * @returns {{ t:number, handle, text, badge?, sim:true }[]}
+ */
+export function momentChat(m) {
+ if (Array.isArray(m?.chat) && m.chat.length)
+ return m.chat
+ .map((c) => ({ ...c, id: c.id || `${c.t}|${c.handle}`, sim: true }))
+ .sort((a, b) => a.t - b.t);
+ const start = +m?.time?.start || Date.now() - 20 * 60e3;
+ const end = +m?.time?.end || Date.now();
+ const s = {
+ id: m?.id || 'moment',
+ kind: 'circling',
+ title: m?.title,
+ place: m?.place,
+ momentId: m?.id,
+ };
+ let r = hashStr(s.id);
+ const rand = () => {
+ r =
+ Math.imul(r ^ (r >>> 15), 2246822507) ^
+ Math.imul(r ^ (r >>> 13), 3266489909);
+ return ((r ^= r >>> 16) >>> 0) / 4294967296;
+ };
+ const out = [];
+ const pool = POOLS.circling;
+ const V = vars(s);
+ const n = Math.min(80, Math.max(24, Math.round((end - start) / 15000)));
+ for (let i = 0; i < n; i += 1) {
+ const t = start + ((i + rand() * 0.8) / n) * (end - start);
+ const [role, tpl, meta] =
+ i % 7 === 3 ? ['newbie', pick(BURST.circling, rand)] : pick(pool, rand);
+ const p = personaFor(role, rand);
+ const text = fill(tpl, V) || tpl;
+ out.push({
+ id: `mc-${s.id}-${i}`,
+ t,
+ handle: p.handle,
+ text,
+ badge: p.mod ? 'mod' : meta?.evidence != null ? 'witness' : undefined,
+ sim: true,
+ });
+ }
+ return out.sort((a, b) => a.t - b.t);
+}
+
+export { fmtKm };
+
+/** A few representative lines for a room preview (Rooms tab ticker). */
+export function sampleLines(s, n = 6) {
+ const V = vars(s);
+ const pool = POOLS[chatGenre(s)] || POOLS.default;
+ let r = hashStr(s.id);
+ const rand = () => {
+ r = (Math.imul(r, 1664525) + 1013904223) >>> 0;
+ return r / 4294967296;
+ };
+ const out = [];
+ const idx = pool.map((_, i) => i).sort(() => rand() - 0.5);
+ for (const i of idx) {
+ const [role, t, meta] = pool[i];
+ const text = fill(t, V);
+ if (!text) continue;
+ const p = personaFor(role, rand);
+ out.push(
+ makeMsg(
+ p,
+ text,
+ meta?.evidence != null
+ ? { evidence: { sealed: true, km: meta.evidence } }
+ : {},
+ ),
+ );
+ if (out.length >= n) break;
+ }
+ return out;
+}
diff --git a/mobile/ui/clerk.js b/mobile/ui/clerk.js
new file mode 100644
index 000000000..78d795f64
--- /dev/null
+++ b/mobile/ui/clerk.js
@@ -0,0 +1,185 @@
+/**
+ * The Clerk (DESIGN.md §12.6): every few minutes it reads the Live lane and
+ * posts "What chat is saying" into the Record, citing the chat lines it used.
+ *
+ * Prototype honesty: this is a local, deterministic summarizer (fixed rules
+ * over keywords, corroborations and evidence flags), not an AI model. It
+ * never states anything it cannot cite, and it keeps the cited lines inside
+ * the summary so the receipts survive after chat scrolls away.
+ */
+
+import { canonical, sha256Hex } from '../capture/provenance.js';
+
+const THEMES = [
+ {
+ id: 'aircraft',
+ re: /\b(heli|helicopter|chopper|orbit|circling|police|news|ft|aviation|track|squawk|7700|gear|runway|final|holding)\b/i,
+ label: 'the aircraft overhead',
+ },
+ {
+ id: 'weather',
+ re: /\b(rain|flash|lightning|thunder|gust|wind|pressure|eye|eyewall|storm|core|radar|flood|underpass)\b/i,
+ label: 'the weather on the ground',
+ },
+ {
+ id: 'scene',
+ re: /\b(street|ave|st\b|floor|deck|corner|block|crowd|roof|banner|building|harbou?r|pier|ridge|road)\b/i,
+ label: 'what people can see from the street',
+ },
+ {
+ id: 'shaking',
+ re: /\b(shak|rolling|aftershock|felt|rattled|swing)/i,
+ label: 'the shaking people felt',
+ },
+ {
+ id: 'sky',
+ re: /\b(iss|pass|bright|satellite|clouds|launch|liftoff|pad|plume)\b/i,
+ label: 'the sky',
+ },
+ {
+ id: 'safety',
+ re: /\b(safe|power|evac|ems|esu|rescue|fire trucks|medic|n95|aqi|gas)\b/i,
+ label: 'safety',
+ },
+];
+const DOUBT =
+ /\b(fake|source\??|not a|unconfirmed|from 20\d\d|last (year|spring)|isn’t|isn't|not evidence|stop|overstates|doesn’t|doesn't)\b/i;
+const EVIDENCE = /\b(seal|sealed|sealing|clip|photo|capture)\b/i;
+
+const clip = (t, n = 90) => {
+ const s = String(t || '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
+};
+const rankOf = (m) =>
+ (m.corro || 0) * 2 +
+ (m.evidence ? 4 : 0) +
+ (m.witness ? 2 : 0) +
+ (m.tier?.index || 0) +
+ (m.mod ? 1 : 0);
+
+/**
+ * @param {object[]} msgs chat lines in the window (oldest first), each
+ * { id, n, handle, text, at, corro?, evidence?, witness?, tier?, mod? }
+ * @param {{ reactions?: Record
, viewers?: number, since?: number }} o
+ * @returns {Promise} a Clerk summary
+ */
+export async function summarize(msgs, o = {}) {
+ const lines = msgs.filter((m) => m && m.text && !m.system);
+ if (lines.length < 5) return null;
+ const used = new Map(); // id → line
+ const cite = (m) => {
+ used.set(m.id, m);
+ return m.id;
+ };
+ const bullets = [];
+
+ // 1. What most of chat is about.
+ const counts = THEMES.map((t) => ({
+ t,
+ hits: lines.filter((m) => t.re.test(m.text)),
+ }))
+ .filter((x) => x.hits.length >= 2)
+ .sort((a, b) => b.hits.length - a.hits.length);
+ if (counts[0]) {
+ const top = counts[0];
+ const best = [...top.hits]
+ .sort((a, b) => rankOf(b) - rankOf(a))
+ .slice(0, 2);
+ bullets.push({
+ kind: 'theme',
+ text: `${top.hits.length} of ${lines.length} messages are about ${top.t.label}. Most-backed: “${clip(best[0].text, 80)}”${best[1] ? ` and “${clip(best[1].text, 60)}”` : ''}.`,
+ cites: best.map(cite),
+ });
+ }
+ if (counts[1]) {
+ const x = counts[1];
+ const best = [...x.hits].sort((a, b) => rankOf(b) - rankOf(a))[0];
+ bullets.push({
+ kind: 'theme',
+ text: `Also: ${x.t.label} (${x.hits.length}), e.g. “${clip(best.text, 70)}”.`,
+ cites: [cite(best)],
+ });
+ }
+
+ // 2. Evidence offered, and whether it reached the Record.
+ const ev = lines.filter(
+ (m) => m.evidence || (EVIDENCE.test(m.text) && m.witness),
+ );
+ if (ev.length) {
+ const promoted = ev.filter((m) => m.promoted);
+ bullets.push({
+ kind: 'evidence',
+ text: `${ev.length} ${ev.length === 1 ? 'person says they have' : 'people say they have'} a sealed capture${promoted.length ? `; ${promoted.length} ${promoted.length === 1 ? 'is' : 'are'} now in the Record` : '; none is in the Record yet'}.`,
+ cites: ev.slice(0, 3).map(cite),
+ });
+ }
+
+ // 3. Disputes: doubt lines, strongest first.
+ const doubt = lines
+ .filter((m) => DOUBT.test(m.text))
+ .sort((a, b) => rankOf(b) - rankOf(a));
+ if (doubt.length)
+ bullets.push({
+ kind: 'disputed',
+ text: `Pushback: “${clip(doubt[0].text, 90)}”${doubt.length > 1 ? ` (${doubt.length} skeptical messages)` : ''}.`,
+ cites: doubt.slice(0, 2).map(cite),
+ });
+
+ // 4. Open questions.
+ const qs = lines.filter((m) => /\?\s*$/.test(m.text) && !m.mod);
+ if (qs.length)
+ bullets.push({
+ kind: 'question',
+ text: `Unanswered in chat: “${clip(qs[qs.length - 1].text, 80)}”${qs.length > 1 ? ` and ${qs.length - 1} more` : ''}.`,
+ cites: qs.slice(-2).map(cite),
+ });
+
+ // 5. Mood, from reactions (counted, not cited lines).
+ const r = o.reactions || {};
+ const rt = Object.values(r).reduce((a, b) => a + b, 0);
+ if (rt)
+ bullets.push({
+ kind: 'mood',
+ text: `Reactions: ${['heart', 'eyes', 'wow', 'fire']
+ .filter((k) => r[k])
+ .map(
+ (k) =>
+ `${r[k].toLocaleString()} ${k === 'eyes' ? 'eyes' : k === 'wow' ? 'wow' : k === 'fire' ? 'fire' : 'hearts'}`,
+ )
+ .join(
+ ' · ',
+ )}${o.viewers ? ` from ${o.viewers.toLocaleString()} watching` : ''}.`,
+ cites: [],
+ });
+
+ if (!bullets.some((b) => b.cites.length)) return null;
+ const quotes = [...used.values()].map((m) => ({
+ id: m.id,
+ n: m.n,
+ handle: m.handle,
+ text: m.text,
+ at: m.at,
+ sim: !!m.sim,
+ }));
+ const from = lines[0].at;
+ const to = lines[lines.length - 1].at;
+ const hash = await sha256Hex(
+ canonical({
+ from,
+ to,
+ ids: lines.map((m) => m.id),
+ bullets: bullets.map((b) => b.text),
+ }),
+ ).catch(() => '00000000');
+ return {
+ id: `clerk-${to.toString(36)}`,
+ kind: 'clerk',
+ at: Date.now(),
+ window: { from, to, n: lines.length },
+ bullets,
+ quotes,
+ hash,
+ };
+}
diff --git a/mobile/ui/composer.js b/mobile/ui/composer.js
index 4d0e23817..2825f9cd1 100644
--- a/mobile/ui/composer.js
+++ b/mobile/ui/composer.js
@@ -52,10 +52,10 @@ export function createComposer({
function render() {
const s = ctx.s;
- const r = ctx.replyTo;
- sheet.head.innerHTML = ` Room· ${esc(s.title)}
- ${r ? 'Reply' : 'Post to the room'}
- ${r ? `${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)} ${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}
` : ''}`;
+ const r = ctx.replyTo || ctx.quote;
+ sheet.head.innerHTML = ` Record· ${esc(s.title)}
+ ${ctx.quote ? 'Quote post' : r ? 'Reply' : 'Post to the Record'}
+ ${r ? `${ctx.quote ? 'Quoting · ' : ''}${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)} ${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}
` : ''}`;
const places = placeOptions();
const t = getTime();
sheet.body.innerHTML = `
@@ -76,7 +76,7 @@ export function createComposer({
${icon('clock')}Anchor in time
No time Now${esc(utc(new Date(), { seconds: false }))} ${t ? `Ribbon time${esc(utc(t, { seconds: false }))} ` : ''}
- `;
+ `;
}
function placeholder(kind) {
@@ -89,10 +89,11 @@ export function createComposer({
}[kind];
}
- function open(s, { replyTo = null, kind } = {}) {
+ function open(s, { replyTo = null, kind, quote = null } = {}) {
ctx = {
s,
replyTo,
+ quote,
kind:
kind ||
(replyTo
@@ -158,6 +159,7 @@ export function createComposer({
kind: ctx.kind,
text,
replyTo: ctx.replyTo?.id || null,
+ quoteOf: ctx.quote?.id || null,
anchor,
evidence: {},
});
@@ -170,5 +172,5 @@ export function createComposer({
}
});
- return { open, close: () => sheet.close() };
+ return { open, close: () => sheet.close(), root: sheet.root };
}
diff --git a/mobile/ui/crews.js b/mobile/ui/crews.js
new file mode 100644
index 000000000..427b1d67e
--- /dev/null
+++ b/mobile/ui/crews.js
@@ -0,0 +1,617 @@
+/**
+ * Crews (DESIGN.md §12.6): persistent groups, like X communities. A crew
+ * has its own feed, members, rules and rooms, and can co-watch a Moment.
+ *
+ * Seeded crews and their posts are EXAMPLES: fictional handles (the same
+ * cast as the simulated chat), never real people. Joining, leaving and the
+ * person's own crew posts are real and stay on this device.
+ */
+
+import { icon } from './icons.js';
+import { $, $$, h, esc, ago, haptic, toast, emitter } from './dom.js';
+import { createSheet } from './sheet.js';
+import { simTier } from './signal.js';
+import { personaOf } from './chatSim.js';
+import { richText } from './emotes.js';
+import { get, put } from '../capture/db.js';
+
+const JOIN_KEY = 'gev.mobile.crews.v1';
+
+export const CREWS = [
+ {
+ id: 'nyc-scanner',
+ name: 'NYC Scanner Nerds',
+ tagline: 'Radio, rotors and rooftops. Five boroughs, receipts first.',
+ hue: '#a3d8ff',
+ glyph: 'waveform',
+ area: 'New York City',
+ lat: 40.7484,
+ lon: -73.9857,
+ members: 2841,
+ genres: ['circling', 'eye', 'air'],
+ moment: 'empire-state',
+ mods: ['@deskmod_rae', '@scanner_sal'],
+ roster: [
+ '@scanner_sal',
+ '@freq_hopper',
+ '@onthe5th',
+ '@corner_deli_mo',
+ '@actually_tho',
+ '@longlens_ben',
+ '@flaps30',
+ '@lurker9',
+ ],
+ rules: [
+ 'No names, faces, plates or addresses of private people. Ever.',
+ 'Scanner audio is a lead, not a source: post it as Context with the time and channel.',
+ 'Sealed or it’s a rumour. Unverified uploads get a note before they get a repost.',
+ 'Be first, be right, be kind to the new eyes.',
+ ],
+ feed: [
+ {
+ h: '@deskmod_rae',
+ m: 2900,
+ k: 'context',
+ pin: true,
+ up: 212,
+ re: 14,
+ t: 'Rules refresher: no names, no plates, no addresses of private people. Scanner audio is a lead, not a source. Post it as Context with the time and the channel.',
+ },
+ {
+ h: '@scanner_sal',
+ m: 22,
+ k: 'witness',
+ up: 188,
+ re: 41,
+ t: 'Aviation and ESU both up on Special Ops at 14:02. Three helos over Midtown on the map: two holding at ~1,500 ft, one lower doing tight lefts. Room is live, Moment is building.',
+ },
+ {
+ h: '@onthe5th',
+ m: 17,
+ k: 'witness',
+ sealed: true,
+ up: 264,
+ re: 33,
+ t: 'Sealed a clip from 5th & 34th looking up. Two figures above the 86th floor deck, one holding a banner. Heading in the seal points straight at the mast. SealIt',
+ },
+ {
+ h: '@actually_tho',
+ m: 12,
+ k: 'correction',
+ up: 97,
+ re: 8,
+ t: 'PSA: the “shots fired on 8th Ave” clip going around tonight is a 2021 fireworks video. Reverse search finds it. Note is already bridged in the room.',
+ },
+ {
+ h: '@freq_hopper',
+ m: 190,
+ k: 'question',
+ up: 31,
+ re: 12,
+ t: 'Anyone know why the Citywide 1 feed drops out around 2 am most nights? Trunk maintenance, or something on my end?',
+ },
+ {
+ h: '@longlens_ben',
+ m: 410,
+ k: 'context',
+ up: 58,
+ re: 5,
+ t: 'Best public vantage for Midtown orbits is the High Line at 30th. You get the ESB, Hudson Yards and the heliport approach in one frame.',
+ },
+ ],
+ events: [
+ {
+ id: 'cw-esb',
+ title: 'Replay: Climbers on the Empire State',
+ sub: 'with @scanner_sal annotating the radio timeline',
+ when: 'Tonight · 9:00 PM ET',
+ going: 41,
+ moment: 'empire-state',
+ },
+ ],
+ },
+ {
+ id: 'gulf-storm',
+ name: 'Gulf Storm Chasers',
+ tagline:
+ 'Radar loops, lightning parties and seawall reports from Brownsville to Key West.',
+ hue: '#8ab4f8',
+ glyph: 'bolt',
+ area: 'Gulf of Mexico',
+ lat: 28.6,
+ lon: -91.5,
+ members: 6120,
+ genres: ['storm', 'cyclone'],
+ mods: ['@radar_rina', '@nightdesk_mod'],
+ roster: [
+ '@radar_rina',
+ '@cape_and_shear',
+ '@wxdan',
+ '@harborkid_22',
+ '@ridge_rd_amy',
+ '@buffering_bev',
+ '@k8_mobile',
+ ],
+ rules: [
+ 'NWS and local officials make the calls. We add eyes, not alerts.',
+ 'Post radar with its timestamp. A loop without a time is a vibe, not data.',
+ 'Flood photos must be sealed or carry a place and time.',
+ 'Don’t drive into water for content.',
+ ],
+ feed: [
+ {
+ h: '@radar_rina',
+ m: 35,
+ k: 'context',
+ pin: true,
+ up: 341,
+ re: 27,
+ t: 'Squall line over the central Gulf is the most electric thing on the map tonight. Lightning density near the leading edge is top-decile for October. Room is live, come hang.',
+ },
+ {
+ h: '@cape_and_shear',
+ m: 18,
+ k: 'prediction',
+ up: 122,
+ re: 19,
+ t: 'Houston cell has a 60 dBZ core over the Ship Channel, moving east ~25 mph. If you’re on I-10 east of downtown, it’s on you in about 20 minutes. StormMode',
+ },
+ {
+ h: '@harborkid_22',
+ m: 9,
+ k: 'witness',
+ sealed: true,
+ up: 176,
+ re: 22,
+ t: 'Galveston seawall at 61st: waves topping the wall at high tide, spray over the road. Sealed clip, 40 s.',
+ },
+ {
+ h: '@wxdan',
+ m: 64,
+ k: 'question',
+ up: 44,
+ re: 16,
+ t: 'Anyone on the Bolivar ferry with a barometer app? Want a ground-truth pressure to compare against the buoy.',
+ },
+ {
+ h: '@buffering_bev',
+ m: 120,
+ k: 'context',
+ up: 63,
+ re: 9,
+ t: 'First lightning party in the Live lane and I have never seen chat move this fast. 10/10 would refresh again.',
+ },
+ ],
+ events: [
+ {
+ id: 'cw-gulf',
+ title: 'Live: Gulf lightning party',
+ sub: 'Live lane open all night, radar on',
+ when: 'Tonight · from 8:00 PM CT',
+ going: 233,
+ genre: 'storm',
+ },
+ ],
+ },
+ {
+ id: 'novosel',
+ name: 'Fort Novosel Alumni',
+ tagline:
+ 'Rotor school grads, instructors and the people who live under the pattern.',
+ hue: '#ffb547',
+ glyph: 'sky',
+ area: 'Fort Novosel, Alabama',
+ lat: 31.33,
+ lon: -85.72,
+ members: 1307,
+ genres: ['heli'],
+ mods: ['@heavy_tess'],
+ roster: [
+ '@heavy_tess',
+ '@flaps30',
+ '@atc_listener',
+ '@medic_off_shift',
+ '@first_time_here',
+ ],
+ rules: [
+ 'No tail numbers of private aircraft. Training fleet is fine.',
+ 'What happens in the pattern is training. Explain it before you alarm anyone.',
+ 'Old stories welcome. Embellishment noted.',
+ ],
+ feed: [
+ {
+ h: '@heavy_tess',
+ m: 26,
+ k: 'witness',
+ up: 89,
+ re: 11,
+ t: 'Fourteenth circuit of the day on the map. Brings me right back. Stage field is busy: at least six in the pattern right now. HeliSpin',
+ },
+ {
+ h: '@flaps30',
+ m: 21,
+ k: 'context',
+ pin: true,
+ up: 154,
+ re: 17,
+ t: 'For the new folks in the room: the “emergency” people keep asking about is an autorotation. It’s practice. The flare at the bottom is the fun part.',
+ },
+ {
+ h: '@first_time_here',
+ m: 15,
+ k: 'question',
+ up: 12,
+ re: 6,
+ t: 'Why do they all fly the same rectangle over and over?',
+ },
+ {
+ h: '@atc_listener',
+ m: 14,
+ k: 'context',
+ up: 40,
+ re: 2,
+ t: 'It’s a traffic pattern: takeoff, crosswind, downwind, base, final. Repeating it is how you get good at the hard part, which is the last 50 ft.',
+ },
+ {
+ h: '@heavy_tess',
+ m: 2600,
+ k: 'context',
+ up: 71,
+ re: 23,
+ t: 'Class reunion in Enterprise in March. Who’s in? The boll weevil statue is still there.',
+ },
+ ],
+ events: [
+ {
+ id: 'cw-rotor',
+ title: 'Rotor school at 600×',
+ sub: 'A full day of pattern work, replayed with commentary',
+ when: 'Saturday · 10:00 AM CT',
+ going: 58,
+ genre: 'heli',
+ },
+ ],
+ },
+ {
+ id: 'orbit',
+ name: 'Orbit Watchers',
+ tagline:
+ 'Passes, launches, reentries and the occasional very patient explanation of what that light was.',
+ hue: '#b39dff',
+ glyph: 'orbit',
+ area: 'Everywhere, looking up',
+ lat: 25.99,
+ lon: -97.15,
+ members: 9480,
+ genres: ['orbit', 'launch'],
+ mods: ['@tle_tom'],
+ roster: [
+ '@tle_tom',
+ '@pass_predictor',
+ '@longlens_ben',
+ '@pigeonwatch',
+ '@just_scrolling',
+ '@lagspike',
+ ],
+ rules: [
+ '“UFO” threads get a Context post with the TLE match. Be kind, everyone starts somewhere.',
+ 'Pass times in local time and UTC.',
+ 'Respect launch exclusion zones. No drone footage.',
+ ],
+ feed: [
+ {
+ h: '@tle_tom',
+ m: 48,
+ k: 'context',
+ pin: true,
+ up: 402,
+ re: 31,
+ t: 'ISS pass tonight: max elevation 71°, about six minutes horizon to horizon. Start looking WSW. It’s the bright one that doesn’t blink. EyesUp',
+ },
+ {
+ h: '@pass_predictor',
+ m: 33,
+ k: 'prediction',
+ up: 118,
+ re: 14,
+ t: 'Starlink train from yesterday’s launch will be visible about 40 minutes after sunset, low in the north-west. It fades within a week as they raise orbit.',
+ },
+ {
+ h: '@longlens_ben',
+ m: 700,
+ k: 'witness',
+ sealed: true,
+ up: 233,
+ re: 19,
+ t: 'Sealed a 6 s exposure of last night’s pass over the harbour. The streak ends where the station went into Earth’s shadow, not where it “vanished”.',
+ },
+ {
+ h: '@pigeonwatch',
+ m: 300,
+ k: 'question',
+ up: 28,
+ re: 11,
+ t: 'Saw a row of lights moving in a line last night. Please tell me that’s Starlink and not my sanity.',
+ },
+ {
+ h: '@tle_tom',
+ m: 295,
+ k: 'context',
+ up: 87,
+ re: 3,
+ t: '@pigeonwatch That’s Starlink: a fresh batch still in a low train. Your sanity is fine. LiftOff',
+ },
+ ],
+ events: [
+ {
+ id: 'cw-launch',
+ title: 'Watch party: launch window at Starbase',
+ sub: 'Live lane, countdown, LiftOff spam encouraged',
+ when: 'Window opens 6:30 PM CT',
+ going: 1204,
+ genre: 'launch',
+ },
+ ],
+ },
+];
+export const CREW = Object.fromEntries(CREWS.map((c) => [c.id, c]));
+
+const fmtM = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
+
+export function crewAvatar(c, cls = '') {
+ return `
${icon(c.glyph)} `;
+}
+
+export function createCrews(core) {
+ const ev = emitter();
+ // First run: you start in one crew so the Rooms tab shows how it works.
+ let joined = new Set(['nyc-scanner']);
+ try {
+ const raw = localStorage.getItem(JOIN_KEY);
+ if (raw) joined = new Set(JSON.parse(raw));
+ } catch {
+ /* ignore */
+ }
+ const saveJoin = () => {
+ try {
+ localStorage.setItem(JOIN_KEY, JSON.stringify([...joined]));
+ } catch {
+ /* ignore */
+ }
+ };
+ let own = {}; // crew id → posts
+ const ready = get('kv', 'crewPosts')
+ .then((r) => (own = r?.posts || {}))
+ .catch(() => {});
+
+ const isJoined = (id) => joined.has(id);
+ function toggle(id) {
+ if (joined.has(id)) joined.delete(id);
+ else joined.add(id);
+ saveJoin();
+ ev.emit('change');
+ return joined.has(id);
+ }
+ async function postTo(id, text) {
+ await ready;
+ const p = {
+ id: `crew-${id}-${Date.now().toString(36)}`,
+ h: core.rt.me.handle,
+ at: Date.now(),
+ k: 'context',
+ t: text,
+ up: 0,
+ re: 0,
+ self: true,
+ };
+ own[id] = [p, ...(own[id] || [])].slice(0, 50);
+ await put('kv', { id: 'crewPosts', posts: own }).catch(() => {});
+ ev.emit('change');
+ return p;
+ }
+ const feedOf = (c) => {
+ const now = Date.now();
+ const seeded = c.feed.map((p, i) => ({
+ ...p,
+ id: `${c.id}-ex${i}`,
+ at: now - p.m * 60e3,
+ example: true,
+ }));
+ return [...(own[c.id] || []), ...seeded].sort(
+ (a, b) => (b.pin ? 1 : 0) - (a.pin ? 1 : 0) || b.at - a.at,
+ );
+ };
+
+ /* ---------------- Sheet ---------------- */
+ const sheet = createSheet({
+ label: 'Crew',
+ detents: ['large'],
+ modal: true,
+ dismissible: true,
+ closeButton: true,
+ className: 'crew-sheet opaque',
+ heights: { large: (H) => Math.round(Math.min(H * 0.92, 900)) },
+ });
+ let cur = null;
+ let tab = 'feed';
+
+ function postHtml(p) {
+ const per = personaOf(p.h);
+ const tier = p.self ? core.signal.tier() : simTier(p.h, per?.floor || 0);
+ const mod = cur.mods.includes(p.h);
+ return `
+ ${esc(p.h.slice(1, 3).toUpperCase())}
+ ${esc(p.h)} ${mod ? 'Mod ' : ''}${esc(tier.name)} ${esc(ago(p.at))}
+ ${p.pin ? `${icon('pin')}Pinned ` : p.example ? 'Example ' : ''}
+ ${richText(p.t, { me: core.rt.me.handle }).html}
+ ${p.sealed ? `${icon('sealFill')}Sealed
` : ''}
+ ${icon('agree')}${p.up} ${icon('reply')}${p.re} ${esc(p.k)}
+ `;
+ }
+
+ async function rooms() {
+ const sits = (await core.situations?.().catch(() => [])) || [];
+ const { chatGenre, viewerCount } = await import('./chatSim.js');
+ return sits
+ .filter((s) => cur.genres.includes(chatGenre(s)))
+ .slice(0, 4)
+ .map((s) => ({ s, v: viewerCount(s) }));
+ }
+
+ async function renderBody() {
+ const c = cur;
+ if (tab === 'feed') {
+ await ready;
+ sheet.body.innerHTML = `${
+ isJoined(c.id)
+ ? `
`
+ : `
${icon('users')}Join to post. Anyone can read.
`
+ }${feedOf(c).map(postHtml).join('')}`;
+ } else if (tab === 'watch') {
+ const rs = await rooms();
+ sheet.body.innerHTML = `
${icon('play')}Co-watch
+ ${c.events
+ .map(
+ (
+ e,
+ ) => `
${esc(e.when)} · ${e.going.toLocaleString()} going
${esc(e.title)} ${esc(e.sub)}
+
${icon('users')}Co-watch now Example
`,
+ )
+ .join('')}
+
${icon('now')}Crew rooms live now
+ ${
+ rs.length
+ ? rs
+ .map(
+ ({ s, v }) =>
+ `
${esc(s.title)} ${v.toLocaleString()} watching · ${esc(s.place || '')} ${icon('chevronRight', { cls: 'chev' })}`,
+ )
+ .join('')
+ : '
No live rooms for this crew right now. Nothing unusual is a valid state.
'
+ }`;
+ sheet.body._rooms = rs;
+ } else if (tab === 'members') {
+ const list = [...new Set([...c.mods, ...c.roster])];
+ sheet.body.innerHTML = `
${icon('sparkles')}Ordered by Signal. Crews show what members got right, never follower counts.
+ ${list
+ .map((x) => ({
+ x,
+ t: simTier(x, personaOf(x)?.floor || 0),
+ p: personaOf(x),
+ }))
+ .sort((a, b) => b.t.min - a.t.min)
+ .map(
+ ({ x, t, p }) =>
+ `
${esc(x.slice(1, 3).toUpperCase())} ${esc(x)} ${esc(t.name)}${p?.domain ? ` · ${esc(p.domain)}` : ''}${c.mods.includes(x) ? ' · Mod' : ''} ×${t.weight.toFixed(1)}
`,
+ )
+ .join('')}
+ ${isJoined(c.id) ? `
YOU ${esc(core.rt.me.handle)} ${esc(core.signal.tier().name)} · you ×${core.signal.tier().weight.toFixed(1)}
` : ''}
+ `;
+ } else {
+ sheet.body.innerHTML = `
${c.rules.map((r) => `${esc(r)} `).join('')}
+ `;
+ }
+ }
+
+ function renderHead() {
+ const c = cur;
+ const j = isJoined(c.id);
+ sheet.head.innerHTML = `
+ ${crewAvatar(c, 'lg')}
+
${esc(c.area)} · Crew Example
${esc(c.name)} ${esc(c.tagline)}
+
${c.roster
+ .slice(0, 4)
+ .map(
+ (x) =>
+ `${esc(x.slice(1, 3).toUpperCase())} `,
+ )
+ .join('')} ${fmtM(c.members)} members
+
${j ? `${icon('checkmark')}Joined` : 'Join'}
+
+
${['feed', 'watch', 'members', 'rules'].map((t) => `${t[0].toUpperCase() + t.slice(1)} `).join('')}
`;
+ }
+
+ async function open(id, { tab: t = 'feed' } = {}) {
+ cur = CREW[id];
+ if (!cur) return;
+ sheet.root.parentElement?.appendChild(sheet.root);
+ tab = t;
+ renderHead();
+ sheet.body.innerHTML = '';
+ sheet.open('large');
+ await renderBody();
+ }
+
+ sheet.root.addEventListener('click', async (e) => {
+ if (!cur) return;
+ if (e.target.closest('[data-join]')) {
+ const on = toggle(cur.id);
+ haptic(on ? 'success' : 'tick');
+ toast(on ? `Joined ${cur.name}` : `Left ${cur.name}`, {
+ icon: icon('users'),
+ });
+ renderHead();
+ await renderBody();
+ return;
+ }
+ const ct = e.target.closest('[data-ctab]');
+ if (ct) {
+ tab = ct.dataset.ctab;
+ $$('[data-ctab]', sheet.head).forEach((b) =>
+ b.setAttribute('aria-selected', String(b === ct)),
+ );
+ haptic('tick');
+ await renderBody();
+ return;
+ }
+ const cw = e.target.closest('[data-cowatch]');
+ if (cw) {
+ const evt = cur.events.find((x) => x.id === cw.dataset.cowatch);
+ const crew = cur;
+ sheet.close();
+ if (evt?.moment) {
+ const m = await core.moment(evt.moment);
+ core.openRoom(m, { lane: 'live', crew });
+ } else {
+ const sits = (await core.situations?.().catch(() => [])) || [];
+ const { chatGenre } = await import('./chatSim.js');
+ const s = sits.find((x) => chatGenre(x) === evt?.genre) || sits[0];
+ if (s) core.openRoom(s, { lane: 'live', crew });
+ }
+ return;
+ }
+ const rm = e.target.closest('[data-room]');
+ if (rm) {
+ const x = (sheet.body._rooms || []).find(
+ (y) => String(y.s.id) === rm.dataset.room,
+ );
+ const crew = cur;
+ sheet.close();
+ if (x) core.openRoom(x.s, { lane: 'live', crew });
+ }
+ });
+ sheet.root.addEventListener('submit', async (e) => {
+ const f = e.target.closest('.cp-compose');
+ if (!f) return;
+ e.preventDefault();
+ const inp = $('input', f);
+ const t = inp.value.trim();
+ if (!t) return;
+ await postTo(cur.id, t);
+ haptic('success');
+ toast(`Posted to ${cur.name}`, { icon: icon('users') });
+ await renderBody();
+ });
+
+ return {
+ on: ev.on,
+ list: () => CREWS.map((c) => ({ ...c, joined: isJoined(c.id) })),
+ isJoined,
+ toggle,
+ open,
+ feedOf,
+ postTo,
+ };
+}
+
+export { fmtM };
diff --git a/mobile/ui/emotes.js b/mobile/ui/emotes.js
new file mode 100644
index 000000000..9fda01d3c
--- /dev/null
+++ b/mobile/ui/emotes.js
@@ -0,0 +1,126 @@
+/**
+ * Chat emotes and reaction glyphs, hand-drawn as inline SVG so they look
+ * the same on every platform (no emoji fonts). Emote codes are typed as
+ * words, Twitch-style: "SealIt", "o7", "HeliSpin".
+ */
+
+import { esc } from './dom.js';
+
+const S = (body, vb = '0 0 32 32') =>
+ `
${body} `;
+
+/** Reaction glyphs (the heart stream). Filled, with a soft highlight. */
+export const REACT = {
+ heart: {
+ label: 'Heart',
+ color: '#ff5a7a',
+ svg: S(
+ `
`,
+ ),
+ },
+ fire: {
+ label: 'Fire',
+ color: '#ff8a3d',
+ svg: S(
+ `
`,
+ ),
+ },
+ eyes: {
+ label: 'Eyes',
+ color: '#a3d8ff',
+ svg: S(
+ `
`,
+ ),
+ },
+ wow: {
+ label: 'Wow',
+ color: '#ffd166',
+ svg: S(
+ `
`,
+ ),
+ },
+};
+export const REACT_KINDS = ['heart', 'fire', 'eyes', 'wow'];
+
+/** Chat emotes. */
+export const EMOTES = {
+ SealIt: {
+ hint: 'Seal it or it didn’t happen',
+ svg: S(
+ `
`,
+ ),
+ },
+ o7: {
+ hint: 'Salute',
+ svg: S(
+ `
`,
+ ),
+ },
+ EyesUp: {
+ hint: 'Look up',
+ svg: S(
+ `
`,
+ ),
+ },
+ HeliSpin: {
+ hint: 'Helicopter circling',
+ svg: S(
+ `
`,
+ ),
+ },
+ Receipts: {
+ hint: 'Show the receipts',
+ svg: S(
+ `
`,
+ ),
+ },
+ Doubt: {
+ hint: 'Doubt',
+ svg: S(
+ `
`,
+ ),
+ },
+ StormMode: {
+ hint: 'Storm mode',
+ svg: S(
+ `
`,
+ ),
+ },
+ LiftOff: {
+ hint: 'Launch',
+ svg: S(
+ `
`,
+ ),
+ },
+};
+export const EMOTE_CODES = Object.keys(EMOTES);
+
+export const reactSvg = (kind) => (REACT[kind] || REACT.heart).svg;
+
+/**
+ * Chat line → HTML: escapes text, turns emote codes into glyphs and
+ * @mentions into highlighted handles.
+ * @param {string} text
+ * @param {{ me?: string }} o
+ */
+export function richText(text, { me = '' } = {}) {
+ const parts = String(text || '').split(/(\s+)/);
+ let mentionsMe = false;
+ const html = parts
+ .map((w) => {
+ if (/^\s+$/.test(w)) return w;
+ const bare = w.replace(/[.,!?]+$/, '');
+ const tail = w.slice(bare.length);
+ if (EMOTES[bare])
+ return `
${EMOTES[bare].svg} ${esc(tail)}`;
+ if (/^@[\w.-]{2,}$/.test(bare)) {
+ const mine =
+ me && bare.toLowerCase() === String(me).toLowerCase().trim();
+ if (mine) mentionsMe = true;
+ return `
${esc(bare)} ${esc(tail)}`;
+ }
+ return esc(w);
+ })
+ .join('');
+ return { html, mentionsMe };
+}
diff --git a/mobile/ui/liveChat.js b/mobile/ui/liveChat.js
new file mode 100644
index 000000000..42868a19f
--- /dev/null
+++ b/mobile/ui/liveChat.js
@@ -0,0 +1,1120 @@
+/**
+ * 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 ? `Open ` : ''}
`;
+ 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 `
+ L${m.n} ${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 `
+ ${icon('reply')}Reply
+ ${icon('agree')}${mine ? 'Corroborated' : 'Corroborate'}
+ ${icon('sendUp')}${m.promoted ? 'In the Record' : 'Promote'}
+ ${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(`
+
+
+
+
${icon('chevronDown')}New messages
+
`);
+ const dockEl = h(`
+
+
+ ${REACT_KINDS.map((k) => `${REACT[k].svg} `).join('')}
+
+ ${icon('eyes')}Need eyes
+ ${icon('viewfinder')}
+
+
+
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
+ ? `${icon('eyes')}Need eyes “${esc(need.question)}”${esc(need.distLabel || '')} `
+ : ''
+ }`;
+ }
+ 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) => `${EMOTES[c].svg}${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) => `${esc(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) => `${REACT[k].svg} `).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;
+
+ 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 > 9) list.firstElementChild.remove();
+ // 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(-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 };
diff --git a/mobile/ui/needEyes.js b/mobile/ui/needEyes.js
new file mode 100644
index 000000000..a2d066d91
--- /dev/null
+++ b/mobile/ui/needEyes.js
@@ -0,0 +1,578 @@
+/**
+ * Need eyes (DESIGN.md §12.2): any room can ask for coverage at a place
+ * ("Is the 7th St underpass flooded?"). People nearby get one quiet ask;
+ * fulfilling it with a sealed capture earns Signal and the byline. This is
+ * the incentive Citizen never had: curiosity turned into coverage.
+ *
+ * Seeded requests are EXAMPLES tied to live situations (fictional askers);
+ * requests the person makes are real, stored on this device and shared with
+ * their other tabs.
+ */
+
+import { icon } from './icons.js';
+import {
+ $,
+ $$,
+ h,
+ esc,
+ ago,
+ haptic,
+ toast,
+ emitter,
+ esriThumb,
+ reducedMotion,
+} from './dom.js';
+import { createSheet } from './sheet.js';
+import { createMiniMap } from './minimap.js';
+import { distKm, fmtKm, llShort, utc, bandOf } from './bands.js';
+import { genre as roomGenre } from './rooms.js';
+import { get, put } from '../capture/db.js';
+import { tierOf, RULE, hashStr } from './signal.js';
+
+/** Per-genre questions, written like people actually ask. */
+const ASKS = {
+ circling: [
+ 'What are the helicopters circling? Anyone on 34th St with a view up?',
+ 'Can someone at street level see what’s on the building?',
+ ],
+ lightning: [
+ 'Anyone on the coast with a view of the line? Need a sealed photo of the shelf cloud.',
+ ],
+ storm: [
+ 'Is the 7th St underpass flooded? Radar has the core right over it.',
+ 'Are the streetlights out along the waterfront?',
+ ],
+ cyclone: [
+ 'Is the seawall at the harbour overtopping yet?',
+ 'How high is the water at the fish port right now?',
+ ],
+ fire: [
+ 'Flames or just smoke from the ridge road? Need a photo facing east.',
+ 'Is the smoke reaching the school on the valley floor?',
+ ],
+ quake: [
+ 'Any visible damage to the old brick blocks downtown?',
+ 'Is the bridge open? Need a photo from either end.',
+ ],
+ squawk: [
+ 'Anyone at the airport fence to see the landing?',
+ 'Are there fire trucks on the taxiway?',
+ ],
+ orbit: [
+ 'Clear enough to see the pass tonight? Need a sky photo facing west.',
+ 'Is it cloudy where you are? Need a sky check before the pass.',
+ ],
+ eye: [
+ 'Is the lane closure still on? The camera angle can’t tell.',
+ 'How long is the line at the corner right now?',
+ ],
+ sea: ['How many ships can you count from the breakwater?'],
+ air: [
+ 'Which runway are they landing on? Need a photo from the viewing area.',
+ ],
+ gnss: ['Anyone flying through tonight with a nav display photo?'],
+ recon: ['Can anyone photograph the upstream bridge abutment?'],
+ default: ['What does it look like from street level right now?'],
+};
+const ASKERS = [
+ '@riverside.example',
+ '@lena.example',
+ '@datadesk.example',
+ '@metdesk.example',
+ '@skeptic.example',
+];
+
+/** Modal sheets created early must still open above later ones. */
+export const front = (sh) => sh.root.parentElement?.appendChild(sh.root);
+
+export function createNeeds(core) {
+ const ev = emitter();
+ let seeds = []; // examples from situations
+ let near = new Map(); // key → examples near a point
+ let mine = []; // the person's requests and fulfilments
+ let done = {}; // id → fulfilment
+ const ready = (async () => {
+ const rec = await get('kv', 'needEyes').catch(() => null);
+ mine = rec?.list || [];
+ done = rec?.done || {};
+ ev.emit('change');
+ })();
+ async function save() {
+ await put('kv', { id: 'needEyes', list: mine, done }).catch(() => {});
+ }
+ core.rt.on('need', (req) => {
+ if (!req?.id) return;
+ const i = mine.findIndex((x) => x.id === req.id);
+ if (i >= 0) mine[i] = { ...mine[i], ...req };
+ else mine.unshift({ ...req, remote: true });
+ if (req.fulfilledBy) done[req.id] = req.fulfilledBy;
+ ev.emit('change');
+ });
+
+ /** Seeds: one example request for each of the most active situations. */
+ function seedFrom(sits) {
+ const out = [];
+ const seen = new Set();
+ for (const s of sits) {
+ if (!Number.isFinite(s.lat)) continue;
+ const g = roomGenre(s);
+ const k = /lightning/.test(s.kind || '') ? 'lightning' : g;
+ if (seen.has(k) || out.length >= 14) continue;
+ seen.add(k);
+ const list = ASKS[k] || ASKS[g] || ASKS.default;
+ const hs = hashStr(s.id);
+ const q = list[hs % list.length];
+ // A spot near the situation, not on top of it (a street, a pier).
+ const km =
+ g === 'cyclone'
+ ? 60
+ : g === 'eye'
+ ? 0.15
+ : g === 'circling'
+ ? 0.25
+ : 1.2;
+ const ang = ((hs % 360) * Math.PI) / 180;
+ const dLat = (km / 111) * Math.cos(ang);
+ const dLon =
+ (km / (111 * Math.cos((s.lat * Math.PI) / 180))) * Math.sin(ang);
+ out.push({
+ id: `need-${s.id}`,
+ room: String(s.id),
+ roomTitle: s.title,
+ situation: s,
+ question: q,
+ lat: s.lat + dLat,
+ lon: s.lon + dLon,
+ radiusKm: g === 'cyclone' ? 25 : 2,
+ at:
+ (Number(new Date(s.updatedAt)) || Date.now()) -
+ (8 + (hs % 30)) * 60e3,
+ by: ASKERS[hs % ASKERS.length],
+ asked: 12 + (hs % 70),
+ example: true,
+ genre: g,
+ });
+ }
+ seeds = out;
+ ev.emit('change');
+ }
+ core
+ .situations?.()
+ .then(seedFrom)
+ .catch(() => {});
+
+ /** Example requests near any point (Your sky, a street check). */
+ function nearSeeds(ll) {
+ if (!ll || !Number.isFinite(ll.lat)) return [];
+ const key = `${ll.lat.toFixed(1)},${ll.lon.toFixed(1)}`;
+ if (near.has(key)) return near.get(key);
+ const place = (title, dLat, dLon, extra = {}) => ({
+ id: `place-near-${key}-${hashStr(title).toString(36)}`,
+ kind: 'place',
+ family: 'eyes',
+ band: 'inside',
+ archetype: 'sensor',
+ title,
+ place: llShort(ll.lat + dLat, ll.lon + dLon, 2),
+ lat: ll.lat + dLat,
+ lon: ll.lon + dLon,
+ live: true,
+ updatedAt: Date.now(),
+ source: 'Need eyes request',
+ metrics: [],
+ layers: [],
+ camera: {
+ lon: ll.lon + dLon,
+ lat: ll.lat + dLat,
+ height: 3000,
+ heading: 0,
+ pitch: -45,
+ },
+ adhoc: true,
+ ...extra,
+ });
+ const sky = place('Sky check before tonight’s ISS pass', 0.006, -0.004, {
+ kind: 'iss',
+ band: 'orbit',
+ });
+ const street = place('Street check near you', -0.009, 0.007);
+ const list = [
+ {
+ id: `need-near-${key}-sky`,
+ room: sky.id,
+ roomTitle: sky.title,
+ situation: sky,
+ question:
+ 'Is the sky clear enough for the ISS pass? Need a sealed photo facing west.',
+ lat: sky.lat,
+ lon: sky.lon,
+ radiusKm: 5,
+ at: Date.now() - 14 * 60e3,
+ by: '@pass_predictor',
+ asked: 23,
+ example: true,
+ genre: 'orbit',
+ },
+ {
+ id: `need-near-${key}-street`,
+ room: street.id,
+ roomTitle: street.title,
+ situation: street,
+ question:
+ 'Traffic sensors show a slowdown on the main road. Crash or roadworks?',
+ lat: street.lat,
+ lon: street.lon,
+ radiusKm: 2,
+ at: Date.now() - 6 * 60e3,
+ by: '@datadesk.example',
+ asked: 9,
+ example: true,
+ genre: 'default',
+ },
+ ];
+ near.set(key, list);
+ return list;
+ }
+
+ const decorate = (r) => {
+ const loc = core.getLocation?.() || null;
+ const km = loc ? distKm(loc, r) : NaN;
+ const f = done[r.id] || r.fulfilledBy || null;
+ return {
+ ...r,
+ km,
+ distLabel:
+ Number.isFinite(km) && km <= 150
+ ? `${fmtKm(km)} from you`
+ : `${fmtKm(r.radiusKm)} radius`,
+ status: f ? 'fulfilled' : 'open',
+ fulfilledBy: f,
+ };
+ };
+ function allList() {
+ const ids = new Set();
+ const nearAll = [...near.values()].flat();
+ return [...mine, ...seeds, ...nearAll]
+ .filter((r) => (ids.has(r.id) ? false : ids.add(r.id)))
+ .map(decorate);
+ }
+ function forRoom(roomId) {
+ return allList().filter(
+ (r) => r.room === String(roomId) && r.status === 'open',
+ );
+ }
+ function nearPoint(ll, km = 25) {
+ if (!ll || !Number.isFinite(ll.lat)) return [];
+ nearSeeds(ll);
+ return allList()
+ .map((r) => ({ ...r, km: distKm(ll, r) }))
+ .filter((r) => r.km <= Math.max(km, r.radiusKm || 0))
+ .sort((a, b) =>
+ a.status === b.status ? a.km - b.km : a.status === 'open' ? -1 : 1,
+ )
+ .map((r) => ({ ...r, distLabel: `${fmtKm(r.km)} away` }));
+ }
+ const byId = (id) => allList().find((r) => r.id === id) || null;
+
+ /* ---------------- UI: card ---------------- */
+ function cardHtml(r) {
+ const f = r.fulfilledBy;
+ return `
+
${icon('eyes')}Need eyes · ${esc(r.distLabel || '')} ${r.example ? 'Example ' : ''}
+
“${esc(r.question)}”
+
${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} nearby asked once
+ ${
+ f
+ ? `
${icon('sealFill')}Fulfilled · ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}
`
+ : r.self
+ ? `
${icon('clock')}Your request · waiting for a sealed answer · expires in 2 h
`
+ : `
${icon('viewfinder')}I’m nearby · answer +${RULE.fulfil.delta} Signal · byline Details
`
+ }
+
`;
+ }
+
+ /* ---------------- UI: create ---------------- */
+ const createS = createSheet({
+ label: 'Ask for eyes',
+ detents: ['large'],
+ modal: true,
+ dismissible: true,
+ closeButton: true,
+ className: 'need-sheet opaque',
+ heights: { large: (H) => Math.round(Math.min(H * 0.9, 820)) },
+ });
+ let cctx = null;
+ function openCreate(s) {
+ front(createS);
+ const g = roomGenre(s);
+ cctx = { s, center: { lat: s.lat, lon: s.lon }, radius: 2 };
+ createS.head.innerHTML = ` Need eyes· ${esc(s.title)}
+ Ask for eyes on a spot
+ People within the radius get one quiet ask. The first sealed answer gets the byline and Signal.
`;
+ createS.body.innerHTML = `
+ Your question
+
+ ${(ASKS[g] || ASKS.default).map((q) => `${esc(q)} `).join('')}
+ Ask people within
+ ${[0.5, 2, 10].map((v) => `${fmtKm(v)} `).join('')}
+ Calm by default: each person gets at most one ask a day, only inside the radius, never with a sound. Requests expire after 2 hours.
+ ${icon('eyes')}Ask for eyes `;
+ createS.open('large');
+ requestAnimationFrame(() => {
+ const mm = createMiniMap($('.need-map', createS.body), {
+ lat: s.lat,
+ lon: s.lon,
+ spanDeg: g === 'cyclone' ? 1.2 : 0.03,
+ label: 'Drag the map to the spot',
+ onChange: (c) => (cctx.center = c),
+ });
+ cctx.mm = mm;
+ });
+ }
+ createS.root.addEventListener('click', async (e) => {
+ if (!cctx) return;
+ const sg = e.target.closest('[data-sugg]');
+ if (sg) {
+ $('.need-q', createS.body).value = sg.dataset.sugg;
+ haptic('tick');
+ return;
+ }
+ const rb = e.target.closest('[data-radius] button');
+ if (rb) {
+ cctx.radius = Number(rb.dataset.v);
+ $$('[data-radius] button', createS.body).forEach((b) =>
+ b.setAttribute('aria-pressed', String(b === rb)),
+ );
+ haptic('tick');
+ return;
+ }
+ if (e.target.closest('[data-act="ask"]')) {
+ const q = $('.need-q', createS.body).value.trim();
+ if (!q) {
+ $('.need-q', createS.body).focus();
+ toast('Write the question first');
+ return;
+ }
+ const s = cctx.s;
+ const req = {
+ id: `need-${core.rt.tab}-${Date.now().toString(36)}`,
+ room: String(s.id),
+ roomTitle: s.title,
+ situation: slim(s),
+ question: q,
+ lat: cctx.center.lat,
+ lon: cctx.center.lon,
+ radiusKm: cctx.radius,
+ at: Date.now(),
+ by: core.rt.me.handle,
+ self: true,
+ asked: Math.max(3, Math.round(cctx.radius * 17 + (hashStr(q) % 20))),
+ genre: roomGenre(s),
+ };
+ mine.unshift(req);
+ await save();
+ core.rt.need(req);
+ ev.emit('change');
+ haptic('success');
+ createS.close();
+ toast(
+ `Asked · about ${req.asked} people nearby get one quiet ask (simulated)`,
+ {
+ icon: icon('eyes'),
+ },
+ );
+ core.hub(s)?.push({
+ id: `sys-need-${req.id}`,
+ system: true,
+ handle: 'Need eyes',
+ text: `${req.by} asked for eyes: “${q}” · ${fmtKm(req.radiusKm)} radius`,
+ at: Date.now(),
+ });
+ }
+ });
+ const slim = (s) => ({
+ id: s.id,
+ kind: s.kind,
+ family: s.family,
+ band: s.band,
+ archetype: s.archetype,
+ title: s.title,
+ place: s.place,
+ lat: s.lat,
+ lon: s.lon,
+ live: s.live,
+ updatedAt: s.updatedAt,
+ source: s.source,
+ metrics: s.metrics || [],
+ layers: s.layers || [],
+ camera: s.camera,
+ momentId: s.momentId,
+ adhoc: s.adhoc,
+ });
+
+ /* ---------------- UI: detail ---------------- */
+ const detailS = createSheet({
+ label: 'Need eyes',
+ detents: ['large'],
+ modal: true,
+ dismissible: true,
+ closeButton: true,
+ className: 'need-sheet opaque',
+ heights: { large: (H) => Math.round(Math.min(H * 0.72, 640)) },
+ });
+ function openDetail(id) {
+ const r = byId(id);
+ if (!r) return;
+ front(detailS);
+ detailS.head.innerHTML = ` Need eyes· ${esc(r.roomTitle || '')}
+ “${esc(r.question)}”
+ Asked by ${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} people nearby asked once${r.example ? ' · Example ' : ''}
`;
+ detailS.body.innerHTML = `${icon('pin')} ${esc(llShort(r.lat, r.lon, 4))} · ${esc(fmtKm(r.radiusKm))} RADIUS
+
+ ${icon('sealFill')}Answer with a sealed capture from inside the radius. Text answers don’t fulfil a request.
+ ${icon('eyes')}First eyes on a request is a permanent byline on the record.
+ ${icon('sparkles')}+${RULE.fulfil.delta} Signal , more if sensors corroborate it. Signal weighs your votes and notes.
+
+ ${
+ r.fulfilledBy
+ ? `${icon('sealFill')}Fulfilled by ${esc(r.fulfilledBy.handle)} · ${esc(utc(new Date(r.fulfilledBy.at)))}
`
+ : `${icon('viewfinder')}I’m nearby · answer with a capture `
+ }`;
+ detailS.open('large');
+ }
+ detailS.root.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-need-act]');
+ if (b) {
+ detailS.close();
+ act(b.dataset.needAct, b.dataset.need);
+ }
+ });
+
+ function act(action, id) {
+ const r = byId(id);
+ if (!r) return;
+ haptic('light');
+ if (action === 'detail') openDetail(id);
+ else if (action === 'fulfil') {
+ if (r.status === 'fulfilled') {
+ toast('Already fulfilled · thank you');
+ return;
+ }
+ core.onCapture?.(r.situation, { needEyes: r });
+ }
+ }
+
+ /** Called after a sealed capture is posted against a request. */
+ async function fulfil(req, post, { km = NaN } = {}) {
+ await ready;
+ const f = {
+ handle: core.rt.me.handle,
+ at: post.anchor?.t || post.at,
+ post: post.id,
+ room: post.room,
+ km,
+ self: true,
+ };
+ done[req.id] = f;
+ const i = mine.findIndex((x) => x.id === req.id);
+ if (i >= 0) mine[i] = { ...mine[i], fulfilledBy: f };
+ await save();
+ core.rt.need({
+ ...req,
+ situation: req.situation ? slim(req.situation) : null,
+ fulfilledBy: f,
+ });
+ ev.emit('change');
+ return f;
+ }
+
+ return {
+ on: ev.on,
+ off: ev.off,
+ ready,
+ all: allList,
+ forRoom,
+ near: nearPoint,
+ byId,
+ cardHtml,
+ openCreate,
+ openDetail,
+ act,
+ fulfil,
+ seedFrom,
+ };
+}
+
+/* ---------------- The byline moment ---------------- */
+
+/**
+ * A satisfying, honest moment after a capture earns Signal: the seal
+ * stamps, the byline types out, the score counts up receipt by receipt.
+ * @param {{ byline?: string, first?: boolean, need?: object,
+ * receipts: { delta, text }[], before: number, after: number,
+ * onRecord?(): void }} o
+ */
+export function celebrate(o) {
+ const t0 = tierOf(o.before);
+ const t1 = tierOf(o.after);
+ const el =
+ h(`
+
+
${icon('sealFill')}
+
${o.need ? 'Need eyes fulfilled' : 'Sealed capture posted'}
+
${o.first ? 'First eyes' : 'On the record'}
+ ${o.need ? `
“${esc(o.need.question)}”
` : ''}
+
+
${o.receipts.map((r, i) => `${esc(r.text)} ${r.delta > 0 ? '+' : ''}${r.delta} `).join('')}
+
Signal ${o.before} ${esc(t1.name)}
+
+
${t1.next ? `${t1.next.min - o.after} to ${esc(t1.next.name)} · your votes now weigh ×${t1.weight.toFixed(1)}` : `Top tier · votes weigh ×${t1.weight.toFixed(1)}`}
+ ${t1.index > t0.index ? `
${icon('sparkles')}Tier up · ${esc(t0.name)} → ${esc(t1.name)}
` : ''}
+
Done ${o.onRecord ? `${icon('note')}See it in the Record ` : ''}
+
Receipts are kept in You → Signal, each with its reason and source.
+
+
`);
+ document.body.appendChild(el);
+ haptic('seal');
+ requestAnimationFrame(() => el.classList.add('show'));
+ // Type the byline.
+ const tgt = $('[data-type]', el);
+ const text = o.byline || '';
+ let i = 0;
+ const typer = setInterval(
+ () => {
+ i += 2;
+ tgt.textContent = text.slice(0, i);
+ if (i >= text.length) clearInterval(typer);
+ },
+ reducedMotion() ? 0 : 28,
+ );
+ // Count the score up.
+ const sc = $('[data-score]', el);
+ const bar = $('.bm-bar i', el);
+ const start = performance.now() + 700;
+ const dur = reducedMotion() ? 1 : 1600;
+ const step = (now) => {
+ const k = Math.max(0, Math.min(1, (now - start) / dur));
+ const e = 1 - (1 - k) ** 3;
+ const v = Math.round(o.before + (o.after - o.before) * e);
+ sc.textContent = v.toLocaleString();
+ const tv = tierOf(v);
+ bar.style.setProperty('--p', `${Math.round(tv.progress * 100)}%`);
+ if (k < 1 && el.isConnected) requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ const close = () => {
+ clearInterval(typer);
+ el.classList.remove('show');
+ setTimeout(() => el.remove(), 380);
+ };
+ el.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-bm]');
+ if (b?.dataset.bm === 'record') {
+ close();
+ o.onRecord?.();
+ } else if (b || e.target === el) close();
+ });
+ return { close, el };
+}
diff --git a/mobile/ui/realtime.js b/mobile/ui/realtime.js
index 5dc7a3da2..509e7849f 100644
--- a/mobile/ui/realtime.js
+++ b/mobile/ui/realtime.js
@@ -97,6 +97,11 @@ export function createRealtime() {
else if (m.t === 'react')
ev.emit('react', { room: m.room, kind: m.kind, remote: true });
else if (m.t === 'rate') ev.emit('rate', m);
+ else if (m.t === 'chat')
+ ev.emit('chat', { room: m.room, msg: { ...m.msg, remote: true } });
+ else if (m.t === 'corro')
+ ev.emit('corro', { room: m.room, id: m.id, w: m.w, remote: true });
+ else if (m.t === 'need') ev.emit('need', m.req);
else if (m.t === 'live') {
const cur = lives.get(m.live.id);
lives.set(m.live.id, {
@@ -161,6 +166,10 @@ export function createRealtime() {
post: (post) => send({ t: 'post', post }),
react: (r, kind) => send({ t: 'react', room: r, kind }),
rate: (payload) => send({ t: 'rate', ...payload }),
+ /** Live lane: one chat line to every other tab in the same room. */
+ chat: (r, msg) => send({ t: 'chat', room: r, msg }),
+ corro: (r, id, w) => send({ t: 'corro', room: r, id, w }),
+ need: (req) => send({ t: 'need', req }),
/* live broadcasts */
liveTick: (live, frame) => send({ t: 'live', live, frame }),
liveEnd: (id) => send({ t: 'live-end', id }),
diff --git a/mobile/ui/record.js b/mobile/ui/record.js
new file mode 100644
index 000000000..0963c1d73
--- /dev/null
+++ b/mobile/ui/record.js
@@ -0,0 +1,457 @@
+/**
+ * The Record lane (DESIGN.md §12.6): Reddit's threads and X's quote-posts,
+ * on the evidence. Considered posts with kinds (§11.3), threaded replies,
+ * quote-posts, sealed attachments, Corroborate / Dispute weighted by Signal,
+ * sort by Evidence / New / Disputed, community notes with bridging, the
+ * Known / Disputed / Unknown synthesis, and the Clerk's "What chat is
+ * saying" summaries with citations back to Live lines.
+ */
+
+import { icon } from './icons.js';
+import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js';
+import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js';
+import { llShort, utc, fmtKm } from './bands.js';
+import { mediaUrl } from './witness.js';
+import { simTier, tierOf, hashStr } from './signal.js';
+import { richText } from './emotes.js';
+
+export const SORTS = [
+ { id: 'evidence', name: 'Evidence' },
+ { id: 'new', name: 'New' },
+ { id: 'disputed', name: 'Disputed' },
+];
+
+/** Center an element inside its scroller without moving outer layers. */
+function centerIn(sc, el, smooth) {
+ const top =
+ el.getBoundingClientRect().top -
+ sc.getBoundingClientRect().top +
+ sc.scrollTop -
+ sc.clientHeight / 2 +
+ el.offsetHeight / 2;
+ sc.scrollTo({ top: Math.max(0, top), behavior: smooth ? 'smooth' : 'auto' });
+}
+
+/** Signal-weighted tallies: seeded voters carry a stable average weight. */
+export function weighted(p, core) {
+ const h = hashStr(p.id);
+ const wUp = 1 + (h % 90) / 100;
+ const wDn = 1 + ((h >>> 8) % 60) / 100;
+ const myW = tierOf(core.signal.cached).weight;
+ const sim = core.simCorro?.(p.id) || [];
+ const simW = sim.reduce((a, x) => a + x.w, 0);
+ const baseUp = Math.max(0, (p.votes?.up || 0) - (p.myVote === 'up' ? 1 : 0));
+ const baseDn = Math.max(0, (p.votes?.dn || 0) - (p.myVote === 'dn' ? 1 : 0));
+ const up = baseUp * wUp + (p.myVote === 'up' ? myW : 0) + simW;
+ const dn = baseDn * wDn + (p.myVote === 'dn' ? myW : 0);
+ return {
+ up,
+ dn,
+ nUp: (p.votes?.up || 0) + sim.length,
+ nDn: p.votes?.dn || 0,
+ share: up / Math.max(0.001, up + dn),
+ myW,
+ };
+}
+
+const disputeScore = (p, r, core) => {
+ const w = weighted(p, core);
+ const corr = r.repliesOf(p).filter((x) => x.kind === 'correction').length;
+ return w.dn / Math.max(1, w.up + w.dn) + corr * 0.4;
+};
+
+function authorTier(p, core) {
+ if (p.author?.self) return tierOf(core.signal.cached);
+ return simTier(p.author?.handle || '', p.author?.domain ? 300 : 0);
+}
+
+function citeChip(c, label) {
+ if (!c) return '';
+ const l =
+ label || (/^O\d/.test(c) ? `OBS ${c.slice(1)}` : c === 'O-cam' ? 'CAM' : c);
+ return `${esc(l)} `;
+}
+
+export function synthHtml(r, { compact = true } = {}) {
+ const sy = r.synth;
+ const total = sy.known.length + sy.disputed.length + sy.unknown.length;
+ const sec = (id, title, list) =>
+ list.length
+ ? ` ${title}${list.length}
${list
+ .map(
+ (l, i) =>
+ `${richText(l.text).html} ${l.cites
+ .filter(Boolean)
+ .map((c) => citeChip(c))
+ .join('')} `,
+ )
+ .join('')} `
+ : '';
+ const hidden = [sy.known, sy.disputed, sy.unknown].reduce(
+ (a, l) => a + Math.max(0, l.length - 2),
+ 0,
+ );
+ void total;
+ return `
+ ${icon('sparkles')}Synthesis ${sy.n} posts · ${sy.obs} obs · ${esc(sy.hash.slice(0, 8))}
+ ${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
+ ${compact && hidden ? `Show ${hidden} more line${hidden === 1 ? '' : 's'} ` : ''}
+
+ `;
+}
+
+export function clerkHtml(sum, { older = 0 } = {}) {
+ if (!sum) return '';
+ const q = new Map(sum.quotes.map((x) => [x.id, x]));
+ return `
+ ${icon('sparkles')} What chat is saying Clerk · ${esc(ago(sum.at))} · ${sum.window.n} lines · ${esc(utc(new Date(sum.window.from), { seconds: false }))}–${esc(utc(new Date(sum.window.to), { seconds: false }))} Synthesis
+ ${sum.bullets
+ .map(
+ (b) =>
+ `${esc(b.text)} ${b.cites.length ? `${b.cites.map((c) => citeChip(`chat:${c}`, `L${q.get(c)?.n ?? '?'}`)).join('')} ` : ''} `,
+ )
+ .join('')}
+ ${sum.quotes
+ .map(
+ (x) =>
+ `
L${x.n} ${esc(x.handle)} ${richText(x.text).html}
`,
+ )
+ .join('')}
+
+ `;
+}
+
+function noteHtml(n, p) {
+ const st = noteStatus(n, n.mine);
+ const bar = (c) => {
+ const x = st.by[c];
+ const pct = x.n ? Math.round((x.h / x.n) * 100) : 0;
+ return `${c} ${x.h}/${x.n} `;
+ };
+ const why = st.shown
+ ? 'Raters who usually disagree both found it helpful'
+ : st.status === 'not-bridged'
+ ? `Not shown: one cluster rates it below ${Math.round(BRIDGE.minHelpful * 100)} %`
+ : `Not shown yet: needs ${BRIDGE.minPerCluster}+ raters from cluster ${st.status.replace('needs-', '').split('').join(' and ')}`;
+ return `
+
${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}
+ ${st.shown ? `
${esc(n.text)}
` : `
Show the proposed note ${esc(n.text)}
`}
+
${icon('bridge')}Bridging ${bar('A')}${bar('B')}${esc(why)}
+
${n.mine ? `You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)} ` : `Helpful Not helpful `}
+
`;
+}
+
+function quoteHtml(q, r) {
+ if (!q) return '';
+ const k = KIND[q.kind] || KIND.context;
+ return `${icon(k.icon)}${esc(k.name)} · ${esc(q.author.handle)} · ${esc(r.numbering.get(q.id) || '')} ${esc(q.text.length > 160 ? `${q.text.slice(0, 159)}…` : q.text)} `;
+}
+
+function promotedHtml(pf) {
+ if (!pf) return '';
+ return `${icon('sendUp')}From Live chat · L${esc(pf.n ?? '')} · ${esc(utc(new Date(pf.at)))} · ${pf.auto ? 'promoted by corroboration' : 'promoted'} · ${Number(pf.corro || 0).toFixed(1)} weighted
`;
+}
+
+export function postHtml(p, r, core, { depth = 0, first = null } = {}) {
+ const s = r.situation;
+ const k = KIND[p.kind] || KIND.context;
+ const badges = badgesFor(p, s, core.rt.me);
+ const replies = r.repliesOf(p);
+ const num = r.numbering.get(p.id);
+ const tier = authorTier(p, core);
+ const w = weighted(p, core);
+ const isFirst = first && first.post === p.id;
+ const sim = p.author?.sim;
+ const anchor =
+ p.anchor && Number.isFinite(p.anchor.lat)
+ ? `${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''} `
+ : '';
+ const media =
+ p.evidence?.captureId || p.evidence?.uploadId
+ ? `${p.evidence.unverified ? 'Unverified upload ' : `${icon('sealFill')} `}
`
+ : '';
+ const quoted = p.quoteOf ? r.posts.find((x) => x.id === p.quoteOf) : null;
+ const pct = Math.round(w.share * 100);
+ return `
+
+ ${icon(k.icon)}
+ ${esc(k.name)} ${esc(p.author.handle)}${p.author.self ? ' (you)' : ''} ${esc(tier.name)} ${esc(ago(p.at))} · ${esc(num)}
+ ${sim ? 'Simulated ' : p.example ? 'Example ' : ''}
+
+ ${isFirst ? `${icon('sealFill')}First eyes · ${esc(p.author.handle)} · ${esc(utc(new Date(first.at)))}${Number.isFinite(first.km) ? ` · ${esc(fmtKm(first.km))}` : ''}
` : ''}
+ ${p.needEyes ? `${icon('eyes')}Answered Need eyes · “${esc(p.needEyes.question || '')}”
` : ''}
+ ${richText(p.text).html}
+ ${promotedHtml(p.promotedFrom)}
+ ${quoteHtml(quoted, r)}
+ ${media}
+ ${badges.length ? `${badges.map((b) => `${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` ${esc(b.detail)} ` : ''} `).join('')}
` : ''}
+
+ ${icon('agree')}${w.nUp}
+ ${icon('dispute')}${w.nDn}
+ ${w.up.toFixed(0)}:${w.dn.toFixed(0)}
+
+ ${anchor}
+ ${icon('reply')}${replies.length ? `${replies.length} ` : ''}
+ ${icon('share')}
+
+ ${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
+ ${replies.length ? `${replies.map((x) => postHtml(x, r, core, { depth: depth + 1, first })).join('')}
` : ''}
+ `;
+}
+
+async function hydrateMedia(el) {
+ for (const m of $$('.p-media[data-media]', el)) {
+ if (m.querySelector('img,video')) continue;
+ const u = await mediaUrl(m.dataset.media, m.dataset.store);
+ if (!u) continue;
+ m.insertAdjacentHTML(
+ 'afterbegin',
+ m.dataset.video
+ ? ` `
+ : ` `,
+ );
+ }
+}
+
+/**
+ * Mount the Record lane.
+ * @param {object} core
+ * @param {object} hub the room's Live hub (for Clerk summaries), optional
+ * @param {{ s, body, dock, flyTo?, scrub?, onCompose(s, o), onCapture(s, o),
+ * onNeedEyes?(), focusPost?, onLive?() }} o
+ */
+export function mountRecordLane(core, hub, o) {
+ const s = o.s;
+ let sort = 'evidence';
+ let r = null;
+ let showOlder = false;
+ const root = h('
');
+ const dockEl = h(
+ `${icon('viewfinder')}Reply with evidence Seal what you see ${icon('note')}Write ${icon('eyes')}
`,
+ );
+ o.body.appendChild(root);
+ o.dock.appendChild(dockEl);
+ const scroller = () => o.body;
+
+ async function render({ keep = true, focusPost } = {}) {
+ const sc = keep ? scroller().scrollTop : 0;
+ r = await core.rooms.room(s);
+ const first = hub?.firstEyes || null;
+ const roots = r.posts.filter((p) => !p.replyTo);
+ let list;
+ if (sort === 'new') list = [...roots].sort((a, b) => b.at - a.at);
+ else if (sort === 'disputed')
+ list = [...roots]
+ .filter((p) => disputeScore(p, r, core) > 0.12)
+ .sort((a, b) => disputeScore(b, r, core) - disputeScore(a, r, core));
+ else list = r.ordered.filter((p) => !r.openQ.includes(p));
+ const clerk = hub?.clerk || [];
+ const needs = core.needs?.forRoom?.(r.id) || [];
+ root.innerHTML = `
+ ${SORTS.map((x) => `${x.name} `).join('')}${r.posts.length} posts
+ ${synthHtml(r)}
+ ${clerk[0] ? clerkHtml(clerk[0], { older: clerk.length - 1 }) : ''}
+ ${
+ showOlder
+ ? clerk
+ .slice(1)
+ .map((c) => clerkHtml(c))
+ .join('')
+ : ''
+ }
+ ${needs.map((n) => core.needs.cardHtml(n)).join('')}
+ ${r.hasExamples ? 'Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''}
+ ${sort === 'evidence' && r.openQ.length ? `${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r, core, { first })).join('')}` : ''}
+ ${icon(sort === 'disputed' ? 'dispute' : sort === 'new' ? 'clock' : 'sealFill')}${sort === 'evidence' ? 'Evidence first sealed, corroborated, close beats loud ' : sort === 'new' ? 'Newest first' : 'Most disputed weighted by Signal '}
+ ${list.length ? list.map((p) => postHtml(p, r, core, { first })).join('') : 'Nothing disputed here. Votes and corrections move posts into this view.
'}
+ ${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
`;
+ hydrateMedia(root);
+ pinPosts();
+ if (keep) scroller().scrollTop = sc;
+ if (focusPost) {
+ const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, root);
+ if (pe) centerIn(scroller(), pe, false);
+ pe?.classList.add('flash');
+ }
+ return r;
+ }
+
+ /* ---- globe pins for anchored posts ---- */
+ let pinIds = [];
+ function clearPins() {
+ for (const id of pinIds) core.globe?.removePin?.(id);
+ pinIds = [];
+ }
+ function pinPosts() {
+ clearPins();
+ if (!core.globe?.addPin) return;
+ for (const p of r.posts) {
+ if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue;
+ const kind = p.evidence?.live
+ ? 'live'
+ : p.kind === 'witness'
+ ? 'witness'
+ : 'post';
+ const id = core.globe.addPin({
+ id: `post:${p.id}`,
+ lon: p.anchor.lon,
+ lat: p.anchor.lat,
+ kind: p.hologram ? 'hologram' : kind,
+ label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`,
+ headingDeg: p.evidence?.headingDeg,
+ });
+ if (id) pinIds.push(id);
+ }
+ }
+
+ function flash(el) {
+ if (!el) return;
+ centerIn(scroller(), el, !reducedMotion());
+ el.classList.remove('flash');
+ void el.offsetWidth;
+ el.classList.add('flash');
+ }
+
+ root.addEventListener('click', async (e) => {
+ const srt = e.target.closest('[data-sort]');
+ if (srt) {
+ sort = srt.dataset.sort;
+ haptic('tick');
+ await render({ keep: false });
+ scroller().scrollTop = 0;
+ return;
+ }
+ if (e.target.closest('[data-act="synth-all"]')) {
+ $('.synth', root)?.classList.remove('compact');
+ e.target.closest('[data-act]').remove();
+ return;
+ }
+ if (e.target.closest('[data-act="older-clerk"]')) {
+ showOlder = !showOlder;
+ await render();
+ return;
+ }
+ const need = e.target.closest('[data-need-act]');
+ if (need) {
+ core.needs.act(need.dataset.needAct, need.dataset.need);
+ return;
+ }
+ const cite = e.target.closest('[data-cite]');
+ if (cite) {
+ const id = cite.dataset.cite;
+ if (id.startsWith('chat:')) {
+ const box = cite.closest('.clerk');
+ const qs = $('.ck-quotes', box);
+ qs.hidden = false;
+ const q = $(`[data-q="${CSS.escape(id.slice(5))}"]`, qs);
+ $$('.ck-q', qs).forEach((x) => x.classList.toggle('hl', x === q));
+ haptic('tick');
+ return;
+ }
+ const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0];
+ const el = target ? $(`[data-post="${CSS.escape(target)}"]`, root) : null;
+ if (el) flash(el);
+ else
+ toast(
+ `${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`,
+ { icon: icon('info') },
+ );
+ return;
+ }
+ const jump = e.target.closest('[data-jump]');
+ if (jump) {
+ flash($(`[data-post="${CSS.escape(jump.dataset.jump)}"]`, root));
+ return;
+ }
+ const postEl = e.target.closest('[data-post]');
+ const post = postEl
+ ? r.posts.find((p) => p.id === postEl.dataset.post)
+ : null;
+ if (!post) return;
+ const anchor = e.target.closest('[data-anchor]');
+ if (anchor && post.anchor) {
+ haptic('light');
+ o.flyTo?.(post.anchor, s);
+ if (post.anchor.t) o.scrub?.(post.anchor.t);
+ return;
+ }
+ const vote = e.target.closest('[data-vote]');
+ if (vote) {
+ haptic('tick');
+ const was = post.myVote;
+ await core.rooms.vote(post, vote.dataset.vote);
+ if (was !== vote.dataset.vote) {
+ const wgt = tierOf(core.signal.cached).weight;
+ toast(
+ `${vote.dataset.vote === 'up' ? 'Corroborated' : 'Disputed'} · your vote weighs ×${wgt.toFixed(1)}`,
+ { icon: icon(vote.dataset.vote === 'up' ? 'agree' : 'dispute') },
+ );
+ }
+ await render();
+ return;
+ }
+ if (e.target.closest('[data-reply]')) {
+ o.onCompose(s, { replyTo: post });
+ return;
+ }
+ if (e.target.closest('[data-quote]')) {
+ o.onCompose(s, { quote: post });
+ return;
+ }
+ const rate = e.target.closest('[data-rate]');
+ if (rate) {
+ const nid = rate.closest('[data-note]').dataset.note;
+ const note = post.notes.find((n) => n.id === nid);
+ const mine = await core.rooms.rate(note, rate.dataset.rate === '1');
+ const { why } = await core.rooms.myCluster();
+ haptic('success');
+ const st = noteStatus(note, mine);
+ if (st.shown && mine.helpful)
+ core.signal.award({
+ id: `note:${note.id}`,
+ rule: 'note',
+ text: `A note you rated helpful is bridged in ${s.title}`,
+ ref: { room: r.id, post: post.id },
+ });
+ toast(
+ `Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`,
+ { icon: icon('bridge') },
+ );
+ await render();
+ }
+ });
+ dockEl.addEventListener('click', (e) => {
+ const act = e.target.closest('[data-act]')?.dataset.act;
+ if (act === 'capture-reply') o.onCapture(s, {});
+ else if (act === 'write') o.onCompose(s, {});
+ else if (act === 'need-eyes') o.onNeedEyes?.();
+ });
+
+ const offPost = core.rooms.on('post', (p) => {
+ if (p.room === String(s.id)) render();
+ });
+ const offClerk = hub?.on('clerk', () => render());
+ const offNeeds = core.needs?.on?.('change', () => render());
+ const offSig = core.on?.(
+ 'simcorro',
+ (x) => x.room === String(s.id) && render(),
+ );
+
+ const ready = render({ keep: false, focusPost: o.focusPost });
+ return {
+ root,
+ ready,
+ render,
+ get room() {
+ return r;
+ },
+ destroy() {
+ clearPins();
+ [offPost, offClerk, offNeeds, offSig].forEach(
+ (f) => typeof f === 'function' && f(),
+ );
+ root.remove();
+ dockEl.remove();
+ },
+ };
+}
diff --git a/mobile/ui/room.js b/mobile/ui/room.js
index a64d8e127..d3764a4f0 100644
--- a/mobile/ui/room.js
+++ b/mobile/ui/room.js
@@ -1,25 +1,139 @@
/**
- * The room view inside a situation's sheet: presence, a pinned Known /
- * Disputed / Unknown synthesis, open questions, evidence-first posts with
- * earned badges, community notes with the bridging rule drawn in the open,
- * anchors that fly the globe and scrub the ribbon, floating reactions, and
- * a reply bar that offers Capture before text.
+ * A room with two lanes (DESIGN.md §12.6): Live (Twitch: fast chat,
+ * emotes, the heart stream rising from the globe) and Record (Reddit and
+ * X: threaded, evidence-ranked, Signal-weighted, with notes and the
+ * synthesis). The bridge between them is promotion and the Clerk.
+ *
+ * `createRoomPanel` mounts both lanes into any sheet (body + dock).
+ * `createRoomView` keeps the round-two contract the world's brief sheet
+ * uses: render(s, el, opts) · onClick(e, { flyTo, scrub }) · replyBar() ·
+ * reactionRail() · close() · current.
*/
import { icon } from './icons.js';
-import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js';
-import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js';
-import { avatar } from './cards.js';
-import { llShort, utc, fmtKm } from './bands.js';
-import { mediaUrl } from './witness.js';
+import { $, h, esc, haptic } from './dom.js';
+import { fmtKm } from './bands.js';
+import { mountLiveLane } from './liveChat.js';
+import { mountRecordLane } from './record.js';
-const REACTIONS = [
- { id: 'seen', icon: 'eyes', label: 'Seeing it too' },
- { id: 'concern', icon: 'hazards', label: 'Concerned' },
- { id: 'thanks', icon: 'hand', label: 'Thanks' },
- { id: 'q', icon: 'question', label: 'Need answers' },
-];
+const fmtV = (v) =>
+ v >= 10000 ? `${(v / 1000).toFixed(1)}k` : v.toLocaleString();
+/**
+ * @param {object} core social core
+ * @param {object} s situation (or adapted Moment)
+ * @param {{ body, dock, switchHost?, lane?, focusPost?, flyTo?, scrub?,
+ * onCompose(s,o), onCapture(s,o), onLane?(lane) }} o
+ */
+export function createRoomPanel(core, s, o) {
+ const hub = core.hub(s);
+ let lane = o.lane === 'record' ? 'record' : 'live';
+ let view = null;
+ const sw = h(`
+ Live
+ ${icon('note')}Record
+
+
`);
+ const host = o.switchHost || o.body;
+ if (o.switchHost) o.switchHost.appendChild(sw);
+ const wrap = h(`
`);
+ if (!o.switchHost) wrap.appendChild(sw);
+ o.body.appendChild(wrap);
+ core.rt.join(hub.id);
+
+ function counts() {
+ $('[data-ls="live"]', sw).textContent = fmtV(hub.viewers);
+ core.rooms
+ .room(s)
+ .then((r) => {
+ const el = $('[data-ls="record"]', sw);
+ if (el) el.textContent = String(r.posts.length + hub.clerk.length);
+ })
+ .catch(() => {});
+ }
+ counts();
+ const offV = hub.on('viewers', () => {
+ const el = $('[data-ls="live"]', sw);
+ if (el) el.textContent = fmtV(hub.viewers);
+ });
+ const offC = hub.on('clerk', counts);
+ const offP = core.rooms.on('post', (p) => p.room === hub.id && counts());
+
+ function mount() {
+ view?.destroy();
+ wrap.dataset.lane = lane;
+ o.body.dataset.lane = lane;
+ o.dock.dataset.lane = lane;
+ $$sel(sw).forEach((b) =>
+ b.setAttribute('aria-selected', String(b.dataset.lane === lane)),
+ );
+ sw.dataset.lane = lane;
+ const common = {
+ s,
+ body: wrap,
+ dock: o.dock,
+ onNeedEyes: () => core.needs.openCreate(s),
+ onCapture: (x, y) => o.onCapture(x || s, y || {}),
+ };
+ if (lane === 'live')
+ view = mountLiveLane(core, hub, {
+ ...common,
+ onCapture: () => o.onCapture(s, {}),
+ onRecord: (ref) => setLane('record', { focusPost: ref }),
+ });
+ else
+ view = mountRecordLane(core, hub, {
+ ...common,
+ flyTo: o.flyTo,
+ scrub: o.scrub,
+ focusPost: o.focusPost,
+ onCompose: o.onCompose,
+ });
+ o.onLane?.(lane);
+ }
+ function setLane(l, { focusPost } = {}) {
+ if (l === lane && !focusPost) return;
+ lane = l;
+ if (focusPost) o.focusPost = focusPost;
+ haptic('tick');
+ mount();
+ }
+ sw.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-lane]');
+ if (b) setLane(b.dataset.lane);
+ });
+ mount();
+ void host;
+
+ return {
+ hub,
+ get lane() {
+ return lane;
+ },
+ get view() {
+ return view;
+ },
+ setLane,
+ destroy() {
+ view?.destroy();
+ view = null;
+ offV();
+ offC();
+ offP();
+ sw.remove();
+ wrap.remove();
+ delete o.body.dataset.lane;
+ delete o.dock.dataset.lane;
+ core.rt.leave();
+ },
+ };
+}
+const $$sel = (sw) => [...sw.querySelectorAll('[data-lane]')];
+
+/**
+ * Round-two contract for the world's brief sheet (world.js). It needs the
+ * social core; `getCore` resolves it lazily so world.js need not change.
+ */
export function createRoomView({
rooms,
rt,
@@ -27,340 +141,65 @@ export function createRoomView({
getChrome,
onCapture,
onCompose,
+ getCore,
}) {
- let cur = null; // { s, r, el }
- let pinIds = [];
+ let cur = null; // { s, el, panel }
+ let coreP = null;
+ const core = async () =>
+ (coreP =
+ coreP ||
+ (getCore
+ ? Promise.resolve(getCore())
+ : import('./social.js').then((m) =>
+ m.socialCore({ rooms, rt, globe }),
+ )));
- function clearPins() {
- for (const id of pinIds) globe.removePin(id);
- pinIds = [];
- getChrome()?.setExtraPips([]);
+ async function render(s, el, { focusPost, lane } = {}) {
+ const c = await core();
+ cur?.panel?.destroy();
+ el.innerHTML = '';
+ const sheet = el.closest('.sheet');
+ const dock = sheet?.querySelector('.sheet-dock') || el;
+ dock.innerHTML = '';
+ const panel = createRoomPanel(c, s, {
+ body: el,
+ dock,
+ lane: lane || (focusPost ? 'record' : 'live'),
+ focusPost,
+ onCompose: (x, y) => onCompose(x, y),
+ onCapture: (x, y) => onCapture(x, y),
+ flyTo: null,
+ scrub: (t) => getChrome?.()?.ribbon?.scrubTo?.(t),
+ });
+ cur = { s, el, panel, r: await rooms.room(s) };
+ return cur.r;
}
- function pinPosts(r) {
- clearPins();
- for (const p of r.posts) {
- if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue;
- const kind = p.evidence?.live
- ? 'live'
- : p.kind === 'witness'
- ? 'witness'
- : 'post';
- const id = globe.addPin({
- id: `post:${p.id}`,
- lon: p.anchor.lon,
- lat: p.anchor.lat,
- kind: p.hologram ? 'hologram' : kind,
- label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`,
- headingDeg: p.evidence?.headingDeg,
- });
- if (id) pinIds.push(id);
- }
- getChrome()?.setExtraPips(
- r.posts
- .filter((p) => p.anchor?.t)
- .map((p) => ({
- at: p.anchor.t,
- label: r.numbering.get(p.id),
- color: p.kind === 'witness' ? '#ebc777' : '#f4f7fb',
- })),
- );
- }
-
- /* ---------------- rendering ---------------- */
-
- function citeChip(c) {
- if (!c) return '';
- const label = /^O\d/.test(c)
- ? `OBS ${c.slice(1)}`
- : c === 'O-cam'
- ? 'CAM'
- : c;
- return `${esc(label)} `;
- }
-
- function synthHtml(r) {
- const sy = r.synth;
- const sec = (id, title, list) =>
- list.length
- ? ` ${title}${list.length}
${list
- .map(
- (l) =>
- `${esc(l.text)} ${l.cites
- .filter(Boolean)
- .map((c) => citeChip(c))
- .join('')} `,
- )
- .join('')} `
- : '';
- return `
- ${icon('sparkles')}Local synthesis ${sy.n} posts · ${sy.obs} observations · ${esc(sy.hash.slice(0, 8))}
- ${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
-
- `;
- }
-
- function noteHtml(n, p) {
- const st = noteStatus(n, n.mine);
- const bar = (c) => {
- const x = st.by[c];
- const pct = x.n ? Math.round((x.h / x.n) * 100) : 0;
- return `${c} ${x.h}/${x.n} `;
- };
- const why = st.shown
- ? 'Raters who usually disagree both found it helpful'
- : st.status === 'not-bridged'
- ? `Not shown: one cluster rates it below ${Math.round(BRIDGE.minHelpful * 100)} %`
- : `Not shown yet: needs ${BRIDGE.minPerCluster}+ raters from cluster ${st.status.replace('needs-', '').split('').join(' and ')}`;
- return `
-
${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}
- ${st.shown ? `
${esc(n.text)}
` : `
Show the proposed note ${esc(n.text)}
`}
-
${icon('bridge')}Bridging ${bar('A')}${bar('B')}${esc(why)}
-
${n.mine ? `You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)} ` : `Helpful Not helpful `}
-
`;
- }
-
- function postHtml(p, r, depth = 0) {
- const s = r.situation;
- const k = KIND[p.kind] || KIND.context;
- const badges = badgesFor(p, s, rt.me);
- const replies = r.repliesOf(p);
- const num = r.numbering.get(p.id);
- const anchor =
- p.anchor && Number.isFinite(p.anchor.lat)
- ? `${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''} `
- : '';
- const media =
- p.evidence?.captureId || p.evidence?.uploadId
- ? `${p.evidence.unverified ? 'Unverified upload ' : `${icon('sealFill')} `}
`
- : '';
- return `
-
- ${icon(k.icon)}
- ${esc(k.name)} ${esc(p.author.handle)}${p.author.self ? ' (you)' : ''} ${esc(ago(p.at))} · ${esc(num)}
- ${p.example ? 'Example ' : ''}
-
- ${esc(p.text)}
- ${media}
- ${badges.length ? `${badges.map((b) => `${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` ${esc(b.detail)} ` : ''} `).join('')}
` : ''}
-
- ${anchor}
-
- ${icon('agree')}${p.votes.up}
- ${icon('dispute')}${p.votes.dn}
- ${icon('reply')}
-
- ${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
- ${replies.length ? `${replies.map((x) => postHtml(x, r, depth + 1)).join('')}
` : ''}
- `;
- }
-
- function presenceHtml(r) {
- const here = rt.presence(r.id);
- const ex = r.participants.filter((a) => a.example).slice(0, 4);
- return `
- ${here.map((a, i) => avatar({ handle: a.handle, self: a.self }, i).replace('class="av', 'class="av live')).join('')}
- ${here.length} watching${here.length > 1 ? ' · live across your tabs' : ''}
- ${ex.map(avatar).join('')}
-
`;
- }
-
- async function render(s, el, { focusPost } = {}) {
- const r = await rooms.room(s);
- cur = { s, r, el };
- rt.join(r.id);
- el.innerHTML = `
- ${presenceHtml(r)}
- ${synthHtml(r)}
- ${r.hasExamples ? '
Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''}
- ${r.openQ.length ? `
${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r)).join('')}` : ''}
-
${icon('sealFill')}Evidence first then recency, never followers
- ${r.ordered
- .filter((p) => !r.openQ.includes(p))
- .map((p) => postHtml(p, r))
- .join('')}
-
${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
-
`;
- hydrateMedia(el);
- pinPosts(r);
- if (focusPost) {
- const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, el);
- pe?.scrollIntoView({ block: 'center' });
- pe?.classList.add('flash');
- }
- return r;
- }
-
- async function hydrateMedia(el) {
- for (const m of $$('.p-media[data-media]', el)) {
- const u = await mediaUrl(m.dataset.media, m.dataset.store);
- if (!u) continue;
- m.insertAdjacentHTML(
- 'afterbegin',
- m.dataset.video
- ? ` `
- : ` `,
- );
- }
- }
-
- /* ---------------- reactions ---------------- */
-
- function reactionRail() {
- return `${REACTIONS.map((x) => `${icon(x.icon)} `).join('')}
`;
- }
-
- function floatReaction(host, kind) {
- if (!host || reducedMotion()) return;
- const x = REACTIONS.find((r) => r.id === kind) || REACTIONS[0];
- const el = h(`${icon(x.icon)} `);
- el.style.setProperty('--dx', `${Math.round(Math.random() * 24 - 12)}px`);
- host.appendChild(el);
- el.animate(
- [
- { transform: 'translate(0, 0) scale(.6)', opacity: 0 },
- {
- transform: 'translate(var(--dx), -40px) scale(1.1)',
- opacity: 1,
- offset: 0.2,
- },
- {
- transform: 'translate(calc(var(--dx) * -1), -190px) scale(.9)',
- opacity: 0,
- },
- ],
- { duration: 2200, easing: 'cubic-bezier(.2,.7,.3,1)' },
- ).finished.then(() => el.remove());
- }
-
- /* ---------------- events ---------------- */
-
- async function rerender(keepScroll) {
- if (!cur) return;
- const sc = keepScroll?.scrollTop;
- await render(cur.s, cur.el);
- if (keepScroll) keepScroll.scrollTop = sc;
- }
-
- async function onClick(e, { scroller, flyTo, scrub }) {
- if (!cur) return false;
- const r = cur.r;
- const postEl = e.target.closest('[data-post]');
- const post = postEl
- ? r.posts.find((p) => p.id === postEl.dataset.post)
- : null;
- const anchor = e.target.closest('[data-anchor]');
- if (anchor && post?.anchor) {
- haptic('light');
- flyTo(post.anchor, cur.s);
- if (post.anchor.t) scrub(post.anchor.t);
- return true;
- }
- const vote = e.target.closest('[data-vote]');
- if (vote && post) {
- haptic('tick');
- await rooms.vote(post, vote.dataset.vote);
- await rerender(scroller);
- return true;
- }
- if (e.target.closest('[data-reply]') && post) {
- onCompose(cur.s, { replyTo: post });
- return true;
- }
- const rate = e.target.closest('[data-rate]');
- if (rate && post) {
- const nid = rate.closest('[data-note]').dataset.note;
- const note = post.notes.find((n) => n.id === nid);
- const mine = await rooms.rate(note, rate.dataset.rate === '1');
- const { why } = await rooms.myCluster();
- haptic('success');
- const st = noteStatus(note, mine);
- toast(
- `Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`,
- { icon: icon('bridge') },
- );
- await rerender(scroller);
- return true;
- }
- const cite = e.target.closest('[data-cite]');
- if (cite) {
- const id = cite.dataset.cite;
- const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0];
- const el = target
- ? $(`[data-post="${CSS.escape(target)}"]`, cur.el)
- : null;
- if (el) {
- el.scrollIntoView({
- block: 'center',
- behavior: reducedMotion() ? 'auto' : 'smooth',
- });
- el.classList.remove('flash');
- void el.offsetWidth;
- el.classList.add('flash');
- } else
- toast(
- `${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`,
- { icon: icon('info') },
- );
- return true;
- }
- const react = e.target.closest('[data-react]');
- if (react) {
- haptic('tick');
- rt.react(r.id, react.dataset.react);
- floatReaction(react.closest('.sheet-dock'), react.dataset.react);
- return true;
- }
- const tog = e.target.closest('[data-act="react-toggle"]');
- if (tog) {
- const dock = tog.closest('.sheet-dock');
- const open = !dock.classList.contains('react-open');
- dock.classList.toggle('react-open', open);
- tog.setAttribute('aria-expanded', String(open));
- haptic('tick');
- return true;
- }
- if (e.target.closest('[data-act="capture-reply"]')) {
- onCapture(cur.s);
- return true;
- }
- if (e.target.closest('[data-act="write"]')) {
- onCompose(cur.s, {});
- return true;
- }
- return false;
- }
-
- rt.on('react', ({ room, kind }) => {
- if (cur && room === cur.r.id)
- floatReaction(document.querySelector('.brief-sheet .sheet-dock'), kind);
- });
- rt.on('presence', () => {
- if (!cur) return;
- const pr = $('.presence', cur.el);
- if (pr) pr.outerHTML = presenceHtml(cur.r);
- });
- rooms.on('post', (p) => {
- if (cur && p.room === cur.r.id) {
- const sc = cur.el.closest('.sheet-body');
- rerender(sc);
- }
- });
-
return {
render,
- onClick,
- reactionRail,
- replyBar: () =>
- `${icon('viewfinder')}Reply with evidence Seal what you see ${icon('note')}Write ${icon('hand')}
`,
+ /** Anchors need the world's camera; everything else the lanes handle. */
+ async onClick(e, { flyTo, scrub } = {}) {
+ if (!cur) return false;
+ const a = e.target.closest?.('[data-anchor]');
+ if (!a) return false;
+ const r = await rooms.room(cur.s);
+ const p = r.posts.find((x) => x.id === a.dataset.anchor);
+ if (!p?.anchor) return false;
+ haptic('light');
+ flyTo?.(p.anchor, cur.s);
+ if (p.anchor.t) scrub?.(p.anchor.t);
+ return true;
+ },
+ reactionRail: () => '',
+ replyBar: () => '',
close() {
- clearPins();
+ cur?.panel?.destroy();
cur = null;
- rt.leave();
},
get current() {
return cur;
},
fmtKm,
+ esc,
};
}
diff --git a/mobile/ui/rooms.js b/mobile/ui/rooms.js
index 94fc31ce1..fab743162 100644
--- a/mobile/ui/rooms.js
+++ b/mobile/ui/rooms.js
@@ -76,8 +76,9 @@ const [HARBOR, MET, PILOT, MEDIC, RIVER, SKEPTIC, ORBIT, LENA, DATA] = EX;
* text(s, v) → string, w was-there, sl sealed, re reply-to index,
* note {text, r: 'AA+B+…'}, up/dn votes, an anchor offset {dLat,dLon}.
*/
-function genre(s) {
+export function genre(s) {
const k = `${s.kind || ''} ${s.archetype || ''}`;
+ if (s.momentId || /circling|moment/.test(k)) return 'circling';
if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone';
if (/quake/.test(k)) return 'quake';
if (/fire/.test(k)) return 'fire';
@@ -93,6 +94,67 @@ function genre(s) {
}
const T = {
+ circling: [
+ {
+ k: 'witness',
+ a: RIVER,
+ m: 16,
+ w: 1,
+ sl: 1,
+ up: 47,
+ dn: 2,
+ d: [0.002, -0.003],
+ text: (s, v) =>
+ `${v(0) ? `${v(0)} overhead` : 'Helicopters overhead'} for about fifteen minutes now. Two are holding high and steady, one is lower and tighter with a searchlight. Sealed clip from street level, looking up.`,
+ note: {
+ text: 'The clip’s heading points at the orbit centre and its seal time falls inside the detection window. ADS-B shows the same three tracks.',
+ r: 'A+A+A+B+B+',
+ },
+ },
+ {
+ k: 'question',
+ a: LENA,
+ m: 13,
+ up: 22,
+ text: () =>
+ 'Does anyone know why they’re circling? Nothing on local news yet and the street is blocked at the corner.',
+ },
+ {
+ k: 'context',
+ a: PILOT,
+ m: 11,
+ re: 1,
+ up: 39,
+ text: () =>
+ 'The low one is flying a tight left-hand orbit, which is what police aviation does over a scene. The two higher ones at about 1,500 ft are holding still for camera shots. That pattern means something on the ground, not an aircraft in trouble.',
+ note: {
+ text: 'Consistent with the tracks: one orbit at ~400 m radius and ~1,000 ft, two at ~650 m and ~1,500 ft.',
+ r: 'A+A+B+B+B+',
+ },
+ },
+ {
+ k: 'correction',
+ a: SKEPTIC,
+ m: 8,
+ up: 26,
+ dn: 4,
+ text: () =>
+ 'A post going around says it’s a shooting. Nothing in this room supports that, and the “scanner audio” people are quoting has no source. Wait for a sealed capture or an official statement.',
+ note: {
+ text: 'No sealed capture or official source here mentions a shooting.',
+ r: 'A+A+B+B+',
+ },
+ },
+ {
+ k: 'prediction',
+ a: DATA,
+ m: 4,
+ up: 9,
+ dn: 3,
+ text: () =>
+ 'The news helicopters leave within thirty minutes of police clearing the scene; the police one stays longest.',
+ },
+ ],
cyclone: [
{
k: 'witness',
@@ -705,7 +767,43 @@ function metricText(s) {
};
}
+/** Posts a Moment ships with (engine `moment.posts`), as example seeds. */
+function momentSeeds(s) {
+ const caps = s.momentCaptures || [];
+ return s.momentPosts.map((mp, i) => {
+ const at = Number(mp.t) || Date.now();
+ const cap = caps.find((c) => c.handle === mp.handle);
+ const there = mp.kind === 'witness' && !!cap;
+ const votes = mp.votes || {};
+ return {
+ id: `${s.id}~mp${i + 1}`,
+ room: String(s.id),
+ kind: KIND[mp.kind] ? mp.kind : 'context',
+ text: String(mp.text || ''),
+ at,
+ author: { handle: mp.handle, example: true, cluster: i % 2 ? 'B' : 'A' },
+ example: true,
+ evidence: {
+ wasThere: there,
+ sealed: there || !!cap?.sealed,
+ example: true,
+ distanceKm: cap ? 0.3 : null,
+ },
+ votes: {
+ up: typeof votes === 'number' ? votes : votes.up || 0,
+ dn: votes.dn || 0,
+ },
+ replyTo: null,
+ anchor: cap
+ ? { lat: cap.lat, lon: cap.lon, t: Number(cap.t) || at }
+ : null,
+ notes: [],
+ };
+ });
+}
+
function seedPosts(s) {
+ if (s.momentPosts?.length) return momentSeeds(s);
const tpl = T[genre(s)] || T.default;
const v = metricText(s);
const base = Number(new Date(s.updatedAt)) || Date.now();
@@ -1075,20 +1173,26 @@ export function createRooms({ rt }) {
async function post(s, draft) {
const m = await mineMap();
- const id = `${s.id}~${rt.tab}~${Date.now().toString(36)}`;
+ const id = draft.id || `${s.id}~${rt.tab}~${Date.now().toString(36)}`;
+ const dup = (m.get(String(s.id)) || []).find((x) => x.id === id);
+ if (dup) return dup;
const p = {
id,
room: String(s.id),
kind: draft.kind || 'witness',
text: String(draft.text || '').slice(0, 2000),
- at: Date.now(),
- author: {
+ at: draft.at || Date.now(),
+ author: draft.author || {
handle: rt.me.handle,
self: true,
local: !!(rt.me.home && distKm(rt.me.home, s) < 50),
},
evidence: draft.evidence || {},
- votes: { up: 0, dn: 0 },
+ quoteOf: draft.quoteOf || null,
+ promotedFrom: draft.promotedFrom || null,
+ needEyes: draft.needEyes || null,
+ momentId: draft.momentId || s.momentId || null,
+ votes: draft.votes || { up: 0, dn: 0 },
replyTo: draft.replyTo || null,
anchor: draft.anchor || null,
hologram: !!draft.hologram,
diff --git a/mobile/ui/roomsTab.js b/mobile/ui/roomsTab.js
new file mode 100644
index 000000000..eb890ff0c
--- /dev/null
+++ b/mobile/ui/roomsTab.js
@@ -0,0 +1,217 @@
+/**
+ * The Rooms tab (DESIGN.md §12.7): live rooms now (watching, heat, a live
+ * line of chat), Moments (today's hero, live detections, your clips), Need
+ * eyes near you, and crews: yours, and ones to discover.
+ */
+
+import { icon } from './icons.js';
+import { $, esc, ago, haptic, toast, esriThumb, reducedMotion } from './dom.js';
+import { BAND_COLOR, bandOf } from './bands.js';
+import { viewerCount, sampleLines, chatGenre } from './chatSim.js';
+import { richText } from './emotes.js';
+import { simTier, hashStr } from './signal.js';
+import { crewAvatar, fmtM } from './crews.js';
+
+const fmtV = (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v));
+
+export function createRoomsTab(el, core) {
+ let tickers = [];
+ let tickTimer = 0;
+ let live = [];
+ let moments = [];
+ el.classList.add('rooms-tab');
+ core.ctx.bindScroll?.(el);
+ el.addEventListener(
+ 'scroll',
+ () => el.classList.toggle('is-scrolled', el.scrollTop > 44),
+ { passive: true },
+ );
+
+ function heatBars(s) {
+ let x = hashStr(s.id);
+ const bars = [];
+ for (let i = 0; i < 12; i += 1) {
+ x = (Math.imul(x, 1103515245) + 12345) >>> 0;
+ bars.push(0.25 + ((x >>> 8) % 100) / 133 + i / 30);
+ }
+ return `${bars.map((b, i) => ` `).join('')} `;
+ }
+
+ function liveRow(s, i) {
+ const band = bandOf(s);
+ const v =
+ viewerCount(s) + Math.max(0, core.rt.presence(String(s.id)).length - 1);
+ const thumb =
+ core.globe?.cachedThumbnail?.(s) || s.thumbUrl || s.thumbnail || '';
+ return `
+ LIVE${fmtV(v)}
+
+ ${esc(s.title)}
+ ${esc(s.place || '')} ${heatBars(s)}
+
+
+ `;
+ }
+
+ function momentHero(m) {
+ const ms = Math.max(
+ 1,
+ Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3),
+ );
+ return `
+
+
${icon('sparkles')}Today’s Moment ${m.illustrative ? 'Illustrative ' : 'Detected · ADS-B '}
+
+
${esc(m.title)}
+
${esc(m.place || '')} · ${ms} min · ${(m.tracks || []).length || 3} aircraft · ${(m.captures || []).length} sealed captures
+
+ ${icon('play')}Replay
+ ${icon('users')}Watch party
+ ${icon('note')}Record
+
+
+
`;
+ }
+
+ async function render() {
+ const sits = (await core.situations()) || [];
+ moments = await core.moments();
+ const scored = sits
+ .filter((s) => Number.isFinite(s.lat))
+ .map((s) => ({ s, v: viewerCount(s) * (1 + (s.severity || 0) * 0.2) }))
+ .sort((a, b) => b.v - a.v);
+ // Diversity: one per chat genre.
+ const seen = new Set();
+ live = [];
+ for (const x of scored) {
+ const g = chatGenre(x.s);
+ if (seen.has(g)) continue;
+ seen.add(g);
+ live.push(x.s);
+ if (live.length >= 6) break;
+ }
+ const detections = sits
+ .filter((s) => s.momentId || /circling/.test(s.kind || ''))
+ .slice(0, 4);
+ const loc = core.myLoc();
+ const needs = core.needs
+ .near(loc, 60)
+ .filter((r) => r.status === 'open')
+ .slice(0, 3);
+ const crews = core.crews.list();
+ const mine = crews.filter((c) => c.joined);
+ const other = crews.filter((c) => !c.joined);
+ const hero = moments[0];
+ el.innerHTML = `
+
+ Rooms
+ Live is fast. The Record is true. Signal rewards being right, early, with receipts.
+
+ Live now ${live.reduce((a, s) => a + viewerCount(s), 0).toLocaleString()} watching
+ ${live.map(liveRow).join('') || '
Nothing unusual right now. That’s a valid state.
'}
+
+ Moments 4D replays with chat and the record
+ ${hero ? momentHero(hero) : ''}
+ ${
+ detections.length || moments.length > 1
+ ? `${[
+ ...moments
+ .slice(1)
+ .map(
+ (m) =>
+ `${icon('sky')}${esc(m.title)} ${m.illustrative ? 'Illustrative' : 'Live detection'} `,
+ ),
+ ...detections.map(
+ (s) =>
+ `${icon('sky')}${esc(s.title)} Detected · ${esc(ago(s.updatedAt))} `,
+ ),
+ ].join('')}
`
+ : ''
+ }
+ ${icon('clock')} Your clips Select a range on the time machine and “Clip this Moment”. It lands here with its receipts.
+
+ Need eyes near you ${loc?.guessed ? 'approximate · share location for distances' : 'one quiet ask each'}
+ ${needs.length ? needs.map((n) => core.needs.cardHtml(n)).join('') : 'No open requests near you. Calm is a feature.
'}
+
+ Your crews ${mine.length ? `${mine.length} joined` : 'communities, not followers'}
+ ${mine.map((c) => `
${crewAvatar(c)}${esc(c.name)} ${fmtM(c.members)} members · ${c.feed.length} new ${esc(c.feed[1]?.h || '')}: ${richText((c.feed[1]?.t || '').slice(0, 70)).html}… `).join('') || '
Join a crew to see its feed here.
'}
+ ${
+ other.length
+ ? `Discover crews
${other.map((c) => `
${crewAvatar(c)}${esc(c.name)} ${esc(c.tagline)} Join
`).join('')}
`
+ : ''
+ }
+ `;
+ // Tickers: a real-feeling line of each room's chat.
+ tickers = live.map((s) => ({ s, lines: sampleLines(s, 8), i: 0 }));
+ tickAll(true);
+ clearInterval(tickTimer);
+ tickTimer = setInterval(() => tickAll(false), 2600);
+ }
+
+ function tickAll(first) {
+ if (!el.isConnected || (el.offsetParent === null && !first)) return;
+ tickers.forEach((t, k) => {
+ const node = $(`[data-tick="${k}"]`, el);
+ if (!node || !t.lines.length) return;
+ // Stagger so the list shimmers rather than blinks.
+ if (!first && Math.random() < 0.45) return;
+ const m = t.lines[t.i % t.lines.length];
+ t.i += 1;
+ const tier = m.tier || simTier(m.handle);
+ node.innerHTML = `${esc(m.handle.slice(1))} ${richText(m.text).html} `;
+ });
+ }
+
+ el.addEventListener('click', async (e) => {
+ const room = e.target.closest('[data-room]');
+ if (room) {
+ const sits = await core.situations();
+ const s = sits.find((x) => String(x.id) === room.dataset.room);
+ if (s) core.openRoom(s, { lane: 'live' });
+ return;
+ }
+ const mo = e.target.closest('[data-mo]');
+ if (mo) {
+ haptic('light');
+ const m =
+ moments.find((x) => x.id === mo.dataset.id) ||
+ (await core.moment(mo.dataset.id));
+ if (mo.dataset.mo === 'play') {
+ if (core.ctx.onPlayMoment) core.ctx.onPlayMoment(m);
+ else core.openRoom(m, { lane: 'live' });
+ } else
+ core.openRoom(m, {
+ lane: mo.dataset.mo === 'record' ? 'record' : 'live',
+ });
+ return;
+ }
+ const nd = e.target.closest('[data-need-act]');
+ if (nd) {
+ core.needs.act(nd.dataset.needAct, nd.dataset.need);
+ return;
+ }
+ const jn = e.target.closest('[data-join]');
+ if (jn) {
+ core.crews.toggle(jn.dataset.join);
+ haptic('success');
+ toast('Joined', { icon: icon('users') });
+ render();
+ return;
+ }
+ const cr = e.target.closest('[data-crew]');
+ if (cr) core.crews.open(cr.dataset.crew);
+ });
+ const offN = core.needs.on('change', () => el.isConnected && render());
+ const offC = core.crews.on('change', () => el.isConnected && render());
+
+ render();
+ return {
+ refresh: render,
+ destroy() {
+ clearInterval(tickTimer);
+ offN?.();
+ offC?.();
+ el.innerHTML = '';
+ },
+ };
+}
diff --git a/mobile/ui/signal.js b/mobile/ui/signal.js
new file mode 100644
index 000000000..f2581d813
--- /dev/null
+++ b/mobile/ui/signal.js
@@ -0,0 +1,184 @@
+/**
+ * Signal (DESIGN.md §12.2): reputation for being right, early, with
+ * receipts. It rises when sealed captures are corroborated (cited by
+ * others, matched by sensor data), when a Need eyes request is fulfilled
+ * and when a note you rated ends up bridged; it falls when your claims are
+ * corrected. Every change is a receipt with its reason and its source, so
+ * the score is never a mystery. Follower counts do not exist.
+ *
+ * Simulated participants get a deterministic tier from their handle, so the
+ * chat's colours and vote weights are stable across reloads.
+ */
+
+import { emitter } from './dom.js';
+import { get, put } from '../capture/db.js';
+
+export const TIERS = [
+ { id: 'new', name: 'New eyes', min: 0, color: '#a7b0bb', weight: 1 },
+ { id: 'observer', name: 'Observer', min: 100, color: '#8fd0ff', weight: 1.2 },
+ { id: 'spotter', name: 'Spotter', min: 300, color: '#6fe3b4', weight: 1.5 },
+ {
+ id: 'corroborated',
+ name: 'Corroborated',
+ min: 700,
+ color: '#f2c96d',
+ weight: 1.9,
+ },
+ { id: 'anchor', name: 'Anchor', min: 1500, color: '#c7a6ff', weight: 2.4 },
+];
+
+/** What each kind of receipt is worth, in the open. */
+export const RULES = [
+ { id: 'sealed', delta: 10, text: 'You posted a sealed capture to a room' },
+ { id: 'there', delta: 15, text: 'Your capture was inside the event radius' },
+ {
+ id: 'sensor',
+ delta: 20,
+ text: 'Sensor data matched your capture (ADS-B, radar, quakes)',
+ },
+ { id: 'first', delta: 50, text: 'First eyes: the first sealed capture' },
+ { id: 'fulfil', delta: 40, text: 'You fulfilled a Need eyes request' },
+ {
+ id: 'corroborated',
+ delta: 3,
+ text: 'Someone corroborated your post (× their Signal weight)',
+ },
+ {
+ id: 'note',
+ delta: 5,
+ text: 'A note you rated helpful became bridged',
+ },
+ { id: 'promoted', delta: 8, text: 'Your chat message was promoted' },
+ {
+ id: 'corrected',
+ delta: -25,
+ text: 'A bridged correction showed your claim was wrong',
+ },
+ {
+ id: 'disputed',
+ delta: -5,
+ text: 'An unverified upload you posted was disputed',
+ },
+];
+export const RULE = Object.fromEntries(RULES.map((r) => [r.id, r]));
+
+export function tierOf(score = 0) {
+ let i = 0;
+ for (let k = 0; k < TIERS.length; k += 1) if (score >= TIERS[k].min) i = k;
+ const tier = TIERS[i];
+ const next = TIERS[i + 1] || null;
+ const progress = next
+ ? (score - tier.min) / Math.max(1, next.min - tier.min)
+ : 1;
+ return {
+ ...tier,
+ index: i,
+ next,
+ progress: Math.max(0, Math.min(1, progress)),
+ };
+}
+
+export function hashStr(s) {
+ let x = 2166136261;
+ const str = String(s || '');
+ for (let i = 0; i < str.length; i += 1)
+ x = Math.imul(x ^ str.charCodeAt(i), 16777619);
+ return x >>> 0;
+}
+
+/**
+ * A stable simulated Signal score for a fictional handle. Personas can pin
+ * a minimum (moderators and domain experts are rarely New eyes).
+ */
+export function simScore(handle, floor = 0) {
+ const h = hashStr(handle);
+ // Skewed: most chatters are New eyes or Observers, few are Anchors.
+ const u = (h % 1000) / 1000;
+ const raw = Math.round(1900 * u ** 2.3);
+ return Math.max(floor, raw);
+}
+
+export function simTier(handle, floor = 0) {
+ return tierOf(simScore(handle, floor));
+}
+
+/** Vote weight shown next to a corroboration: "× 1.5". */
+export const weightOf = (score) => tierOf(score).weight;
+
+export function createSignal({ me } = {}) {
+ const ev = emitter();
+ let st = null; // Promise<{ id, receipts: [] }>
+
+ const load = () =>
+ (st =
+ st ||
+ get('kv', 'signal')
+ .catch(() => null)
+ .then((r) => r || { id: 'signal', receipts: [] }));
+
+ async function save() {
+ const s = await load();
+ await put('kv', s).catch(() => {});
+ }
+
+ async function receipts() {
+ const s = await load();
+ return [...s.receipts].sort((a, b) => b.at - a.at);
+ }
+
+ async function score() {
+ const s = await load();
+ return Math.max(
+ 0,
+ Math.round(s.receipts.reduce((a, r) => a + (r.delta || 0), 0)),
+ );
+ }
+
+ /**
+ * Record a receipt. Idempotent by id, so replays never double count.
+ * @param {{ id: string, rule: string, delta?: number, text: string,
+ * ref?: object, source?: string }} r
+ */
+ async function award(r) {
+ const s = await load();
+ if (s.receipts.some((x) => x.id === r.id)) return null;
+ const before = await score();
+ const rec = {
+ at: Date.now(),
+ delta: r.delta ?? RULE[r.rule]?.delta ?? 0,
+ ...r,
+ };
+ s.receipts.push(rec);
+ await save();
+ const after = await score();
+ const t0 = tierOf(before);
+ const t1 = tierOf(after);
+ const out = {
+ receipt: rec,
+ before,
+ after,
+ tier: t1,
+ up: t1.index > t0.index,
+ };
+ ev.emit('change', out);
+ return out;
+ }
+
+ /** The person's tier right now (sync best effort for rendering). */
+ let cached = 0;
+ score().then((v) => (cached = v));
+ ev.on('change', (o) => (cached = o.after));
+
+ return {
+ on: ev.on,
+ off: ev.off,
+ receipts,
+ score,
+ award,
+ get cached() {
+ return cached;
+ },
+ tier: () => tierOf(cached),
+ me,
+ };
+}
diff --git a/mobile/ui/social-dev.css b/mobile/ui/social-dev.css
new file mode 100644
index 000000000..18d0ad9b7
--- /dev/null
+++ b/mobile/ui/social-dev.css
@@ -0,0 +1,77 @@
+/* Social harness only: a minimal shell around the social mount points. */
+html,
+body {
+ height: 100%;
+ margin: 0;
+ background: #050607;
+ color: var(--ink);
+ font-family: var(--font);
+ overflow: hidden;
+}
+.sd-app {
+ position: fixed;
+ inset: 0;
+}
+.sd-app .stage {
+ position: absolute;
+ inset: 0;
+}
+.sd-lead {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ background: var(--ground);
+}
+.sd-app[data-tab='globe'] .sd-lead,
+.sd-app[data-tab='overlay'] .sd-lead {
+ display: none;
+}
+.sd-lead .view {
+ position: absolute;
+ inset: 0;
+ overflow-y: auto;
+ padding-bottom: 120px;
+}
+.sd-app .capture {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+}
+.sd-tabs {
+ position: absolute;
+ z-index: 50;
+ left: 50%;
+ bottom: calc(10px + env(safe-area-inset-bottom, 0px));
+ transform: translateX(-50%);
+ display: flex;
+ gap: 2px;
+ padding: 4px;
+ border-radius: 999px;
+ background: rgba(20, 22, 28, 0.82);
+ -webkit-backdrop-filter: blur(20px);
+ backdrop-filter: blur(20px);
+ box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.14);
+}
+.sd-tabs button {
+ height: 40px;
+ padding: 0 12px;
+ border-radius: 999px;
+ font-size: 0.8125rem;
+ font-weight: 650;
+ color: var(--ink-2);
+}
+.sd-tabs button[aria-pressed='true'] {
+ color: #05070b;
+ background: var(--ink);
+}
+.sd-overlay {
+ position: absolute;
+ z-index: 4;
+ left: 10px;
+ width: min(360px, calc(100% - 20px));
+ bottom: 70px;
+ height: 340px;
+}
+.sd-overlay[hidden] {
+ display: none;
+}
diff --git a/mobile/ui/social-dev.html b/mobile/ui/social-dev.html
new file mode 100644
index 000000000..ec7fdfcce
--- /dev/null
+++ b/mobile/ui/social-dev.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ Social harness
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rooms
+ Globe
+ Capture
+ You
+ Moment chat
+
+
+
+
+
diff --git a/mobile/ui/social-dev.js b/mobile/ui/social-dev.js
new file mode 100644
index 000000000..6001d46fc
--- /dev/null
+++ b/mobile/ui/social-dev.js
@@ -0,0 +1,155 @@
+/**
+ * Social harness (dev only): mounts the Rooms tab, a room in both lanes, the
+ * You tab, Capture with Need eyes, and the Moment chat overlay against the
+ * offline stub globe, so the social layer can be tested on its own.
+ * /mobile/ui/social-dev.html?tab=rooms|you|globe|capture|overlay
+ * &room=|moment&lane=live|record&lat=&lon=
+ */
+
+import { $, $$ } from './dom.js';
+import { mountGlobe } from './globeHost.js';
+import { createRealtime } from './realtime.js';
+import { createRooms } from './rooms.js';
+import { createAttach } from './attach.js';
+import {
+ configureSocial,
+ mountRoomsTab,
+ mountYouTab,
+ openRoom,
+ chatOverlay,
+ needEyesNear,
+ momentRoom,
+} from './social.js';
+
+const q = new URLSearchParams(location.search);
+const app = $('#app');
+let loc =
+ q.get('lat') && q.get('lon')
+ ? { lat: Number(q.get('lat')), lon: Number(q.get('lon')) }
+ : null;
+
+const g = await mountGlobe($('#globe'), { preferStub: true });
+const rt = createRealtime();
+const rooms = createRooms({ rt });
+const getSituations = () => g.situations(loc ? { observer: loc } : undefined);
+
+let capture = null;
+const core = configureSocial({
+ globe: g,
+ rt,
+ rooms,
+ getSituations,
+ getLocation: () => loc,
+ onShowGlobe: (s) => {
+ if (tab !== 'globe' && tab !== 'overlay') select('globe');
+ if (Number.isFinite(s.lat))
+ g.flyTo({
+ lon: s.lon,
+ lat: s.lat,
+ height: s.momentId ? 30e3 : 400e3,
+ duration: 1.2,
+ });
+ },
+ onCapture: async (s, o) => {
+ await select('capture');
+ capture?.attachTo(s, o);
+ },
+ onPlayMoment: (m) => {
+ select('overlay');
+ playOverlay(m);
+ },
+});
+
+const rtab = mountRoomsTab($('#view-rooms'), {});
+const you = mountYouTab($('#view-you'), {});
+
+const attach = createAttach({
+ rooms,
+ globe: g,
+ getSituations,
+ onPosted: () => {
+ rtab.refresh();
+ you.render();
+ },
+});
+
+let tab = 'rooms';
+async function select(t) {
+ if (t === tab) return;
+ const prev = tab;
+ tab = t;
+ app.dataset.tab = t;
+ $$('.sd-tabs [data-sd]').forEach((b) =>
+ b.setAttribute('aria-pressed', String(b.dataset.sd === t)),
+ );
+ $('#view-rooms').hidden = t !== 'rooms';
+ $('#view-you').hidden = t !== 'you';
+ $('#view-capture').hidden = t !== 'capture';
+ $('.sd-overlay').hidden = t !== 'overlay';
+ if (prev === 'capture') capture?.leave();
+ if (t === 'capture') {
+ if (!capture) {
+ const { createCapture } = await import('./capture.js');
+ capture = createCapture($('#view-capture'), {
+ globe: g,
+ rt,
+ attach,
+ getLocation: () => loc,
+ onSealed: () => you.render(),
+ onVerify: () => {},
+ });
+ window.__sd.capture = capture;
+ }
+ capture.enter();
+ }
+ if (t === 'you') you.render();
+ if (t === 'overlay' && !overlay) playOverlay((await core.moments())[0]);
+}
+$('.sd-tabs').addEventListener('click', (e) => {
+ const b = e.target.closest('[data-sd]');
+ if (b) select(b.dataset.sd);
+});
+
+/* Moment chat overlay on a simple clock. */
+let overlay = null;
+let clock = 0;
+function playOverlay(m) {
+ overlay?.destroy();
+ clearInterval(clock);
+ const el = $('.sd-overlay');
+ el.innerHTML = '';
+ overlay = chatOverlay(el, { globe: g, moment: m });
+ let t = +m.time.start;
+ overlay.setTime(t);
+ g.flyTo({ lon: m.lon, lat: m.lat, height: 5000, duration: 1 });
+ clock = setInterval(() => {
+ t += 15000;
+ if (t > +m.time.end) t = +m.time.start;
+ overlay.setTime(t);
+ }, 700);
+}
+
+window.__sd = {
+ g,
+ rt,
+ rooms,
+ core,
+ openRoom,
+ select,
+ needEyesNear,
+ momentRoom,
+ setLoc: (l) => (loc = l),
+ playOverlay,
+};
+
+const t0 = q.get('tab');
+if (t0) await select(t0);
+const rid = q.get('room');
+if (rid) {
+ const target =
+ rid === 'moment'
+ ? (await core.moments())[0]
+ : (await getSituations()).find((s) => String(s.id) === rid);
+ if (target) await openRoom(target, { lane: q.get('lane') || 'live' });
+}
+document.documentElement.dataset.ready = '1';
diff --git a/mobile/ui/social.js b/mobile/ui/social.js
new file mode 100644
index 000000000..956aeb239
--- /dev/null
+++ b/mobile/ui/social.js
@@ -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 (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('
');
+ 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 = `${esc(BAND_NAME[band] || 'Room')} ${s.live === false && !s.momentId ? 'Room' : 'Live room'}· ${esc(s.place || '')}
+ ${esc(s.title)}
+ ${o.crew ? `${icon('users')}Watching with ${esc(o.crew.name)} · ${12 + (o.crew.members % 40)} crew here Example
` : ''}
+ ${s.illustrative ? 'Illustrative reconstruction · tracks are synthetic
' : ''}`;
+ 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 };
diff --git a/mobile/ui/socialBridge.js b/mobile/ui/socialBridge.js
new file mode 100644
index 000000000..713c15405
--- /dev/null
+++ b/mobile/ui/socialBridge.js
@@ -0,0 +1,33 @@
+/**
+ * The social agent's mount points (rooms, chat, You) live in ./social.js.
+ * Feature-detected: a checkout without it still builds and the shell shows
+ * its own fallbacks. A glob (not a bare dynamic import) lets Vite pick the
+ * file up as soon as it appears.
+ */
+
+let mod;
+export function social() {
+ if (mod !== undefined) return mod;
+ const loaders = import.meta.glob('./social.js');
+ const load = loaders['./social.js'];
+ mod = load
+ ? load().catch((err) => {
+ console.warn('[social] social.js failed to load', err);
+ return null;
+ })
+ : Promise.resolve(null);
+ return mod;
+}
+
+/** Call an export if it exists; resolves to { ok, value }. */
+export async function callSocial(name, ...args) {
+ const m = await social();
+ const fn = m?.[name];
+ if (typeof fn !== 'function') return { ok: false };
+ try {
+ return { ok: true, value: await fn(...args) };
+ } catch (err) {
+ console.warn(`[social] ${name} failed`, err);
+ return { ok: false, error: err };
+ }
+}
diff --git a/mobile/ui/you.js b/mobile/ui/you.js
index ea60ae68e..ae2d8f3ed 100644
--- a/mobile/ui/you.js
+++ b/mobile/ui/you.js
@@ -1,6 +1,8 @@
/**
- * You: your sealed captures and live bundles, unverified uploads, your room
- * posts and holograms, followed situations, home area, and settings.
+ * You (DESIGN.md §12.2, §12.7): Signal with its receipts, First eyes
+ * bylines, your sealed captures and uploads, your posts and holograms,
+ * what you watch, calm-by-default notifications, location precision, the
+ * privacy line we don't cross, and settings.
*/
import { icon, FAMILY_ICON } from './icons.js';
@@ -8,7 +10,51 @@ import { $, $$, esc, ago, haptic, store, applyMotionPref } from './dom.js';
import { all, get } from '../capture/db.js';
import { deviceKey } from '../capture/provenance.js';
import { KIND, badgesFor } from './rooms.js';
-import { llShort } from './bands.js';
+import { llShort, utc } from './bands.js';
+import { RULES, tierOf, TIERS } from './signal.js';
+
+/** Shown, dimmed and labelled, until the person has receipts of their own. */
+const EXAMPLE_LEDGER = [
+ {
+ delta: 40,
+ text: 'Fulfilled Need eyes: “Is the 7th St underpass flooded?”',
+ m: 1440,
+ },
+ { delta: 50, text: 'First eyes on Heavy rain cell over Houston', m: 1440 },
+ {
+ delta: 20,
+ text: 'ADS-B: 4 aircraft within 8 km at the sealed time',
+ m: 1441,
+ },
+ {
+ delta: 5,
+ text: '@wxdan (Corroborated, ×1.9) corroborated your capture',
+ m: 1420,
+ },
+ {
+ delta: -25,
+ text: 'A bridged correction: your “Cat 5” claim used the wrong scale',
+ m: 4320,
+ },
+];
+
+const NOTIFY = [
+ {
+ id: 'severe',
+ label: 'Severe, near me',
+ sub: 'Only things that can hurt people, inside your alert radius',
+ },
+ {
+ id: 'notable',
+ label: 'Notable',
+ sub: 'Plus wonder: ISS passes, launches, aurora over you',
+ },
+ {
+ id: 'all',
+ label: 'Everything',
+ sub: 'Every situation inside the radius. Not recommended.',
+ },
+];
export function createYou(
view,
@@ -22,8 +68,14 @@ export function createYou(
rt,
onOpenRoom,
onLocate,
+ core: coreIn,
},
) {
+ let core = coreIn || null;
+ const getCore = async () =>
+ (core =
+ core ||
+ (await import('./social.js')).socialCore({ rooms, rt, getSituations }));
let urls = [];
view.addEventListener(
'scroll',
@@ -42,6 +94,28 @@ export function createYou(
all('uploads').catch(() => []),
rooms.myPosts().catch(() => []),
]);
+ const c = await getCore();
+ const [score, receipts] = await Promise.all([
+ c.signal.score(),
+ c.signal.receipts(),
+ ]);
+ const tier = tierOf(score);
+ const firsts = receipts.filter((r) => r.rule === 'first');
+ const exampleLedger = !receipts.length;
+ const ledger = exampleLedger
+ ? EXAMPLE_LEDGER.map((x) => ({
+ ...x,
+ at: Date.now() - x.m * 60e3,
+ example: true,
+ }))
+ : receipts;
+ const loc = c.myLoc();
+ const needs = c.needs.near(loc, 60).filter((x) => x.status === 'open');
+ const notify = store.get('notifyLevel') || 'severe';
+ const asks = store.get('needAsks') || 'daily';
+ const quiet = store.get('quietHours') ?? true;
+ const prec = store.get('precisionDefault') || '100m';
+ const watchSummary = c.ctx.watch?.summary?.() || null;
uploads.sort((a, b) => b.addedAt - a.addedAt);
posts.sort((a, b) => b.at - a.at);
const sitOf = (id) => sits.find((x) => String(x.id) === id);
@@ -78,6 +152,40 @@ export function createYou(
${key ? `Signing key ${esc(key.keyId)} · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'}
${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${uploads.length} upload${uploads.length === 1 ? '' : 's'} · ${posts.length} post${posts.length === 1 ? '' : 's'} · ${followed.length} followed