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

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

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

No files matched your search

File diff suppressed because it is too large. Load diff
+29 -8
View File
@@ -7,7 +7,7 @@
*/
import { icon } from './icons.js';
import { $, $$, esc, haptic, toast } from './dom.js';
import { $, $$, esc, haptic, toast, store } from './dom.js';
import { createSheet } from './sheet.js';
import { KINDS, KIND, wasThere } from './rooms.js';
import {
@@ -152,8 +152,8 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
<span class="att-thumb ${it.unverified ? 'un' : ''}">${it.previewUrl ? (it.isVideo ? `<video src="${esc(it.previewUrl)}#t=0.1" muted playsinline preload="metadata"></video>` : `<img src="${esc(it.previewUrl)}" alt=""/>`) : ''}<span>${icon(it.unverified ? 'upload' : 'sealFill')}</span></span>
<div><div class="tele att-tele">${esc(head)}</div>
<div class="tele att-sub">${it.fix ? `${esc(llShort(it.fix.lat, it.fix.lon, 4))}${Number.isFinite(it.fix.acc) ? ` ±${Math.round(it.fix.acc)} M` : ''}` : 'NO POSITION'} · ${esc(utc(new Date(it.at), { seconds: false }))}</div>
<h2 class="sheet-title">Where does this belong?</h2></div>
</div>`;
<h2 class="sheet-title">${ctx.needEyes ? 'Answer the request' : 'Where does this belong?'}</h2></div>
</div>${ctx.needEyes ? `<div class="att-need">${icon('eyes')}<span><b>Need eyes</b> “${esc(ctx.needEyes.question)}”<small class="tele">${esc(ctx.needEyes.by || '')} · ${it.unverified ? 'uploads can’t fulfil a request' : 'first sealed answer gets the byline'}</small></span></div>` : ''}`;
sheet.body.innerHTML = `
<div class="att-list" role="radiogroup" aria-label="Situation">
${ctx.list.map(row).join('')}
@@ -172,7 +172,10 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
<button class="pill-btn primary att-post" type="button" data-act="post">${icon('sendUp')}Post to the room</button>`;
}
async function open(item, { preset = null, replyTo = null } = {}) {
async function open(
item,
{ preset = null, replyTo = null, needEyes = null } = {},
) {
ctx = {
item,
list: [],
@@ -182,12 +185,15 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
? 'witness'
: 'correction'
: 'witness',
precision: item.unverified ? '100m' : 'exact',
precision: item.unverified
? '100m'
: store.get('precisionDefault') || '100m',
hologram: false,
replyTo,
preset,
preset: preset || needEyes?.situation || null,
needEyes,
};
ctx.list = await suggestions(item, preset);
ctx.list = await suggestions(item, ctx.preset);
if (!ctx.list.length) ctx.sel = 'new';
render();
sheet.open('large');
@@ -274,10 +280,15 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
const p = PRECISION.find((x) => x.id === ctx.precision);
const round = (v) => (p.round == null ? v : Number(v.toFixed(p.round)));
const fix = it.fix;
const there =
let there =
it.sealed && fix
? wasThere(s, fix, it.at)
: { there: false, km: fix ? distKm(fix, s) : NaN };
// Answering a Need eyes request: "there" means inside its radius.
if (ctx.needEyes && it.sealed && fix) {
const nk = distKm(fix, ctx.needEyes);
if (nk <= (ctx.needEyes.radiusKm || 2)) there = { there: true, km: nk };
}
const text = $('.att-note', sheet.body).value.trim() || defaultText(it, s);
const postRec = await rooms.post(s, {
kind: ctx.kind,
@@ -292,6 +303,10 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
precision: ctx.precision,
}
: { t: it.at },
needEyes:
ctx.needEyes && !it.unverified
? { id: ctx.needEyes.id, question: ctx.needEyes.question }
: null,
evidence: {
captureId: it.unverified ? undefined : it.id,
uploadId: it.unverified ? it.id : undefined,
@@ -318,10 +333,16 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) {
});
haptic('success');
sheet.close();
const need = ctx.needEyes && !it.unverified ? ctx.needEyes : null;
if (need && s.adhoc) await rooms.addPlace(s);
toast(`Posted to ${s.title}${there.there ? ' · Was there' : ''}`, {
icon: icon(it.unverified ? 'upload' : 'sealFill'),
});
onPosted?.(s, postRec);
// Signal, First eyes, Need eyes and the Moment timeline (social layer).
import('./social.js')
.then((m) => m.socialCore().afterPost(s, postRec, it, { needEyes: need }))
.catch((err) => console.warn('social afterPost failed', err));
}
function defaultText(it, s) {
+79 -8
View File
@@ -74,6 +74,7 @@ export function createCapture(
<div class="cap-level" aria-hidden="true"><i></i></div>
<div class="cap-top">
<div class="cap-tape glass-strong"><canvas></canvas><span class="cap-caret"></span></div>
<div class="cap-needs" hidden></div>
<div class="hud glass-strong" aria-live="off">
<div class="hud-grid">
<span>LAT</span><b class="num" data-h="lat">—</b><span>LON</span><b class="num" data-h="lon">—</b>
@@ -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(
<dt>Heading</dt><dd>${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}</dd>
<dt>Media</dt><dd>${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB</dd>
</dl>
<div class="seal-actions"><button class="pill-btn primary" type="button" data-seal="post">${icon('sendUp')}${target ? 'Post to the room' : 'Post to a room'}</button><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Keep private</button></div>`,
<div class="seal-actions"><button class="pill-btn primary" type="button" data-seal="post">${icon(target?.needEyes ? 'eyes' : 'sendUp')}${target?.needEyes ? 'Answer the request' : target ? 'Post to the room' : 'Post to a room'}</button><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Keep private</button></div>`,
);
}
@@ -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')}<span>Answering in <b>${esc(s.title)}</b>${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}</span>${icon('close')}`;
if (mode === 'look') setMode('witness');
targetChip.innerHTML = needEyes
? `${icon('eyes')}<span>Need eyes · <b>${esc(needEyes.question.length > 46 ? `${needEyes.question.slice(0, 45)}…` : needEyes.question)}</b></span>${icon('close')}`
: `${icon('reply')}<span>Answering in <b>${esc(s.title)}</b>${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}</span>${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 = `<button type="button" class="cn-pill glass-strong" data-cn="toggle" aria-expanded="${needsOpen}">${icon('eyes')}<span><b>${list.length} Need eyes near you</b><small>“${esc(r0.question.length > 44 ? `${r0.question.slice(0, 43)}…` : r0.question)}” · ${esc(r0.distLabel || '')}</small></span>${icon(needsOpen ? 'chevronUp' : 'chevronDown')}</button>${
needsOpen
? `<div class="cn-list glass-strong">${list
.slice(0, 4)
.map(
(r) =>
`<button type="button" class="cn-row" data-cn="answer" data-id="${esc(r.id)}"><span><b>“${esc(r.question)}”</b><small class="tele">${esc(r.distLabel || '')} · ${esc(r.by)} · +40 SIGNAL${r.example ? ' · EXAMPLE' : ''}</small></span><em>Answer</em></button>`,
)
.join('')}</div>`
: ''
}`;
}
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,
+1089
View File
File diff suppressed because it is too large. Load diff
+185
View File
@@ -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<string, number>, viewers?: number, since?: number }} o
* @returns {Promise<object|null>} 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,
};
}
+9 -7
View File
@@ -52,10 +52,10 @@ export function createComposer({
function render() {
const s = ctx.s;
const r = ctx.replyTo;
sheet.head.innerHTML = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Room<b>·</b>${esc(s.title)}</div>
<h2 class="sheet-title">${r ? 'Reply' : 'Post to the room'}</h2>
${r ? `<div class="cmp-quote"><b class="tele">${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}</b>${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}</div>` : ''}`;
const r = ctx.replyTo || ctx.quote;
sheet.head.innerHTML = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Record<b>·</b>${esc(s.title)}</div>
<h2 class="sheet-title">${ctx.quote ? 'Quote post' : r ? 'Reply' : 'Post to the Record'}</h2>
${r ? `<div class="cmp-quote ${ctx.quote ? 'is-quote' : ''}"><b class="tele">${ctx.quote ? 'Quoting · ' : ''}${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}</b>${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}</div>` : ''}`;
const places = placeOptions();
const t = getTime();
sheet.body.innerHTML = `
@@ -76,7 +76,7 @@ export function createComposer({
<div class="ca-h tele">${icon('clock')}Anchor in time</div>
<div class="seg-chips" data-anchor="time"><button type="button" data-v="none" aria-pressed="${ctx.time === 'none'}">No time</button><button type="button" data-v="now" aria-pressed="${ctx.time === 'now'}">Now<small>${esc(utc(new Date(), { seconds: false }))}</small></button>${t ? `<button type="button" data-v="ribbon" aria-pressed="${ctx.time === 'ribbon'}">Ribbon time<small>${esc(utc(t, { seconds: false }))}</small></button>` : ''}</div>
</div>
<div class="cmp-foot"><span class="tele">Stored on this device · shared live with your other open tabs</span><button class="pill-btn primary" type="button" data-act="post">Post</button></div>`;
<div class="cmp-foot"><span class="tele">On the record · hash-chained in the room ledger · shared with your other open tabs</span><button class="pill-btn primary" type="button" data-act="post">Post</button></div>`;
}
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 };
}
+617
View File
@@ -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 `<span class="crew-av ${cls}" style="--hue:${c.hue}">${icon(c.glyph)}</span>`;
}
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 `<article class="cp ${p.pin ? 'is-pin' : ''} k-${p.k}" style="--tc:${tier.color}">
<div class="cp-head"><span class="cp-av" style="--tc:${tier.color}">${esc(p.h.slice(1, 3).toUpperCase())}</span>
<span class="cp-meta"><b>${esc(p.h)}</b>${mod ? '<span class="cp-mod tele">Mod</span>' : ''}<span class="cp-tier tele">${esc(tier.name)}</span><span class="tele cp-when">${esc(ago(p.at))}</span></span>
${p.pin ? `<span class="cp-pin tele">${icon('pin')}Pinned</span>` : p.example ? '<span class="badge-example">Example</span>' : ''}</div>
<p>${richText(p.t, { me: core.rt.me.handle }).html}</p>
${p.sealed ? `<div class="badges"><span class="bdg ex" data-b="sealed">${icon('sealFill')}Sealed</span></div>` : ''}
<div class="cp-acts tele"><span>${icon('agree')}${p.up}</span><span>${icon('reply')}${p.re}</span><span class="cp-k">${esc(p.k)}</span></div>
</article>`;
}
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)
? `<form class="cp-compose"><input type="text" maxlength="280" placeholder="Post to ${esc(c.name)}…" data-no-drag aria-label="Post to the crew"/><button class="pill-btn primary sm" type="submit">Post</button></form>`
: `<div class="cp-join-hint">${icon('users')}<span>Join to post. Anyone can read.</span></div>`
}${feedOf(c).map(postHtml).join('')}<p class="footnote">Seeded posts are examples by fictional members. Your posts stay on this device.</p>`;
} else if (tab === 'watch') {
const rs = await rooms();
sheet.body.innerHTML = `<div class="room-h tele">${icon('play')}Co-watch</div>
${c.events
.map(
(
e,
) => `<div class="cw-card" style="--hue:${c.hue}"><div class="cw-when tele">${esc(e.when)} · ${e.going.toLocaleString()} going</div><b>${esc(e.title)}</b><span>${esc(e.sub)}</span>
<div class="cw-acts"><button type="button" class="pill-btn primary sm" data-cowatch="${esc(e.id)}">${icon('users')}Co-watch now</button><span class="badge-example">Example</span></div></div>`,
)
.join('')}
<div class="room-h tele">${icon('now')}Crew rooms <span>live now</span></div>
${
rs.length
? rs
.map(
({ s, v }) =>
`<button type="button" class="cr-room" data-room="${esc(s.id)}"><i class="live-dot"></i><span><b>${esc(s.title)}</b><small class="tele">${v.toLocaleString()} watching · ${esc(s.place || '')}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')
: '<div class="empty">No live rooms for this crew right now. Nothing unusual is a valid state.</div>'
}`;
sheet.body._rooms = rs;
} else if (tab === 'members') {
const list = [...new Set([...c.mods, ...c.roster])];
sheet.body.innerHTML = `<p class="cm-why">${icon('sparkles')}Ordered by Signal. Crews show what members got right, never follower counts.</p>
${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 }) =>
`<div class="cm-row" style="--tc:${t.color}"><span class="cp-av">${esc(x.slice(1, 3).toUpperCase())}</span><span class="cm-main"><b>${esc(x)}</b><small class="tele">${esc(t.name)}${p?.domain ? ` · ${esc(p.domain)}` : ''}${c.mods.includes(x) ? ' · Mod' : ''}</small></span><span class="cm-w tele">×${t.weight.toFixed(1)}</span></div>`,
)
.join('')}
${isJoined(c.id) ? `<div class="cm-row is-me" style="--tc:${core.signal.tier().color}"><span class="cp-av">YOU</span><span class="cm-main"><b>${esc(core.rt.me.handle)}</b><small class="tele">${esc(core.signal.tier().name)} · you</small></span><span class="cm-w tele">×${core.signal.tier().weight.toFixed(1)}</span></div>` : ''}
<p class="footnote">Fictional members (example). ${fmtM(c.members)} members in the seeded crew.</p>`;
} else {
sheet.body.innerHTML = `<ol class="cr-rules">${c.rules.map((r) => `<li>${esc(r)}</li>`).join('')}</ol>
<p class="footnote">Every crew inherits the line we don’t cross: we track things that broadcast, never people. No face recognition, no person search.</p>`;
}
}
function renderHead() {
const c = cur;
const j = isJoined(c.id);
sheet.head.innerHTML = `<div class="crew-hero" style="--hue:${c.hue}">
${crewAvatar(c, 'lg')}
<div class="ch-main"><div class="tele ch-area">${esc(c.area)} · Crew <span class="badge-example">Example</span></div><h2 class="sheet-title">${esc(c.name)}</h2><p>${esc(c.tagline)}</p>
<div class="ch-meta tele"><span class="avs">${c.roster
.slice(0, 4)
.map(
(x) =>
`<span class="cp-av" style="--tc:${simTier(x, personaOf(x)?.floor || 0).color}">${esc(x.slice(1, 3).toUpperCase())}</span>`,
)
.join('')}</span>${fmtM(c.members)} members</div></div>
<button type="button" class="pill-btn ${j ? 'plain' : 'primary'} sm ch-join" data-join>${j ? `${icon('checkmark')}Joined` : 'Join'}</button>
</div>
<div class="crew-tabs" role="tablist">${['feed', 'watch', 'members', 'rules'].map((t) => `<button type="button" role="tab" data-ctab="${t}" aria-selected="${t === tab}">${t[0].toUpperCase() + t.slice(1)}</button>`).join('')}</div>`;
}
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 };
+126
View File
@@ -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') =>
`<svg viewBox="${vb}" aria-hidden="true" focusable="false">${body}</svg>`;
/** Reaction glyphs (the heart stream). Filled, with a soft highlight. */
export const REACT = {
heart: {
label: 'Heart',
color: '#ff5a7a',
svg: S(
`<path d="M16 27.5C9.2 22.6 3.6 18 3.6 11.6 3.6 7.7 6.6 4.6 10.4 4.6c2.4 0 4.3 1.2 5.6 3.2 1.3-2 3.2-3.2 5.6-3.2 3.8 0 6.8 3.1 6.8 7 0 6.4-5.6 11-12.4 15.9Z" fill="#ff4f73"/><path d="M8.2 9.4c.7-1.6 2.2-2.4 3.6-2.2" stroke="#fff" stroke-opacity=".7" stroke-width="2" stroke-linecap="round" fill="none"/>`,
),
},
fire: {
label: 'Fire',
color: '#ff8a3d',
svg: S(
`<path d="M16 29c-6 0-10-4-10-9.4 0-4.4 2.9-7 4.6-9.6 1.2 2.3 2.7 3.5 4 3.6-.5-4.4.9-8.6 4.9-12.1.3 4.6 2.1 7 4.3 9.5 2 2.3 3.7 4.7 3.7 8.6C27.5 25 22 29 16 29Z" fill="#ff7a2f"/><path d="M16 29c-3.2 0-5.3-2.1-5.3-5 0-2.6 1.8-4 2.8-5.6.7 1.2 1.6 1.8 2.3 1.9-.2-2.4.5-4.6 2.6-6.5.2 2.5 1.2 3.8 2.3 5.1 1.1 1.3 2 2.6 2 4.7 0 3.1-3.1 5.4-6.7 5.4Z" fill="#ffd166"/>`,
),
},
eyes: {
label: 'Eyes',
color: '#a3d8ff',
svg: S(
`<ellipse cx="10.4" cy="16" rx="6.4" ry="8.6" fill="#f4f7fb"/><ellipse cx="21.6" cy="16" rx="6.4" ry="8.6" fill="#f4f7fb"/><circle cx="12" cy="17.6" r="3.2" fill="#14181f"/><circle cx="23.2" cy="17.6" r="3.2" fill="#14181f"/><circle cx="13" cy="16.4" r="1" fill="#fff"/><circle cx="24.2" cy="16.4" r="1" fill="#fff"/>`,
),
},
wow: {
label: 'Wow',
color: '#ffd166',
svg: S(
`<circle cx="16" cy="16" r="12.6" fill="#ffcf4a"/><ellipse cx="11.6" cy="13" rx="1.7" ry="2.4" fill="#3a2600"/><ellipse cx="20.4" cy="13" rx="1.7" ry="2.4" fill="#3a2600"/><ellipse cx="16" cy="21.2" rx="3.4" ry="4.2" fill="#3a2600"/><path d="M8.6 8.8c1-1.1 2.3-1.7 3.6-1.8M23.4 8.8c-1-1.1-2.3-1.7-3.6-1.8" stroke="#3a2600" stroke-width="1.6" stroke-linecap="round"/>`,
),
},
};
export const REACT_KINDS = ['heart', 'fire', 'eyes', 'wow'];
/** Chat emotes. */
export const EMOTES = {
SealIt: {
hint: 'Seal it or it didn’t happen',
svg: S(
`<path d="M16 3.4l2.6 2 3.2-.5 1.2 3 3 1.2-.5 3.2 2 2.6-2 2.6.5 3.2-3 1.2-1.2 3-3.2-.5-2.6 2-2.6-2-3.2.5-1.2-3-3-1.2.5-3.2-2-2.6 2-2.6-.5-3.2 3-1.2 1.2-3 3.2.5Z" fill="#ebc777"/><path d="m11 16.4 3.4 3.4 6.8-7" stroke="#2a1a00" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
),
},
o7: {
hint: 'Salute',
svg: S(
`<circle cx="13" cy="17" r="9" fill="#8fd0ff"/><circle cx="10.2" cy="15.6" r="1.4" fill="#0b1a28"/><circle cx="15.8" cy="15.6" r="1.4" fill="#0b1a28"/><path d="M10 20.6c1.8 1.3 4.2 1.3 6 0" stroke="#0b1a28" stroke-width="1.6" fill="none" stroke-linecap="round"/><path d="M20.4 11.2 27.6 4" stroke="#f4f7fb" stroke-width="3.2" stroke-linecap="round"/><path d="M18.6 13.6l2.6-2.6" stroke="#f4f7fb" stroke-width="4" stroke-linecap="round"/>`,
),
},
EyesUp: {
hint: 'Look up',
svg: S(
`<ellipse cx="10.4" cy="17" rx="6" ry="8" fill="#f4f7fb"/><ellipse cx="21.6" cy="17" rx="6" ry="8" fill="#f4f7fb"/><circle cx="11" cy="12.6" r="3" fill="#14181f"/><circle cx="22.2" cy="12.6" r="3" fill="#14181f"/><path d="M9 4.6 16 1.6l7 3" stroke="#a3d8ff" stroke-width="1.8" fill="none" stroke-linecap="round"/>`,
),
},
HeliSpin: {
hint: 'Helicopter circling',
svg: S(
`<path d="M3 7.6h26" stroke="#a3d8ff" stroke-width="2.2" stroke-linecap="round"/><path d="M16 7.6v4" stroke="#a3d8ff" stroke-width="2"/><path d="M8.6 15.2c0-2.2 1.8-3.8 4-3.8h5.2c3.4 0 6 2.6 6 6v1.2c0 1.6-1.3 2.8-2.8 2.8H11.4c-1.6 0-2.8-1.2-2.8-2.8Z" fill="#dfe8f2"/><path d="M19 14h4.4" stroke="#14181f" stroke-width="1.6"/><path d="M8.6 16.6H2.4l-1-3" stroke="#dfe8f2" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M11 24.6h11M13 21.4l-1 3.2M20 21.4l1 3.2" stroke="#dfe8f2" stroke-width="1.6" stroke-linecap="round"/>`,
),
},
Receipts: {
hint: 'Show the receipts',
svg: S(
`<path d="M8 3.4h16v25.2l-2.7-1.8-2.6 1.8-2.7-1.8-2.6 1.8-2.7-1.8L8 28.6Z" fill="#f4f7fb"/><path d="M11.4 9h9.2M11.4 13h9.2M11.4 17h5.6" stroke="#59616c" stroke-width="1.6" stroke-linecap="round"/><path d="m16.8 20.4 2 2 4-4.2" stroke="#2fbf71" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
),
},
Doubt: {
hint: 'Doubt',
svg: S(
`<circle cx="16" cy="16" r="12.4" fill="#c7a6ff"/><path d="M9.4 11.4l4-1.2M18.8 10.6l3.8 1" stroke="#1d1030" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="14.6" r="1.6" fill="#1d1030"/><circle cx="20.2" cy="14.6" r="1.6" fill="#1d1030"/><path d="M11.6 21.6c2.6-1 5.8-1 8.6.4" stroke="#1d1030" stroke-width="1.8" fill="none" stroke-linecap="round"/>`,
),
},
StormMode: {
hint: 'Storm mode',
svg: S(
`<path d="M9 19.6h14.4a5.2 5.2 0 0 0 .4-10.4 7.4 7.4 0 0 0-14.3 1.8A4.3 4.3 0 0 0 9 19.6Z" fill="#8a96a8"/><path d="m17.6 17.4-4.6 6.4h3.6l-1.4 5.8 5.6-7.6h-3.6l1.8-4.6Z" fill="#ffd166"/>`,
),
},
LiftOff: {
hint: 'Launch',
svg: S(
`<path d="M16 2.6c4 3.2 5.8 7.8 5.4 13.2l-2.4 4.2h-6l-2.4-4.2C10.2 10.4 12 5.8 16 2.6Z" fill="#f4f7fb"/><circle cx="16" cy="11.4" r="2.2" fill="#7cc4ff"/><path d="m10.6 15.8-3.4 3.6.4 3.4 4.8-2.8M21.4 15.8l3.4 3.6-.4 3.4-4.8-2.8" fill="#ff7a2f"/><path d="M13.6 21.4c.4 3.4 1.2 6 2.4 8 1.2-2 2-4.6 2.4-8Z" fill="#ffd166"/>`,
),
},
};
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 `<span class="emote" title="${bare}" data-emote="${bare}">${EMOTES[bare].svg}</span>${esc(tail)}`;
if (/^@[\w.-]{2,}$/.test(bare)) {
const mine =
me && bare.toLowerCase() === String(me).toLowerCase().trim();
if (mine) mentionsMe = true;
return `<b class="mention ${mine ? 'me' : ''}">${esc(bare)}</b>${esc(tail)}`;
}
return esc(w);
})
.join('');
return { html, mentionsMe };
}
File diff suppressed because it is too large. Load diff
+578
View File
@@ -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 `<div class="need-card ${f ? 'is-done' : ''}" data-need="${esc(r.id)}" data-band="${esc(bandOf(r.situation || {}))}">
<div class="nc-top tele">${icon('eyes')}<b>Need eyes</b><span>· ${esc(r.distLabel || '')}</span>${r.example ? '<em class="badge-example">Example</em>' : ''}</div>
<p class="nc-q">“${esc(r.question)}”</p>
<div class="nc-by tele">${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} nearby asked once</div>
${
f
? `<div class="nc-done tele">${icon('sealFill')}Fulfilled · ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}</div>`
: r.self
? `<div class="nc-done tele mine">${icon('clock')}Your request · waiting for a sealed answer · expires in 2 h</div>`
: `<div class="nc-acts"><button type="button" class="pill-btn primary sm" data-need-act="fulfil" data-need="${esc(r.id)}">${icon('viewfinder')}I’m nearby · answer</button><span class="tele nc-reward">+${RULE.fulfil.delta} Signal · byline</span><button type="button" class="pill-btn plain sm" data-need-act="detail" data-need="${esc(r.id)}">Details</button></div>`
}
</div>`;
}
/* ---------------- 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 = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Need eyes<b>·</b>${esc(s.title)}</div>
<h2 class="sheet-title">Ask for eyes on a spot</h2>
<div class="sheet-sub">People within the radius get one quiet ask. The first sealed answer gets the byline and Signal.</div>`;
createS.body.innerHTML = `<div class="need-map"></div>
<div class="att-h tele">Your question</div>
<textarea class="cmp-text need-q" rows="2" maxlength="160" data-no-drag placeholder="What should someone there look at?"></textarea>
<div class="need-sugg">${(ASKS[g] || ASKS.default).map((q) => `<button type="button" class="chip" data-sugg="${esc(q)}">${esc(q)}</button>`).join('')}</div>
<div class="att-h tele">Ask people within</div>
<div class="seg-chips" data-radius>${[0.5, 2, 10].map((v) => `<button type="button" data-v="${v}" aria-pressed="${v === 2}">${fmtKm(v)}</button>`).join('')}</div>
<p class="att-fine">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.</p>
<button class="pill-btn primary att-post" type="button" data-act="ask">${icon('eyes')}Ask for eyes</button>`;
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 = `<div class="c-band tele" data-band="inside"><i class="bd"></i>Need eyes<b>·</b>${esc(r.roomTitle || '')}</div>
<h2 class="sheet-title">“${esc(r.question)}”</h2>
<div class="sheet-sub">Asked by ${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} people nearby asked once${r.example ? ' · <span class="badge-example">Example</span>' : ''}</div>`;
detailS.body.innerHTML = `<div class="need-shot" style="background-image:url('${esc(esriThumb(r.lon, r.lat, r.radiusKm > 10 ? 0.8 : 0.02, 640, 360))}')"><span class="ns-pin">${icon('pin')}</span><span class="ns-ring" style="--r:${Math.min(46, 18 + r.radiusKm * 4)}%"></span><span class="ns-tele tele">${esc(llShort(r.lat, r.lon, 4))} · ${esc(fmtKm(r.radiusKm))} RADIUS</span></div>
<ul class="need-why">
<li>${icon('sealFill')}<span><b>Answer with a sealed capture</b> from inside the radius. Text answers don’t fulfil a request.</span></li>
<li>${icon('eyes')}<span><b>First eyes</b> on a request is a permanent byline on the record.</span></li>
<li>${icon('sparkles')}<span><b>+${RULE.fulfil.delta} Signal</b>, more if sensors corroborate it. Signal weighs your votes and notes.</span></li>
</ul>
${
r.fulfilledBy
? `<div class="nc-done tele">${icon('sealFill')}Fulfilled by ${esc(r.fulfilledBy.handle)} · ${esc(utc(new Date(r.fulfilledBy.at)))}</div>`
: `<button class="pill-btn primary att-post" type="button" data-need-act="fulfil" data-need="${esc(r.id)}">${icon('viewfinder')}I’m nearby · answer with a capture</button>`
}`;
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(`<div class="byline-moment" role="dialog" aria-label="${o.first ? 'First eyes' : 'Signal earned'}">
<div class="bm-card">
<div class="bm-seal">${icon('sealFill')}<i></i><i></i></div>
<div class="bm-kicker tele">${o.need ? 'Need eyes fulfilled' : 'Sealed capture posted'}</div>
<h2>${o.first ? 'First eyes' : 'On the record'}</h2>
${o.need ? `<p class="bm-q">“${esc(o.need.question)}”</p>` : ''}
<div class="bm-byline tele"><span data-type></span><b class="caret"></b></div>
<ul class="bm-rx">${o.receipts.map((r, i) => `<li style="--i:${i}"><span>${esc(r.text)}</span><b class="num ${r.delta < 0 ? 'neg' : ''}">${r.delta > 0 ? '+' : ''}${r.delta}</b></li>`).join('')}</ul>
<div class="bm-score"><span class="tele">Signal</span><b class="num" data-score>${o.before}</b><span class="bm-tier" style="--tc:${t1.color}">${esc(t1.name)}</span></div>
<div class="bm-bar" style="--tc:${t1.color}"><i style="--p:${Math.round(t0.progress * 100)}%"></i></div>
<div class="bm-next tele">${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)}`}</div>
${t1.index > t0.index ? `<div class="bm-up tele">${icon('sparkles')}Tier up · ${esc(t0.name)} → ${esc(t1.name)}</div>` : ''}
<div class="bm-acts"><button type="button" class="pill-btn plain" data-bm="done">Done</button>${o.onRecord ? `<button type="button" class="pill-btn primary" data-bm="record">${icon('note')}See it in the Record</button>` : ''}</div>
<p class="bm-fine">Receipts are kept in You → Signal, each with its reason and source.</p>
</div>
</div>`);
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 };
}
+9
View File
@@ -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 }),
+457
View File
@@ -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 `<button type="button" class="cite" data-cite="${esc(c)}">${esc(l)}</button>`;
}
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
? `<div class="sy-sec" data-sy="${id}"><div class="sy-h tele"><i></i>${title}<span>${list.length}</span></div><ul>${list
.map(
(l, i) =>
`<li class="${i >= 2 ? 'more' : ''}"><span>${richText(l.text).html}</span><span class="sy-cites">${l.cites
.filter(Boolean)
.map((c) => citeChip(c))
.join('')}</span></li>`,
)
.join('')}</ul></div>`
: '';
const hidden = [sy.known, sy.disputed, sy.unknown].reduce(
(a, l) => a + Math.max(0, l.length - 2),
0,
);
void total;
return `<section class="synth ${compact && hidden ? 'compact' : ''}" aria-label="Known, disputed, unknown">
<div class="sy-top"><span class="tele">${icon('sparkles')}Synthesis</span><span class="tele sy-meta">${sy.n} posts · ${sy.obs} obs · ${esc(sy.hash.slice(0, 8))}</span></div>
${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
${compact && hidden ? `<button type="button" class="sy-more" data-act="synth-all">Show ${hidden} more line${hidden === 1 ? '' : 's'}</button>` : ''}
<p class="sy-foot">Written on this device by fixed rules from the posts and the sensor data. Every line cites its sources, and it is rebuilt whenever the room changes.</p>
</section>`;
}
export function clerkHtml(sum, { older = 0 } = {}) {
if (!sum) return '';
const q = new Map(sum.quotes.map((x) => [x.id, x]));
return `<section class="clerk" data-clerk="${esc(sum.id)}" aria-label="What chat is saying">
<div class="ck-top"><span class="ck-av">${icon('sparkles')}</span><span class="ck-who"><b>What chat is saying</b><span class="tele">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 }))}</span></span><span class="badge-sim">Synthesis</span></div>
<ul>${sum.bullets
.map(
(b) =>
`<li data-k="${b.kind}"><span>${esc(b.text)}</span>${b.cites.length ? `<span class="sy-cites">${b.cites.map((c) => citeChip(`chat:${c}`, `L${q.get(c)?.n ?? '?'}`)).join('')}</span>` : ''}</li>`,
)
.join('')}</ul>
<div class="ck-quotes" hidden>${sum.quotes
.map(
(x) =>
`<div class="ck-q" data-q="${esc(x.id)}"><span class="tele">L${x.n}</span><b>${esc(x.handle)}</b><span>${richText(x.text).html}</span></div>`,
)
.join('')}</div>
<p class="sy-foot">Summarized on this device by fixed rules from the Live lane${sum.quotes.some((x) => x.sim) ? ' (simulated chat)' : ''}, not an AI model. Chat is not evidence; cited lines are kept here as receipts · ${esc(sum.hash.slice(0, 8))}${older ? ` · <button type="button" class="linkish" data-act="older-clerk">${older} earlier</button>` : ''}</p>
</section>`;
}
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 `<span class="br" data-c="${c}"><b>${c}</b><i style="--p:${pct}%"></i><em class="num">${x.h}/${x.n}</em></span>`;
};
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 `<div class="note ${st.shown ? 'shown' : 'pending'}" data-note="${esc(n.id)}">
<div class="note-h tele">${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}</div>
${st.shown ? `<p>${esc(n.text)}</p>` : `<details><summary>Show the proposed note</summary><p>${esc(n.text)}</p></details>`}
<div class="bridge"><span class="tele">${icon('bridge')}Bridging</span>${bar('A')}${bar('B')}<span class="why">${esc(why)}</span></div>
<div class="note-rate">${n.mine ? `<span class="tele">You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)}</span>` : `<button type="button" class="pill-btn plain sm" data-rate="1">Helpful</button><button type="button" class="pill-btn plain sm" data-rate="0">Not helpful</button>`}</div>
</div>`;
}
function quoteHtml(q, r) {
if (!q) return '';
const k = KIND[q.kind] || KIND.context;
return `<button type="button" class="p-quote" data-jump="${esc(q.id)}"><span class="tele">${icon(k.icon)}${esc(k.name)} · ${esc(q.author.handle)} · ${esc(r.numbering.get(q.id) || '')}</span><span>${esc(q.text.length > 160 ? `${q.text.slice(0, 159)}…` : q.text)}</span></button>`;
}
function promotedHtml(pf) {
if (!pf) return '';
return `<div class="p-promo"><span class="tele">${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</span></div>`;
}
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)
? `<button type="button" class="anchor tele" data-anchor="${esc(p.id)}">${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''}</button>`
: '';
const media =
p.evidence?.captureId || p.evidence?.uploadId
? `<div class="p-media" data-media="${esc(p.evidence.captureId || p.evidence.uploadId)}" data-store="${p.evidence.uploadId ? 'uploads' : 'media'}" data-video="${p.evidence.video ? 1 : ''}">${p.evidence.unverified ? '<span class="p-unv tele">Unverified upload</span>' : `<span class="p-seal">${icon('sealFill')}</span>`}</div>`
: '';
const quoted = p.quoteOf ? r.posts.find((x) => x.id === p.quoteOf) : null;
const pct = Math.round(w.share * 100);
return `<article class="post k-${p.kind} ${p.example || sim ? 'is-example' : ''} ${p.author?.self ? 'is-mine' : ''} ${isFirst ? 'is-first' : ''}" data-post="${esc(p.id)}" style="--depth:${depth};--tc:${tier.color}">
<div class="p-head">
<span class="p-kind" title="${esc(k.name)}">${icon(k.icon)}</span>
<span class="p-meta"><b class="tele">${esc(k.name)}</b><span class="p-who">${esc(p.author.handle)}${p.author.self ? ' (you)' : ''}</span><span class="p-tier tele" title="Signal tier">${esc(tier.name)}</span><span class="tele p-when">${esc(ago(p.at))} · ${esc(num)}</span></span>
${sim ? '<span class="badge-sim">Simulated</span>' : p.example ? '<span class="badge-example">Example</span>' : ''}
</div>
${isFirst ? `<div class="p-first tele">${icon('sealFill')}First eyes · ${esc(p.author.handle)} · ${esc(utc(new Date(first.at)))}${Number.isFinite(first.km) ? ` · ${esc(fmtKm(first.km))}` : ''}</div>` : ''}
${p.needEyes ? `<div class="p-need tele">${icon('eyes')}Answered Need eyes · “${esc(p.needEyes.question || '')}”</div>` : ''}
<p class="p-text">${richText(p.text).html}</p>
${promotedHtml(p.promotedFrom)}
${quoteHtml(quoted, r)}
${media}
${badges.length ? `<div class="badges">${badges.map((b) => `<span class="bdg ${b.ex ? 'ex' : ''}" data-b="${b.id}">${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` <em>${esc(b.detail)}</em>` : ''}</span>`).join('')}</div>` : ''}
<div class="p-actions">
<button type="button" class="vote ${p.myVote === 'up' ? 'on' : ''}" data-vote="up" aria-label="Corroborate">${icon('agree')}<span class="num">${w.nUp}</span></button>
<button type="button" class="vote dis ${p.myVote === 'dn' ? 'on' : ''}" data-vote="dn" aria-label="Dispute">${icon('dispute')}<span class="num">${w.nDn}</span></button>
<span class="p-wbar ${w.up + w.dn ? '' : 'none'}" title="Signal-weighted: ${w.up.toFixed(1)} corroborate · ${w.dn.toFixed(1)} dispute"><i style="--p:${pct}%"></i><em class="tele">${w.up.toFixed(0)}:${w.dn.toFixed(0)}</em></span>
<span class="p-sp"></span>
${anchor}
<button type="button" class="vote" data-reply aria-label="Reply">${icon('reply')}${replies.length ? `<span class="num">${replies.length}</span>` : ''}</button>
<button type="button" class="vote" data-quote aria-label="Quote">${icon('share')}</button>
</div>
${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
${replies.length ? `<div class="replies">${replies.map((x) => postHtml(x, r, core, { depth: depth + 1, first })).join('')}</div>` : ''}
</article>`;
}
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
? `<video src="${esc(u)}#t=0.1" muted playsinline preload="metadata"></video>`
: `<img src="${esc(u)}" alt="Attached capture" loading="lazy"/>`,
);
}
}
/**
* 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('<div class="rec" data-lane="record"></div>');
const dockEl = h(
`<div class="reply-bar"><button type="button" class="rb-cap" data-act="capture-reply">${icon('viewfinder')}<span><b>Reply with evidence</b><small>Seal what you see</small></span></button><button type="button" class="rb-write" data-act="write">${icon('note')}Write</button><button type="button" class="rb-react" data-act="need-eyes" aria-label="Ask for eyes">${icon('eyes')}</button></div>`,
);
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 = `
<div class="rec-sort" role="tablist" aria-label="Sort the Record">${SORTS.map((x) => `<button type="button" role="tab" data-sort="${x.id}" aria-selected="${x.id === sort}">${x.name}</button>`).join('')}<span class="rec-sp"></span><span class="tele rec-n">${r.posts.length} posts</span></div>
${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 ? '<div class="example-note"><span class="badge-example">Example</span> 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.</div>' : ''}
${sort === 'evidence' && r.openQ.length ? `<div class="room-h tele">${icon('question')}Open questions <span>float until answered</span></div>${r.openQ.map((p) => postHtml(p, r, core, { first })).join('')}` : ''}
<div class="room-h tele">${icon(sort === 'disputed' ? 'dispute' : sort === 'new' ? 'clock' : 'sealFill')}${sort === 'evidence' ? 'Evidence first <span>sealed, corroborated, close beats loud</span>' : sort === 'new' ? 'Newest first' : 'Most disputed <span>weighted by Signal</span>'}</div>
${list.length ? list.map((p) => postHtml(p, r, core, { first })).join('') : '<div class="empty">Nothing disputed here. Votes and corrections move posts into this view.</div>'}
<div class="room-end tele">${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}</div>`;
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();
},
};
}
+177 -338
View File
@@ -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(`<div class="lane-switch" role="tablist" aria-label="Room lanes">
<button type="button" role="tab" data-lane="live"><i class="ls-dot"></i>Live<span class="ls-n tele" data-ls="live"></span></button>
<button type="button" role="tab" data-lane="record">${icon('note')}Record<span class="ls-n tele" data-ls="record"></span></button>
<span class="ls-thumb"></span>
</div>`);
const host = o.switchHost || o.body;
if (o.switchHost) o.switchHost.appendChild(sw);
const wrap = h(`<div class="room-panel" data-lane="${lane}"></div>`);
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 `<button type="button" class="cite" data-cite="${esc(c)}">${esc(label)}</button>`;
}
function synthHtml(r) {
const sy = r.synth;
const sec = (id, title, list) =>
list.length
? `<div class="sy-sec" data-sy="${id}"><div class="sy-h tele"><i></i>${title}<span>${list.length}</span></div><ul>${list
.map(
(l) =>
`<li><span>${esc(l.text)}</span><span class="sy-cites">${l.cites
.filter(Boolean)
.map((c) => citeChip(c))
.join('')}</span></li>`,
)
.join('')}</ul></div>`
: '';
return `<section class="synth" aria-label="Known, disputed, unknown">
<div class="sy-top"><span class="tele">${icon('sparkles')}Local synthesis</span><span class="tele sy-meta">${sy.n} posts · ${sy.obs} observations · ${esc(sy.hash.slice(0, 8))}</span></div>
${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
<p class="sy-foot">Written on this device by fixed rules from the posts and the sensor data, not by an AI model. Every line cites its sources; it is rebuilt whenever the room changes.</p>
</section>`;
}
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 `<span class="br" data-c="${c}"><b>${c}</b><i style="--p:${pct}%"></i><em class="num">${x.h}/${x.n}</em></span>`;
};
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 `<div class="note ${st.shown ? 'shown' : 'pending'}" data-note="${esc(n.id)}">
<div class="note-h tele">${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}</div>
${st.shown ? `<p>${esc(n.text)}</p>` : `<details><summary>Show the proposed note</summary><p>${esc(n.text)}</p></details>`}
<div class="bridge"><span class="tele">${icon('bridge')}Bridging</span>${bar('A')}${bar('B')}<span class="why">${esc(why)}</span></div>
<div class="note-rate">${n.mine ? `<span class="tele">You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)}</span>` : `<button type="button" class="pill-btn plain sm" data-rate="1">Helpful</button><button type="button" class="pill-btn plain sm" data-rate="0">Not helpful</button>`}</div>
</div>`;
}
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)
? `<button type="button" class="anchor tele" data-anchor="${esc(p.id)}">${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''}</button>`
: '';
const media =
p.evidence?.captureId || p.evidence?.uploadId
? `<div class="p-media" data-media="${esc(p.evidence.captureId || p.evidence.uploadId)}" data-store="${p.evidence.uploadId ? 'uploads' : 'media'}" data-video="${p.evidence.video ? 1 : ''}">${p.evidence.unverified ? '<span class="p-unv tele">Unverified upload</span>' : `<span class="p-seal">${icon('sealFill')}</span>`}</div>`
: '';
return `<article class="post k-${p.kind} ${p.example ? 'is-example' : ''} ${p.author?.self ? 'is-mine' : ''}" data-post="${esc(p.id)}" style="--depth:${depth}">
<div class="p-head">
<span class="p-kind" title="${esc(k.name)}">${icon(k.icon)}</span>
<span class="p-meta"><b class="tele">${esc(k.name)}</b><span class="p-who">${esc(p.author.handle)}${p.author.self ? ' (you)' : ''}</span><span class="tele p-when">${esc(ago(p.at))} · ${esc(num)}</span></span>
${p.example ? '<span class="badge-example">Example</span>' : ''}
</div>
<p class="p-text">${esc(p.text)}</p>
${media}
${badges.length ? `<div class="badges">${badges.map((b) => `<span class="bdg ${b.ex ? 'ex' : ''}" data-b="${b.id}">${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` <em>${esc(b.detail)}</em>` : ''}</span>`).join('')}</div>` : ''}
<div class="p-actions">
${anchor}
<span class="p-sp"></span>
<button type="button" class="vote ${p.myVote === 'up' ? 'on' : ''}" data-vote="up" aria-label="Agree">${icon('agree')}<span class="num">${p.votes.up}</span></button>
<button type="button" class="vote dis ${p.myVote === 'dn' ? 'on' : ''}" data-vote="dn" aria-label="Dispute">${icon('dispute')}<span class="num">${p.votes.dn}</span></button>
<button type="button" class="vote" data-reply aria-label="Reply">${icon('reply')}</button>
</div>
${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
${replies.length ? `<div class="replies">${replies.map((x) => postHtml(x, r, depth + 1)).join('')}</div>` : ''}
</article>`;
}
function presenceHtml(r) {
const here = rt.presence(r.id);
const ex = r.participants.filter((a) => a.example).slice(0, 4);
return `<div class="presence">
<span class="avs live">${here.map((a, i) => avatar({ handle: a.handle, self: a.self }, i).replace('class="av', 'class="av live')).join('')}</span>
<span class="pr-t tele"><b data-here>${here.length}</b> watching${here.length > 1 ? ' · live across your tabs' : ''}</span>
<span class="avs ex">${ex.map(avatar).join('')}</span>
</div>`;
}
async function render(s, el, { focusPost } = {}) {
const r = await rooms.room(s);
cur = { s, r, el };
rt.join(r.id);
el.innerHTML = `<div class="room" data-room="${esc(r.id)}">
${presenceHtml(r)}
${synthHtml(r)}
${r.hasExamples ? '<div class="example-note"><span class="badge-example">Example</span> 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.</div>' : ''}
${r.openQ.length ? `<div class="room-h tele">${icon('question')}Open questions <span>float until answered</span></div>${r.openQ.map((p) => postHtml(p, r)).join('')}` : ''}
<div class="room-h tele">${icon('sealFill')}Evidence first <span>then recency, never followers</span></div>
${r.ordered
.filter((p) => !r.openQ.includes(p))
.map((p) => postHtml(p, r))
.join('')}
<div class="room-end tele">${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}</div>
</div>`;
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
? `<video src="${esc(u)}#t=0.1" muted playsinline preload="metadata"></video>`
: `<img src="${esc(u)}" alt="Attached capture" loading="lazy"/>`,
);
}
}
/* ---------------- reactions ---------------- */
function reactionRail() {
return `<div class="react-rail" role="group" aria-label="React">${REACTIONS.map((x) => `<button type="button" data-react="${x.id}" aria-label="${x.label}">${icon(x.icon)}</button>`).join('')}</div>`;
}
function floatReaction(host, kind) {
if (!host || reducedMotion()) return;
const x = REACTIONS.find((r) => r.id === kind) || REACTIONS[0];
const el = h(`<span class="react-float">${icon(x.icon)}</span>`);
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: () =>
`<div class="reply-bar"><button type="button" class="rb-cap" data-act="capture-reply">${icon('viewfinder')}<span><b>Reply with evidence</b><small>Seal what you see</small></span></button><button type="button" class="rb-write" data-act="write">${icon('note')}Write</button><button type="button" class="rb-react" data-act="react-toggle" aria-expanded="false" aria-label="React">${icon('hand')}</button></div>`,
/** 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,
};
}
+109 -5
View File
@@ -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,
+217
View File
@@ -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 `<span class="heat" aria-hidden="true">${bars.map((b, i) => `<i style="--h:${Math.min(1, b).toFixed(2)};--d:${i}"></i>`).join('')}</span>`;
}
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 `<button type="button" class="lr-row" data-room="${esc(s.id)}" style="--band:${BAND_COLOR[band]}">
<span class="lr-thumb" ${thumb ? `style="background-image:url('${esc(thumb)}')"` : ''}><span class="lr-live tele"><i></i>LIVE</span><span class="lr-v tele">${fmtV(v)}</span></span>
<span class="lr-main">
<b>${esc(s.title)}</b>
<span class="lr-meta tele"><span>${esc(s.place || '')}</span>${heatBars(s)}</span>
<span class="lr-ticker" data-tick="${i}"></span>
</span>
</button>`;
}
function momentHero(m) {
const ms = Math.max(
1,
Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3),
);
return `<div class="mo-hero" data-moment="${esc(m.id)}" style="background-image:url('${esc(esriThumb(m.lon, m.lat, 0.018, 720, 520))}')">
<span class="fr" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<div class="mo-top tele"><span class="mo-k">${icon('sparkles')}Today’s Moment</span>${m.illustrative ? '<span class="badge-ill">Illustrative</span>' : '<span class="mo-real">Detected · ADS-B</span>'}</div>
<div class="mo-body">
<h3>${esc(m.title)}</h3>
<div class="mo-sub tele">${esc(m.place || '')} · ${ms} min · ${(m.tracks || []).length || 3} aircraft · ${(m.captures || []).length} sealed captures</div>
<div class="mo-acts">
<button type="button" class="pill-btn primary sm" data-mo="play" data-id="${esc(m.id)}">${icon('play')}Replay</button>
<button type="button" class="pill-btn glass sm" data-mo="party" data-id="${esc(m.id)}">${icon('users')}Watch party</button>
<button type="button" class="pill-btn glass sm" data-mo="record" data-id="${esc(m.id)}">${icon('note')}Record</button>
</div>
</div>
</div>`;
}
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 = `
<header class="navbar"><span class="nav-title">Rooms</span></header>
<h1 class="large-title">Rooms</h1>
<p class="rt-lede">Live is fast. The Record is true. Signal rewards being right, early, with receipts.</p>
<div class="section-h">Live now <small>${live.reduce((a, s) => a + viewerCount(s), 0).toLocaleString()} watching</small></div>
<div class="lr-list">${live.map(liveRow).join('') || '<div class="empty">Nothing unusual right now. That’s a valid state.</div>'}</div>
<div class="section-h">Moments <small>4D replays with chat and the record</small></div>
${hero ? momentHero(hero) : ''}
${
detections.length || moments.length > 1
? `<div class="mo-chips" data-hscroll>${[
...moments
.slice(1)
.map(
(m) =>
`<button type="button" class="mo-chip" data-mo="party" data-id="${esc(m.id)}">${icon('sky')}<span><b>${esc(m.title)}</b><small class="tele">${m.illustrative ? 'Illustrative' : 'Live detection'}</small></span></button>`,
),
...detections.map(
(s) =>
`<button type="button" class="mo-chip" data-room="${esc(s.id)}">${icon('sky')}<span><b>${esc(s.title)}</b><small class="tele">Detected · ${esc(ago(s.updatedAt))}</small></span></button>`,
),
].join('')}</div>`
: ''
}
<div class="mo-clips"><span class="mo-clip-ic">${icon('clock')}</span><span><b>Your clips</b><small>Select a range on the time machine and “Clip this Moment”. It lands here with its receipts.</small></span></div>
<div class="section-h">Need eyes near you <small>${loc?.guessed ? 'approximate · share location for distances' : 'one quiet ask each'}</small></div>
${needs.length ? needs.map((n) => core.needs.cardHtml(n)).join('') : '<div class="empty">No open requests near you. Calm is a feature.</div>'}
<div class="section-h">Your crews <small>${mine.length ? `${mine.length} joined` : 'communities, not followers'}</small></div>
<div class="crew-strip" data-hscroll>${mine.map((c) => `<button type="button" class="crew-card" data-crew="${esc(c.id)}" style="--hue:${c.hue}">${crewAvatar(c)}<b>${esc(c.name)}</b><small class="tele">${fmtM(c.members)} members · ${c.feed.length} new</small><span class="crew-last">${esc(c.feed[1]?.h || '')}: ${richText((c.feed[1]?.t || '').slice(0, 70)).html}…</span></button>`).join('') || '<div class="empty">Join a crew to see its feed here.</div>'}</div>
${
other.length
? `<div class="section-h">Discover crews</div><div class="group">${other.map((c) => `<div class="row crew-row" data-crew="${esc(c.id)}">${crewAvatar(c)}<span class="row-main"><span class="row-title">${esc(c.name)}</span><span class="row-sub">${esc(c.tagline)}</span></span><button type="button" class="pill-btn primary sm" data-join="${esc(c.id)}">Join</button></div>`).join('')}</div>`
: ''
}
<p class="footnote">Simulated chat and example crews are labelled. Your posts, votes, captures and Signal are real and stay on this device.</p>`;
// 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 = `<span class="tk ${first || reducedMotion() ? '' : 'in'}"><b style="color:${tier.color}">${esc(m.handle.slice(1))}</b> ${richText(m.text).html}</span>`;
});
}
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 = '';
},
};
}
+184
View File
@@ -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,
};
}
+77
View File
@@ -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;
}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>Social harness</title>
<meta name="color-scheme" content="dark" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="../styles/tokens.css" />
<link rel="stylesheet" href="../styles/shell.css" />
<link rel="stylesheet" href="../styles/feed.css" />
<link rel="stylesheet" href="../styles/sheet.css" />
<link rel="stylesheet" href="../styles/globe-ui.css" />
<link rel="stylesheet" href="../styles/capture.css" />
<link rel="stylesheet" href="../styles/threads.css" />
<link rel="stylesheet" href="../styles/cards.css" />
<link rel="stylesheet" href="../styles/room.css" />
<link rel="stylesheet" href="../styles/layout.css" />
<link rel="stylesheet" href="./social-dev.css" />
<script type="module" src="./social-dev.js"></script>
</head>
<body>
<div id="app" class="app sd-app" data-tab="rooms">
<div id="stage" class="stage" aria-label="Globe">
<div id="globe" class="globe-host"></div>
</div>
<main id="lead" class="lead sd-lead">
<section
id="view-rooms"
class="view view--scroll"
aria-label="Rooms"
></section>
<section
id="view-you"
class="view view--scroll"
aria-label="You"
hidden
></section>
</main>
<section
id="view-capture"
class="capture"
aria-label="Capture"
hidden
></section>
<div id="sheets" class="sheets"></div>
<nav class="sd-tabs" aria-label="Harness">
<button type="button" data-sd="rooms" aria-pressed="true">Rooms</button>
<button type="button" data-sd="globe">Globe</button>
<button type="button" data-sd="capture">Capture</button>
<button type="button" data-sd="you">You</button>
<button type="button" data-sd="overlay">Moment chat</button>
</nav>
<div class="sd-overlay" hidden></div>
</div>
</body>
</html>
+155
View File
@@ -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=<situation id>|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';
+651
View File
@@ -0,0 +1,651 @@
/**
* God's Eye social layer (DESIGN.md §12.2, §12.3, §12.6): the mount points
* the shell integrates, and the one shared social core behind them.
*
* configureSocial(ctx) → core (call once from main.js)
* mountRoomsTab(el, ctx) → { refresh(), destroy() }
* openRoom(situationOrMoment, { lane: 'live'|'record', focusPost?, crew?, detent? })
* → Promise<{ sheet, panel, close() }>
* mountYouTab(el, ctx) → { render(), destroy() }
* chatOverlay(el, ctx) → { push(msg), setMoment(moment), setTime(t), destroy() }
* needEyesNear(lonlat, km = 25) → Request[] (sync; open requests first)
* onSocial(event, fn) → unsubscribe ('moment-capture' | 'promote' | 'clerk' | 'signal')
* momentCaptures(momentId) → Promise<Capture[]> (the person's captures posted into a Moment)
*
* ctx (every key optional, read late so it can be filled in after boot):
* { globe, store, rt, rooms, getSituations(), getLocation(), bindScroll(el),
* onCapture(situation, { needEyes?, replyTo? }), onOpenSituation(s),
* onPlayMoment(moment), onShowGlobe(situation), onVerify(bundle),
* onCredits(), watch: { summary()? , open()? }, sheetHost }
* Pass main.js's `rt` and `rooms` so the app keeps one realtime channel.
*/
import { icon } from './icons.js';
import { h, esc, emitter, toast, haptic, store as domStore } from './dom.js';
import { createSheet } from './sheet.js';
import { createRealtime } from './realtime.js';
import { createRooms } from './rooms.js';
import { createSignal, tierOf, RULE, simTier } from './signal.js';
import { createRoomHub, mountChatOverlay, guessLoc } from './liveChat.js';
import { createRoomPanel } from './room.js';
import { createNeeds, celebrate } from './needEyes.js';
import { createCrews } from './crews.js';
import { createComposer } from './composer.js';
import { PERSONAS, viewerCount } from './chatSim.js';
import { BAND_NAME, bandOf, fmtKm, utc, distKm } from './bands.js';
import { get, put } from '../capture/db.js';
/* ---------------- Styles (no index.html change needed) ---------------- */
function ensureCss() {
if (
typeof document === 'undefined' ||
document.getElementById('gev-social-css')
)
return;
const l = document.createElement('link');
l.id = 'gev-social-css';
l.rel = 'stylesheet';
l.href = new URL('../styles/social.css', import.meta.url).href;
document.head.appendChild(l);
}
/* ---------------- Moments ---------------- */
/** The hero Moment when the engine has none (illustrative, labelled). */
function heroFallback() {
const end = Date.now() - 4 * 60e3;
return {
id: 'empire-state',
title: 'Climbers on the Empire State Building',
place: 'Midtown Manhattan, New York',
lon: -73.9857,
lat: 40.7484,
time: { start: end - 18 * 60e3, end },
illustrative: true,
tracks: [
{ id: 'n1', kind: 'helicopter', label: 'News 1', operator: 'News' },
{ id: 'n2', kind: 'helicopter', label: 'News 2', operator: 'News' },
{ id: 'p1', kind: 'helicopter', label: 'Police', operator: 'Police' },
],
captures: [],
chat: [],
posts: [],
sources: [
{ name: 'Illustrative reconstruction', detail: 'Synthetic tracks' },
],
};
}
export function isMoment(t) {
return (
!!t &&
!t.momentPosts &&
!!(t.tracks || t.cameraPath || (t.time && t.captures))
);
}
/** A Moment as a room: the same Live and Record lanes, on its evidence. */
export function momentRoom(m) {
const tracks = m.tracks || [];
const caps = m.captures || [];
const mins = Math.max(
1,
Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3),
);
return {
id: `moment:${m.id}`,
momentId: m.id,
moment: m,
kind: 'circling',
family: 'sky',
band: 'sky',
archetype: 'track',
title: m.title,
place: m.place,
subtitle: m.illustrative
? 'Illustrative reconstruction'
: 'Detected from live ADS-B',
lon: m.lon,
lat: m.lat,
live: !m.illustrative,
illustrative: !!m.illustrative,
updatedAt: +m.time?.end || Date.now(),
time: m.time,
source: m.illustrative ? 'Illustrative reconstruction' : 'ADS-B detection',
severity: 2,
metrics: [
{ label: 'Aircraft circling', value: tracks.length || 3 },
{ label: 'Sealed captures', value: caps.length },
{ label: 'Window', value: mins, unit: 'min' },
],
momentPosts: m.posts?.length ? m.posts : null,
momentCaptures: caps,
camera: m.cameraPath?.[0] || {
lon: m.lon,
lat: m.lat,
height: 2500,
heading: 0,
pitch: -35,
},
layers: [],
};
}
/* ---------------- The core ---------------- */
let core = null;
/**
* The shared social core. The first call builds it; later calls merge ctx.
* @param {object} ctx
*/
export function socialCore(ctx = {}) {
ensureCss();
if (core) {
Object.assign(core.ctx, strip(ctx));
if (ctx.globe && !core.globe) core.globe = ctx.globe;
return core;
}
core = buildCore(ctx);
return core;
}
export const configureSocial = socialCore;
/** Accept the shell's names too (openSituation, openMoment, openWatch). */
const strip = (o) => {
const x = Object.fromEntries(
Object.entries(o || {}).filter(([, v]) => v != null),
);
if (!x.onOpenSituation && x.openSituation)
x.onOpenSituation = x.openSituation;
if (!x.onPlayMoment && x.openMoment) x.onPlayMoment = x.openMoment;
if (!x.watch && x.openWatch) x.watch = { open: x.openWatch };
else if (x.watch && !x.watch.open && x.openWatch)
x.watch = Object.assign(Object.create(x.watch), { open: x.openWatch });
return x;
};
function buildCore(ctx) {
const ev = emitter();
const c = {
ctx: strip(ctx),
ev,
on: ev.on,
off: ev.off,
hubs: new Map(),
};
c.globe = ctx.globe || null;
c.store = ctx.store || domStore;
c.rt = ctx.rt || createRealtime();
c.rooms = ctx.rooms || createRooms({ rt: c.rt });
c.signal = createSignal({ me: c.rt.me });
c.signal.on('change', (x) => ev.emit('signal', x));
c.situations = async () => {
try {
if (c.ctx.getSituations) return (await c.ctx.getSituations()) || [];
if (c.globe?.situations) return (await c.globe.situations()) || [];
} catch {
/* ignore */
}
return [];
};
c.getLocation = () => c.ctx.getLocation?.() || null;
c.myLoc = () => c.getLocation() || c.rt.me.home || guessLoc();
c.hub = (s) => {
if (!s) return null;
const id = String(s.id);
if (!c.hubs.has(id)) c.hubs.set(id, createRoomHub(c, s));
return c.hubs.get(id);
};
c.onCapture = (s, o = {}) => {
roomSheet?.close();
if (c.ctx.onCapture) return c.ctx.onCapture(s, o);
// Fallback: switch to Capture and pre-attach through the shell's handle.
const cap = typeof window !== 'undefined' ? window.__gev?.capture : null;
if (c.ctx.selectTab) {
c.ctx.selectTab('capture');
setTimeout(() => (window.__gev?.capture || cap)?.attachTo?.(s, o), 60);
return null;
}
toast('Open Capture to answer with a sealed photo', {
icon: icon('viewfinder'),
});
return null;
};
/* Moments, feature-detected on the engine. */
const eng = () => c.globe?.raw || c.globe;
c.moments = async () => {
try {
const fn = c.globe?.moments || eng()?.moments;
if (typeof fn === 'function') {
const list = await fn.call(c.globe?.moments ? c.globe : eng());
if (Array.isArray(list) && list.length) return list;
}
} catch {
/* ignore */
}
return [heroFallback()];
};
c.moment = async (id) => {
try {
const fn = c.globe?.moment || eng()?.moment;
if (typeof fn === 'function') {
const m = await fn.call(c.globe?.moment ? c.globe : eng(), id);
if (m) return m;
}
} catch {
/* ignore */
}
return (await c.moments()).find((m) => m.id === id) || heroFallback();
};
/* Where the person has a sealed capture (for the Witness glow). */
const witnessRooms = new Set();
c.rooms
.myPosts()
.then((list) =>
list.forEach((p) => p.evidence?.sealed && witnessRooms.add(p.room)),
)
.catch(() => {});
c.myWitness = (room) => witnessRooms.has(String(room));
/* Simulated corroboration of the person's sealed posts (labelled). */
let simc = {};
get('kv', 'simcorro')
.then((r) => (simc = r?.map || {}))
.catch(() => {});
c.simCorro = (postId) => simc[postId] || [];
async function simulateCorroboration(s, post) {
const cast = [
...PERSONAS.local,
...PERSONAS.wx,
...PERSONAS.pilot,
...PERSONAS.photo,
...PERSONAS.skeptic,
].sort(() => Math.random() - 0.5);
const n = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < n; i += 1) {
const p = cast[i];
setTimeout(
async () => {
const t = simTier(p.handle, p.floor);
simc[post.id] = [
...(simc[post.id] || []),
{ handle: p.handle, w: t.weight, at: Date.now() },
];
await put('kv', { id: 'simcorro', map: simc }).catch(() => {});
const delta = Math.round(RULE.corroborated.delta * t.weight);
await c.signal.award({
id: `corro:${post.id}:${p.handle}`,
rule: 'corroborated',
delta,
text: `${p.handle} (${t.name}, ×${t.weight.toFixed(1)}) corroborated your capture in ${s.title} · simulated`,
ref: { room: String(s.id), post: post.id },
sim: true,
});
ev.emit('simcorro', { room: String(s.id), post: post.id });
if (i === 0)
toast(`${p.handle} corroborated your capture · +${delta} Signal`, {
icon: icon('agree'),
});
},
7000 + i * (3500 + Math.random() * 4000),
);
}
}
/**
* After a capture is posted (attach.js calls this): receipts, First eyes,
* Need eyes fulfilment, the byline moment, and the Moment timeline.
*/
c.afterPost = async (s, post, item = {}, { needEyes = null } = {}) => {
if (!post?.evidence?.sealed) return null;
witnessRooms.add(String(s.id));
const before = await c.signal.score();
const receipts = [];
const add = async (r) => {
const out = await c.signal.award(r);
if (out) receipts.push(out.receipt);
};
const ref = { room: String(s.id), post: post.id };
await add({
id: `sealed:${post.id}`,
rule: 'sealed',
text: `Sealed capture posted to ${s.title}`,
ref,
});
if (post.evidence.wasThere)
await add({
id: `there:${post.id}`,
rule: 'there',
text: `Inside the ${needEyes ? 'request' : 'event'} radius${Number.isFinite(post.evidence.distanceKm) ? ` · ${fmtKm(post.evidence.distanceKm)} ${needEyes ? 'from the spot' : 'away'}` : ''}`,
ref,
});
// First eyes on the room: no earlier sealed capture from inside it.
const r = await c.rooms.room(s).catch(() => null);
const earlier = (r?.posts || []).filter(
(p) => p.id !== post.id && p.evidence?.sealed && p.evidence?.wasThere,
);
const roomFirst = post.evidence.wasThere && !earlier.length;
let km = NaN;
if (Number.isFinite(post.evidence.distanceKm))
km = post.evidence.distanceKm;
else if (needEyes && post.anchor && Number.isFinite(post.anchor.lat))
km = distKm(post.anchor, needEyes);
let needFirst = false;
if (needEyes) {
const live = c.needs.byId(needEyes.id);
needFirst = !live?.fulfilledBy;
await c.needs.fulfil(needEyes, post, { km });
await add({
id: `fulfil:${needEyes.id}`,
rule: 'fulfil',
text: `Fulfilled Need eyes: “${needEyes.question}”`,
ref,
});
}
const first = roomFirst || needFirst;
if (first)
await add({
id: `first:${needEyes ? `need:${needEyes.id}` : s.id}`,
rule: 'first',
text: needEyes
? `First eyes on “${needEyes.question}”`
: `First eyes on ${s.title}`,
ref,
});
// Sensor corroboration: real contacts near the sealed position.
const a = post.anchor;
if (a && Number.isFinite(a.lat) && c.globe?.contactsNear) {
const near = (c.globe.contactsNear(a.lon, a.lat, 8) || []).filter((x) =>
/aircraft|helicopter|ship|vessel/.test(x.kind),
);
if (near.length) {
const air = near.filter((x) =>
/aircraft|helicopter/.test(x.kind),
).length;
await add({
id: `sensor:${post.id}`,
rule: 'sensor',
text: `${air ? `ADS-B: ${air} aircraft` : `AIS: ${near.length} vessels`} within 8 km at the sealed time`,
ref,
source: air ? 'ADS-B' : 'AIS',
});
}
}
const after = await c.signal.score();
const handle = c.rt.me.handle;
const at = post.anchor?.t || post.at;
const byline = `${first ? 'FIRST EYES' : 'SEALED'} · ${handle} · ${utc(new Date(at))}${Number.isFinite(km) ? ` · ${fmtKm(km).toUpperCase()}` : ''}`;
// The Live lane hears about it.
c.hubs.get(String(s.id))?.push({
id: `sys-cap-${post.id}`,
system: true,
handle: 'Record',
text: `${handle} posted a sealed capture${Number.isFinite(km) ? ` from ${fmtKm(km)}` : ''}${first ? ' · First eyes' : ''}.`,
at: Date.now(),
ref: post.id,
});
c.hubs.get(String(s.id))?.refreshFirst();
// A Moment's timeline picks it up.
const mid = s.momentId || activeMomentId();
if (mid) {
const cap = {
id: `capture:${mid}:${post.id}`,
t: at,
lon: a?.lon,
lat: a?.lat,
headingDeg: post.evidence.headingDeg ?? null,
fovDeg: 62,
title: post.text,
handle,
sealed: true,
firstEyes: first,
post: post.id,
self: true,
};
const rec = await get('kv', 'momentCaptures').catch(() => null);
const map = rec?.map || {};
map[mid] = [...(map[mid] || []).filter((x) => x.id !== cap.id), cap];
await put('kv', { id: 'momentCaptures', map }).catch(() => {});
const fn = c.globe?.addMomentCapture || eng()?.addMomentCapture;
if (typeof fn === 'function')
try {
fn.call(c.globe?.addMomentCapture ? c.globe : eng(), mid, cap);
} catch {
/* optional */
}
ev.emit('moment-capture', { momentId: mid, capture: cap });
}
simulateCorroboration(s, post);
const total = receipts.reduce((x, y) => x + (y.delta || 0), 0);
if (needEyes || first)
celebrate({
byline,
first,
need: needEyes,
receipts,
before,
after,
onRecord: () => openRoom(s, { lane: 'record', focusPost: post.id }),
});
else if (total)
toast(`+${total} Signal · ${receipts.length} receipts`, {
icon: icon('sparkles'),
});
return { receipts, before, after, first };
};
function activeMomentId() {
try {
const fn = c.globe?.activeMoment || eng()?.activeMoment;
return typeof fn === 'function'
? fn.call(c.globe?.activeMoment ? c.globe : eng())?.id || null
: null;
} catch {
return null;
}
}
/* Cross-tab traffic into the right hub. */
c.rt.on('chat', ({ room, msg }) => {
const hub = c.hubs.get(String(room));
if (hub && msg) hub.push({ ...msg, tier: msg.tier || tierOf(0) });
});
c.rt.on('react', ({ room, kind, remote }) => {
const hub = c.hubs.get(String(room));
if (hub && ['heart', 'fire', 'eyes', 'wow'].includes(kind))
hub.react(kind, { remote });
});
c.rt.on('corro', ({ room, id, w }) => {
const hub = c.hubs.get(String(room));
const m = hub?.log.find((x) => x.id === id);
if (m) hub.addCorro(m, `tab:${id}:${Math.random()}`, w || 1);
});
c.needs = createNeeds(c);
c.crews = createCrews(c);
c.openRoom = (t, o) => openRoom(t, o);
c.composer = null;
c.getComposer = () => {
// Modal sheets made earlier must still open above the room sheet.
const r = c.composer?.root;
r?.parentElement?.appendChild(r);
return (c.composer =
c.composer ||
createComposer({
rooms: c.rooms,
getTime: () => c.ctx.getTime?.() || null,
getLocation: c.getLocation,
getView: () => null,
onCapture: (s, o) => c.onCapture(s, o),
onPosted: () => {},
}));
};
return c;
}
/* ---------------- openRoom ---------------- */
let roomSheet = null;
let openPanel = null;
/**
* Open a room sheet in a lane. Accepts a Situation or a Moment.
* @param {object} target
* @param {{ lane?: 'live'|'record', focusPost?: string, crew?: object, detent?: string }} o
*/
export async function openRoom(target, o = {}) {
const c = socialCore();
if (!target) return null;
const s = isMoment(target) ? momentRoom(target) : target;
const lane = o.lane === 'record' ? 'record' : 'live';
if (!roomSheet)
roomSheet = createSheet({
host:
c.ctx.sheetHost || document.getElementById('sheets') || document.body,
label: 'Room',
detents: ['medium', 'large'],
dismissible: true,
closeButton: true,
className: 'room-sheet',
heights: {
medium: (H) => Math.round(H * 0.56),
large: (H) => Math.round(H * 0.94),
},
});
const sheet = roomSheet;
openPanel?.destroy();
openPanel = null;
let dock = sheet.sheet.querySelector('.sheet-dock');
if (!dock) {
dock = h('<div class="sheet-dock"></div>');
sheet.sheet.appendChild(dock);
}
dock.innerHTML = '';
sheet.body.innerHTML = '';
const band = bandOf(s);
const hub = c.hub(s);
sheet.sheet.dataset.band = band;
sheet.head.innerHTML = `<div class="rs-top tele" data-band="${band}"><i class="bd"></i><span>${esc(BAND_NAME[band] || 'Room')}</span><span class="rs-live"><i></i>${s.live === false && !s.momentId ? 'Room' : 'Live room'}</span><span>·</span><span class="rs-place">${esc(s.place || '')}</span></div>
<h2 class="sheet-title">${esc(s.title)}</h2>
${o.crew ? `<div class="rs-crew" style="--hue:${o.crew.hue}">${icon('users')}<span>Watching with <b>${esc(o.crew.name)}</b> · ${12 + (o.crew.members % 40)} crew here</span><em class="badge-example">Example</em></div>` : ''}
${s.illustrative ? '<div class="rs-ill tele">Illustrative reconstruction · tracks are synthetic</div>' : ''}`;
const panel = createRoomPanel(c, s, {
body: sheet.body,
dock,
switchHost: sheet.head,
lane,
focusPost: o.focusPost,
onCompose: (x, y) => c.getComposer().open(x, y),
onCapture: (x, y) => {
sheet.close();
c.onCapture(x, y);
},
flyTo: (a) => {
sheet.setDetent('medium');
c.globe?.flyTo?.({
lon: a.lon,
lat: a.lat,
height: 2500,
pitch: -45,
duration: 1.6,
});
},
scrub: null,
onLane: (l) => {
sheet.sheet.dataset.lane = l;
},
});
openPanel = panel;
const detent =
o.detent || (lane === 'live' && c.ctx.onShowGlobe ? 'medium' : 'large');
if (c.ctx.onShowGlobe) c.ctx.onShowGlobe(s);
sheet.open(detent);
const offClose = sheet.on('close', () => {
offClose?.();
if (openPanel === panel) {
panel.destroy();
openPanel = null;
}
});
haptic('light');
void hub;
return { sheet, panel, close: () => sheet.close() };
}
/* ---------------- Tabs ---------------- */
export function mountRoomsTab(el, ctx = {}) {
const c = socialCore(ctx);
let api = null;
const p = import('./roomsTab.js').then(
(m) => (api = m.createRoomsTab(el, c)),
);
return {
refresh: () => p.then(() => api?.refresh()),
destroy: () => p.then(() => api?.destroy()),
};
}
export function mountYouTab(el, ctx = {}) {
const c = socialCore(ctx);
let api = null;
const p = import('./you.js').then((m) => {
api = m.createYou(el, {
bindScroll: c.ctx.bindScroll || (() => {}),
getSituations: c.situations,
onOpenSituation: (s) => c.ctx.onOpenSituation?.(s),
onVerify: (b) => c.ctx.onVerify?.(b),
onCredits: () => c.ctx.onCredits?.(),
rooms: c.rooms,
rt: c.rt,
onOpenRoom: (s, postId) =>
openRoom(s, { lane: 'record', focusPost: postId }),
onLocate: async () => c.ctx.onLocate?.() || c.getLocation(),
core: c,
});
api.render();
return api;
});
return {
render: () => p.then(() => api?.render()),
destroy: () => p.then(() => (el.innerHTML = '')),
};
}
/** Twitch-style chat overlay for the Moment player. */
export function chatOverlay(el, ctx = {}) {
const c = socialCore(ctx);
return mountChatOverlay(c, el, {
globe: ctx.globe || c.globe,
moment: ctx.moment,
});
}
/** Open Need eyes requests near a point (sync; for Today and Capture). */
export function needEyesNear(lonlat, km = 25) {
const c = socialCore();
return c.needs.near(lonlat, km);
}
export function onSocial(event, fn) {
return socialCore().on(event, fn);
}
export async function momentCaptures(momentId) {
const rec = await get('kv', 'momentCaptures').catch(() => null);
return rec?.map?.[momentId] || [];
}
export function openCrew(id, o) {
return socialCore().crews.open(id, o);
}
export function openNeedEyes(situation) {
return socialCore().needs.openCreate(situation);
}
export function signalNow() {
const c = socialCore();
return { score: c.signal.cached, tier: c.signal.tier() };
}
export { celebrate, viewerCount };
+33
View File
@@ -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 };
}
}
+173 -4
View File
@@ -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(
<span>${key ? `Signing key <span class="mono">${esc(key.keyId)}</span> · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'}</span>
<span>${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</span></div></div>
<section class="sig-card" style="--tc:${tier.color}" aria-label="Signal">
<div class="sig-top"><span class="tele">${icon('sparkles')}Signal</span><span class="sig-tier">${esc(tier.name)}</span></div>
<div class="sig-score"><b class="num">${score.toLocaleString()}</b><span class="sig-w tele">votes weigh ×${tier.weight.toFixed(1)}</span></div>
<div class="sig-bar"><i style="--p:${Math.round(tier.progress * 100)}%"></i>${TIERS.map((t) => `<em style="--at:${Math.min(100, (t.min / 1500) * 100)}%" title="${esc(t.name)}"></em>`).join('')}</div>
<div class="sig-next tele">${tier.next ? `${(tier.next.min - score).toLocaleString()} to ${esc(tier.next.name)}` : 'Top tier'} · ${receipts.length} receipt${receipts.length === 1 ? '' : 's'} · ${firsts.length} first eyes</div>
<p class="sig-why">Signal rises when your sealed captures are corroborated by people and by sensors, and falls when your claims are corrected. It weighs your votes and notes. <b>No follower counts, ever.</b></p>
<details class="sig-rules"><summary>How Signal is calculated</summary><ul>${RULES.map((r) => `<li><b class="num ${r.delta < 0 ? 'neg' : ''}">${r.delta > 0 ? '+' : ''}${r.delta}</b><span>${esc(r.text)}</span></li>`).join('')}</ul><p>Tiers: ${TIERS.map((t) => `<span style="color:${t.color}">${esc(t.name)} ${t.min}+ (×${t.weight})</span>`).join(' · ')}</p></details>
</section>
${
firsts.length
? `<div class="section-h">First eyes <small>permanent bylines on the record</small></div><div class="bylines">${firsts
.map(
(r) =>
`<button type="button" class="byline" data-ref-room="${esc(r.ref?.room || '')}" data-ref-post="${esc(r.ref?.post || '')}"><span class="by-seal">${icon('sealFill')}</span><span><b class="tele">FIRST EYES · ${esc(rt.me.handle)} · ${esc(utc(new Date(r.at)))}</b><small>${esc(r.text.replace(/^First eyes on /, ''))}</small></span></button>`,
)
.join('')}</div>`
: ''
}
<div class="section-h">Receipts <small>${exampleLedger ? 'example until you earn your own' : 'every change, with its reason'}</small></div>
<div class="group ledger ${exampleLedger ? 'is-example' : ''}">${ledger
.slice(0, 14)
.map(
(r) =>
`<div class="row rx ${r.ref?.room ? 'tap' : ''}" ${r.ref?.room ? `data-ref-room="${esc(r.ref.room)}" data-ref-post="${esc(r.ref.post || '')}"` : ''}><b class="rx-d num ${r.delta < 0 ? 'neg' : ''}">${r.delta > 0 ? '+' : ''}${r.delta}</b><span class="row-main"><span class="row-title">${esc(r.text)}</span><span class="row-sub">${esc(ago(r.at))}${r.source ? ` · ${esc(r.source)}` : ''}${r.sim ? ' · simulated' : ''}${r.example ? ' · <span class="badge-example">Example</span>' : ''}</span></span></div>`,
)
.join('')}</div>
${
exampleLedger
? `<div class="sig-cta">${icon('eyes')}<span><b>${needs.length ? `${needs.length} Need eyes request${needs.length === 1 ? '' : 's'} near you` : 'Earn your first receipt'}</b>Answer one with a sealed capture: +40 Signal and the First eyes byline.</span>${needs[0] ? `<button type="button" class="pill-btn primary sm" data-need-act="fulfil" data-need="${esc(needs[0].id)}">Answer</button>` : ''}</div>`
: ''
}
<div class="section-h">Captures <small>tap to verify</small></div>
${
captures.length
@@ -118,7 +226,7 @@ export function createYou(
: '<div class="empty">Nothing posted yet. Every situation has a room: open one and reply with evidence.</div>'
}
<div class="section-h">Following</div>
<div class="section-h">Watching <small>${watchSummary ? esc(watchSummary) : 'places and things you follow'}</small>${c.ctx.watch?.open ? '<button type="button" class="linkish" data-act="watch">Alerts</button>' : ''}</div>
${
fol.length || contacts.length
? `<div class="group">${fol
@@ -142,6 +250,42 @@ export function createYou(
<div class="section-h">Home area</div>
<div class="group"><button class="row" type="button" data-act="home"><span class="row-icon" style="--fam:var(--b-ground)">${icon('home')}</span><span class="row-main"><span class="row-title">${home ? `Set near ${esc(llShort(home.lat, home.lon, 1))}` : 'Set your home area'}</span><span class="row-sub">Earns the Local badge on posts within 50 km. Stored on this device at 10 km precision.</span></span>${icon('chevronRight', { cls: 'chev' })}</button></div>
<div class="section-h">Notifications <small>calm by default</small></div>
<div class="group">
<div class="row"><span class="row-icon" style="--fam:var(--b-ground)">${icon('hazards')}</span><span class="row-main"><span class="row-title">Tell me about</span><span class="row-sub">${esc(NOTIFY.find((x) => x.id === notify).sub)}</span></span></div>
<div class="seg-row"><div class="segmented" data-set="notifyLevel">${NOTIFY.map((v) => `<button type="button" data-v="${v.id}" aria-pressed="${notify === v.id}">${v.label}</button>`).join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--eyes)">${icon('eyes')}</span><span class="row-main"><span class="row-title">Need eyes asks</span><span class="row-sub">At most one quiet ask a day, only when you’re inside a request’s radius</span></span></div>
<div class="seg-row"><div class="segmented" data-set="needAsks">${[
['daily', 'One a day'],
['off', 'Off'],
]
.map(
([v, l]) =>
`<button type="button" data-v="${v}" aria-pressed="${asks === v}">${l}</button>`,
)
.join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--orbit)">${icon('clock')}</span><span class="row-main"><span class="row-title">Quiet hours</span><span class="row-sub">22:00–07:00 · severe and local only</span></span><button class="switch" type="button" role="switch" aria-checked="${quiet}" aria-label="Quiet hours" data-switch="quietHours"></button></div>
<div class="row calm-now"><span class="row-icon" style="--fam:var(--ok)">${icon('checkmark')}</span><span class="row-main"><span class="row-title">Nothing unusual near you</span><span class="row-sub">That’s a valid state. No alarm feed, no upsell.</span></span></div>
</div>
<div class="section-h">Location you share <small>default for new captures</small></div>
<div class="group">
<div class="seg-row"><div class="segmented" data-set="precisionDefault">${[
['exact', 'Exact'],
['100m', '100 m'],
['1km', '1 km'],
]
.map(
([v, l]) =>
`<button type="button" data-v="${v}" aria-pressed="${prec === v}">${l}</button>`,
)
.join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--seal)">${icon('lock')}</span><span class="row-main"><span class="row-sub">The seal still proves the exact position to verifiers without publishing it. You can change it per capture.</span></span></div>
</div>
<div class="section-h">The line we don’t cross</div>
<div class="privacy-card">${icon('eyes')}<div><b>We track things that broadcast, never people.</b><span>Trackable entities are transponders, AIS, orbital elements, public infrastructure and events. Contributors are pseudonymous. There is no face recognition and no person search, and aircraft on owner-privacy lists (LADD/PIA) are never named.</span></div></div>
<div class="section-h">Settings</div>
<div class="group">
<div class="row"><span class="row-icon" style="--fam:var(--sky)">${icon('globe')}</span><span class="row-main"><span class="row-title">Globe quality</span><span class="row-sub">Applies on next launch</span></span></div>
@@ -171,6 +315,22 @@ export function createYou(
}
view.addEventListener('click', async (e) => {
const nd = e.target.closest('[data-need-act]');
if (nd) {
(await getCore()).needs.act(nd.dataset.needAct, nd.dataset.need);
return;
}
if (e.target.closest('[data-act="watch"]')) {
(await getCore()).ctx.watch?.open?.();
return;
}
const ref = e.target.closest('[data-ref-room]');
if (ref && ref.dataset.refRoom) {
const sits = await getSituations();
const st = sits.find((x) => String(x.id) === ref.dataset.refRoom);
if (st) onOpenRoom(st, ref.dataset.refPost || undefined);
return;
}
const cap = e.target.closest('[data-cap]');
if (cap) {
const c = (await all('captures')).find((x) => x.id === cap.dataset.cap);
@@ -194,6 +354,7 @@ export function createYou(
placeThumb(seg);
store.set(seg.dataset.set, segBtn.dataset.v);
if (seg.dataset.set === 'reduceMotion') applyMotionPref();
if (seg.dataset.set === 'notifyLevel') render();
haptic('tick');
return;
}
@@ -240,5 +401,13 @@ export function createYou(
new ResizeObserver(() => $$('.segmented', view).forEach(placeThumb)).observe(
view,
);
// Signal changes (a corroboration, a fulfilled request) refresh You live.
getCore()
.then((c) =>
c.signal.on('change', () => {
if (view.isConnected && !view.hidden) render();
}),
)
.catch(() => {});
return { render };
}