/**
* The Record lane (DESIGN.md §12.6): Reddit's threads and X's quote-posts,
* on the evidence. Considered posts with kinds (§11.3), threaded replies,
* quote-posts, sealed attachments, Corroborate / Dispute weighted by Signal,
* sort by Evidence / New / Disputed, community notes with bridging, the
* Known / Disputed / Unknown synthesis, and the Clerk's "What chat is
* saying" summaries with citations back to Live lines.
*/
import { icon } from './icons.js';
import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js';
import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js';
import { llShort, utc, fmtKm } from './bands.js';
import { mediaUrl } from './witness.js';
import { simTier, tierOf, hashStr } from './signal.js';
import { richText } from './emotes.js';
export const SORTS = [
{ id: 'evidence', name: 'Evidence' },
{ id: 'new', name: 'New' },
{ id: 'disputed', name: 'Disputed' },
];
/** Center an element inside its scroller without moving outer layers. */
function centerIn(sc, el, smooth) {
const top =
el.getBoundingClientRect().top -
sc.getBoundingClientRect().top +
sc.scrollTop -
sc.clientHeight / 2 +
el.offsetHeight / 2;
sc.scrollTo({ top: Math.max(0, top), behavior: smooth ? 'smooth' : 'auto' });
}
/** Signal-weighted tallies: seeded voters carry a stable average weight. */
export function weighted(p, core) {
const h = hashStr(p.id);
const wUp = 1 + (h % 90) / 100;
const wDn = 1 + ((h >>> 8) % 60) / 100;
const myW = tierOf(core.signal.cached).weight;
const sim = core.simCorro?.(p.id) || [];
const simW = sim.reduce((a, x) => a + x.w, 0);
const baseUp = Math.max(0, (p.votes?.up || 0) - (p.myVote === 'up' ? 1 : 0));
const baseDn = Math.max(0, (p.votes?.dn || 0) - (p.myVote === 'dn' ? 1 : 0));
const up = baseUp * wUp + (p.myVote === 'up' ? myW : 0) + simW;
const dn = baseDn * wDn + (p.myVote === 'dn' ? myW : 0);
return {
up,
dn,
nUp: (p.votes?.up || 0) + sim.length,
nDn: p.votes?.dn || 0,
share: up / Math.max(0.001, up + dn),
myW,
};
}
const disputeScore = (p, r, core) => {
const w = weighted(p, core);
const corr = r.repliesOf(p).filter((x) => x.kind === 'correction').length;
return w.dn / Math.max(1, w.up + w.dn) + corr * 0.4;
};
function authorTier(p, core) {
if (p.author?.self) return tierOf(core.signal.cached);
return simTier(p.author?.handle || '', p.author?.domain ? 300 : 0);
}
function citeChip(c, label) {
if (!c) return '';
const l =
label || (/^O\d/.test(c) ? `OBS ${c.slice(1)}` : c === 'O-cam' ? 'CAM' : c);
return `${esc(l)} `;
}
export function synthHtml(r, { compact = true } = {}) {
const sy = r.synth;
const total = sy.known.length + sy.disputed.length + sy.unknown.length;
const sec = (id, title, list) =>
list.length
? `
${title}${list.length}
${list
.map(
(l, i) =>
`${richText(l.text).html} ${l.cites
.filter(Boolean)
.map((c) => citeChip(c))
.join('')} `,
)
.join('')} `
: '';
const hidden = [sy.known, sy.disputed, sy.unknown].reduce(
(a, l) => a + Math.max(0, l.length - 2),
0,
);
void total;
return `
${icon('sparkles')}Synthesis ${sy.n} posts · ${sy.obs} obs · ${esc(sy.hash.slice(0, 8))}
${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)}
${compact && hidden ? `Show ${hidden} more line${hidden === 1 ? '' : 's'} ` : ''}
`;
}
export function clerkHtml(sum, { older = 0 } = {}) {
if (!sum) return '';
const q = new Map(sum.quotes.map((x) => [x.id, x]));
return `
${icon('sparkles')} What chat is saying Clerk · ${esc(ago(sum.at))} · ${sum.window.n} lines · ${esc(utc(new Date(sum.window.from), { seconds: false }))}–${esc(utc(new Date(sum.window.to), { seconds: false }))} Synthesis
${sum.bullets
.map(
(b) =>
`${esc(b.text)} ${b.cites.length ? `${b.cites.map((c) => citeChip(`chat:${c}`, `L${q.get(c)?.n ?? '?'}`)).join('')} ` : ''} `,
)
.join('')}
${sum.quotes
.map(
(x) =>
`
L${x.n} ${esc(x.handle)} ${richText(x.text).html}
`,
)
.join('')}
`;
}
function noteHtml(n, p) {
const st = noteStatus(n, n.mine);
const bar = (c) => {
const x = st.by[c];
const pct = x.n ? Math.round((x.h / x.n) * 100) : 0;
return `${c} ${x.h}/${x.n} `;
};
const why = st.shown
? 'Raters who usually disagree both found it helpful'
: st.status === 'not-bridged'
? `Not shown: one cluster rates it below ${Math.round(BRIDGE.minHelpful * 100)} %`
: `Not shown yet: needs ${BRIDGE.minPerCluster}+ raters from cluster ${st.status.replace('needs-', '').split('').join(' and ')}`;
return `
${icon('note')}${st.shown ? 'Community note' : 'Proposed note'}${p.example ? ' · example' : ''}
${st.shown ? `
${esc(n.text)}
` : `
Show the proposed note ${esc(n.text)}
`}
${icon('bridge')}Bridging ${bar('A')}${bar('B')}${esc(why)}
${n.mine ? `You rated ${n.mine.helpful ? 'helpful' : 'not helpful'} as cluster ${esc(n.mine.cluster)} ` : `Helpful Not helpful `}
`;
}
function quoteHtml(q, r) {
if (!q) return '';
const k = KIND[q.kind] || KIND.context;
return `${icon(k.icon)}${esc(k.name)} · ${esc(q.author.handle)} · ${esc(r.numbering.get(q.id) || '')} ${esc(q.text.length > 160 ? `${q.text.slice(0, 159)}…` : q.text)} `;
}
function promotedHtml(pf) {
if (!pf) return '';
return `${icon('sendUp')}From Live chat · L${esc(pf.n ?? '')} · ${esc(utc(new Date(pf.at)))} · ${pf.auto ? 'promoted by corroboration' : 'promoted'} · ${Number(pf.corro || 0).toFixed(1)} weighted
`;
}
export function postHtml(p, r, core, { depth = 0, first = null } = {}) {
const s = r.situation;
const k = KIND[p.kind] || KIND.context;
const badges = badgesFor(p, s, core.rt.me);
const replies = r.repliesOf(p);
const num = r.numbering.get(p.id);
const tier = authorTier(p, core);
const w = weighted(p, core);
const isFirst = first && first.post === p.id;
const sim = p.author?.sim;
const anchor =
p.anchor && Number.isFinite(p.anchor.lat)
? `${icon('pin')}${esc(llShort(p.anchor.lat, p.anchor.lon, 3))}${p.anchor.t ? ` · ${esc(utc(new Date(p.anchor.t), { seconds: false }))}` : ''} `
: '';
const media =
p.evidence?.captureId || p.evidence?.uploadId
? `${p.evidence.unverified ? 'Unverified upload ' : `${icon('sealFill')} `}
`
: '';
const quoted = p.quoteOf ? r.posts.find((x) => x.id === p.quoteOf) : null;
const pct = Math.round(w.share * 100);
return `
${icon(k.icon)}
${esc(k.name)} ${esc(p.author.handle)}${p.author.self ? ' (you)' : ''} ${esc(tier.name)} ${esc(ago(p.at))} · ${esc(num)}
${sim ? 'Simulated ' : p.example ? 'Example ' : ''}
${isFirst ? `${icon('sealFill')}First eyes · ${esc(p.author.handle)} · ${esc(utc(new Date(first.at)))}${Number.isFinite(first.km) ? ` · ${esc(fmtKm(first.km))}` : ''}
` : ''}
${p.needEyes ? `${icon('eyes')}Answered Need eyes · “${esc(p.needEyes.question || '')}”
` : ''}
${richText(p.text).html}
${promotedHtml(p.promotedFrom)}
${quoteHtml(quoted, r)}
${media}
${badges.length ? `${badges.map((b) => `${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` ${esc(b.detail)} ` : ''} `).join('')}
` : ''}
${icon('agree')}${w.nUp}
${icon('dispute')}${w.nDn}
${w.up.toFixed(0)}:${w.dn.toFixed(0)}
${anchor}
${icon('reply')}${replies.length ? `${replies.length} ` : ''}
${icon('share')}
${(p.notes || []).map((n) => noteHtml(n, p)).join('')}
${replies.length ? `${replies.map((x) => postHtml(x, r, core, { depth: depth + 1, first })).join('')}
` : ''}
`;
}
async function hydrateMedia(el) {
for (const m of $$('.p-media[data-media]', el)) {
if (m.querySelector('img,video')) continue;
const u = await mediaUrl(m.dataset.media, m.dataset.store);
if (!u) continue;
m.insertAdjacentHTML(
'afterbegin',
m.dataset.video
? ` `
: ` `,
);
}
}
/**
* Mount the Record lane.
* @param {object} core
* @param {object} hub the room's Live hub (for Clerk summaries), optional
* @param {{ s, body, dock, flyTo?, scrub?, onCompose(s, o), onCapture(s, o),
* onNeedEyes?(), focusPost?, onLive?() }} o
*/
export function mountRecordLane(core, hub, o) {
const s = o.s;
let sort = 'evidence';
let r = null;
let showOlder = false;
const root = h('
');
const dockEl = h(
`${icon('viewfinder')}Reply with evidence Seal what you see ${icon('note')}Write ${icon('eyes')}
`,
);
o.body.appendChild(root);
o.dock.appendChild(dockEl);
const scroller = () => o.body;
async function render({ keep = true, focusPost } = {}) {
const sc = keep ? scroller().scrollTop : 0;
r = await core.rooms.room(s);
const first = hub?.firstEyes || null;
const roots = r.posts.filter((p) => !p.replyTo);
let list;
if (sort === 'new') list = [...roots].sort((a, b) => b.at - a.at);
else if (sort === 'disputed')
list = [...roots]
.filter((p) => disputeScore(p, r, core) > 0.12)
.sort((a, b) => disputeScore(b, r, core) - disputeScore(a, r, core));
else list = r.ordered.filter((p) => !r.openQ.includes(p));
const clerk = hub?.clerk || [];
const needs = core.needs?.forRoom?.(r.id) || [];
root.innerHTML = `
${SORTS.map((x) => `${x.name} `).join('')}${r.posts.length} posts
${synthHtml(r)}
${clerk[0] ? clerkHtml(clerk[0], { older: clerk.length - 1 }) : ''}
${
showOlder
? clerk
.slice(1)
.map((c) => clerkHtml(c))
.join('')
: ''
}
${needs.map((n) => core.needs.cardHtml(n)).join('')}
${r.hasExamples ? 'Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''}
${sort === 'evidence' && r.openQ.length ? `${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r, core, { first })).join('')}` : ''}
${icon(sort === 'disputed' ? 'dispute' : sort === 'new' ? 'clock' : 'sealFill')}${sort === 'evidence' ? 'Evidence first sealed, corroborated, close beats loud ' : sort === 'new' ? 'Newest first' : 'Most disputed weighted by Signal '}
${list.length ? list.map((p) => postHtml(p, r, core, { first })).join('') : 'Nothing disputed here. Votes and corrections move posts into this view.
'}
${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
`;
hydrateMedia(root);
pinPosts();
if (keep) scroller().scrollTop = sc;
if (focusPost) {
const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, root);
if (pe) centerIn(scroller(), pe, false);
pe?.classList.add('flash');
}
return r;
}
/* ---- globe pins for anchored posts ---- */
let pinIds = [];
function clearPins() {
for (const id of pinIds) core.globe?.removePin?.(id);
pinIds = [];
}
function pinPosts() {
clearPins();
if (!core.globe?.addPin) return;
for (const p of r.posts) {
if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue;
const kind = p.evidence?.live
? 'live'
: p.kind === 'witness'
? 'witness'
: 'post';
const id = core.globe.addPin({
id: `post:${p.id}`,
lon: p.anchor.lon,
lat: p.anchor.lat,
kind: p.hologram ? 'hologram' : kind,
label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`,
headingDeg: p.evidence?.headingDeg,
});
if (id) pinIds.push(id);
}
}
function flash(el) {
if (!el) return;
centerIn(scroller(), el, !reducedMotion());
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
root.addEventListener('click', async (e) => {
const srt = e.target.closest('[data-sort]');
if (srt) {
sort = srt.dataset.sort;
haptic('tick');
await render({ keep: false });
scroller().scrollTop = 0;
return;
}
if (e.target.closest('[data-act="synth-all"]')) {
$('.synth', root)?.classList.remove('compact');
e.target.closest('[data-act]').remove();
return;
}
if (e.target.closest('[data-act="older-clerk"]')) {
showOlder = !showOlder;
await render();
return;
}
const need = e.target.closest('[data-need-act]');
if (need) {
core.needs.act(need.dataset.needAct, need.dataset.need);
return;
}
const cite = e.target.closest('[data-cite]');
if (cite) {
const id = cite.dataset.cite;
if (id.startsWith('chat:')) {
const box = cite.closest('.clerk');
const qs = $('.ck-quotes', box);
qs.hidden = false;
const q = $(`[data-q="${CSS.escape(id.slice(5))}"]`, qs);
$$('.ck-q', qs).forEach((x) => x.classList.toggle('hl', x === q));
haptic('tick');
return;
}
const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0];
const el = target ? $(`[data-post="${CSS.escape(target)}"]`, root) : null;
if (el) flash(el);
else
toast(
`${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`,
{ icon: icon('info') },
);
return;
}
const jump = e.target.closest('[data-jump]');
if (jump) {
flash($(`[data-post="${CSS.escape(jump.dataset.jump)}"]`, root));
return;
}
const postEl = e.target.closest('[data-post]');
const post = postEl
? r.posts.find((p) => p.id === postEl.dataset.post)
: null;
if (!post) return;
const anchor = e.target.closest('[data-anchor]');
if (anchor && post.anchor) {
haptic('light');
o.flyTo?.(post.anchor, s);
if (post.anchor.t) o.scrub?.(post.anchor.t);
return;
}
const vote = e.target.closest('[data-vote]');
if (vote) {
haptic('tick');
const was = post.myVote;
await core.rooms.vote(post, vote.dataset.vote);
if (was !== vote.dataset.vote) {
const wgt = tierOf(core.signal.cached).weight;
toast(
`${vote.dataset.vote === 'up' ? 'Corroborated' : 'Disputed'} · your vote weighs ×${wgt.toFixed(1)}`,
{ icon: icon(vote.dataset.vote === 'up' ? 'agree' : 'dispute') },
);
}
await render();
return;
}
if (e.target.closest('[data-reply]')) {
o.onCompose(s, { replyTo: post });
return;
}
if (e.target.closest('[data-quote]')) {
o.onCompose(s, { quote: post });
return;
}
const rate = e.target.closest('[data-rate]');
if (rate) {
const nid = rate.closest('[data-note]').dataset.note;
const note = post.notes.find((n) => n.id === nid);
const mine = await core.rooms.rate(note, rate.dataset.rate === '1');
const { why } = await core.rooms.myCluster();
haptic('success');
const st = noteStatus(note, mine);
if (st.shown && mine.helpful)
core.signal.award({
id: `note:${note.id}`,
rule: 'note',
text: `A note you rated helpful is bridged in ${s.title}`,
ref: { room: r.id, post: post.id },
});
toast(
`Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`,
{ icon: icon('bridge') },
);
await render();
}
});
dockEl.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'capture-reply') o.onCapture(s, {});
else if (act === 'write') o.onCompose(s, {});
else if (act === 'need-eyes') o.onNeedEyes?.();
});
const offPost = core.rooms.on('post', (p) => {
if (p.room === String(s.id)) render();
});
const offClerk = hub?.on('clerk', () => render());
const offNeeds = core.needs?.on?.('change', () => render());
const offSig = core.on?.(
'simcorro',
(x) => x.room === String(s.id) && render(),
);
const ready = render({ keep: false, focusPost: o.focusPost });
return {
root,
ready,
render,
get room() {
return r;
},
destroy() {
clearPins();
[offPost, offClerk, offNeeds, offSig].forEach(
(f) => typeof f === 'function' && f(),
);
root.remove();
dockEl.remove();
},
};
}