feat(mobile): rooms, live and upload capture, and the altitude-band identity

The Now feed mixes sensor, eye, track, number, witness and room cards
across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with
composited thumbnails and live public-camera frames.

Every situation has a room: typed, place- and time-anchored posts,
evidence badges, bridging-rated notes and a cited Known / Disputed /
Unknown local synthesis, with cross-tab presence over BroadcastChannel.
Example posts are labelled as such.

Capture adds Live (10 s segments sealed into a hash chain, shown on the
globe as a view frustum) and Upload (EXIF placement, Unverified tier),
and an attach sheet that files any capture into a nearby situation.

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-19 02:49:03 +00:00
1 parent ba1673a6db
commit 8b7fcb09df
38 files changed
+9837 -1081

No files matched your search

+580 -147
View File
@@ -1,20 +1,32 @@
/**
* Now: a two-column, RedNote-style masonry of live situations with a large
* collapsing title, section tabs, pull-to-refresh and skeleton loading.
* Now: the global equivalent of a neighbourhood safety feed (DESIGN.md §11.2).
* Mixed card archetypes across the four altitude bands, ordered so that
* neighbours differ, with Room and Witness cards woven in, an altitude
* filter, distance from you, and a "Near you" lens. Two-column masonry with
* full-width rows for live cameras and rooms; columns are balanced exactly
* before every wide row.
*/
import { icon, FAMILY_ICON, FAMILIES } from './icons.js';
import { $, $$, esc, ago, fmtNum, clamp, haptic } from './dom.js';
import { icon } from './icons.js';
import { $, $$, esc, ago, clamp, haptic, reducedMotion } from './dom.js';
import { cardHtml } from './cards.js';
import { localWitness } from './witness.js';
import {
BANDS,
bandOf,
archetypeOf,
distKm,
fmtKm,
utc,
presenceRadiusKm,
} from './bands.js';
const SECTIONS = [
{ id: 'now', label: 'Happening now' },
{ id: 'near', label: 'Near you' },
{ id: 'recon', label: 'Reconstructions' },
{ id: 'always', label: 'Always on' },
];
const ASPECTS = [1.25, 1, 1.33, 0.82, 1.18, 0.95];
const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name]));
const BAND_RANGE = {
orbit: '36,000 km',
sky: '15 km',
ground: '0 m',
inside: 'you',
};
function hashStr(s) {
let x = 2166136261;
@@ -23,14 +35,6 @@ function hashStr(s) {
return x >>> 0;
}
/** Height/width ratio for a card: from the thumbnail URL when known, else stable per id. */
function aspectFor(s) {
const m = /size=(\d+),(\d+)/.exec(s.thumbnail || '');
if (m) return clamp(Number(m[2]) / Number(m[1]), 0.72, 1.4);
if (s.kind === 'reconstruction') return 1.3;
return ASPECTS[hashStr(String(s.id)) % ASPECTS.length];
}
export function isReconstruction(s) {
return (
s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread
@@ -40,150 +44,498 @@ export function isAlwaysOn(s) {
return s.section === 'always' || !!s.alwaysOn;
}
function distKm(a, b) {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
const WIDE = new Set(['eye', 'room']);
const BODY_H = {
sensor: 84,
track: 110,
number: 62,
witness: 78,
prompt: 46,
};
const GAP = 8;
function aspect(item) {
const a = item.archetype;
if (a === 'number') return 1.06;
if (a === 'track') return 1;
if (a === 'witness') return item.w?.liveNow ? 1.3 : 1.22;
if (a === 'prompt') return 1.08;
const opts = [1.18, 1.32, 1.0, 1.42, 1.12];
return opts[hashStr(String(item.key)) % opts.length];
}
/** Diversity-aware ordering: keep priority, avoid same archetype/band neighbours. */
export function diversify(list) {
const pool = [...list];
const out = [];
let sinceWide = 9;
while (pool.length) {
let best = 0;
let bs = -Infinity;
for (let i = 0; i < Math.min(pool.length, 7); i += 1) {
const it = pool[i];
const p1 = out[out.length - 1];
const p2 = out[out.length - 2];
let sc = -i * 1.15;
if (p1?.archetype === it.archetype) sc -= 6;
if (p2?.archetype === it.archetype) sc -= 2.5;
if (p1?.band === it.band) sc -= 4;
if (p2?.band === it.band) sc -= 1.5;
if (WIDE.has(it.archetype) && sinceWide < 3) sc -= 5;
if (sc > bs) {
bs = sc;
best = i;
}
}
const it = pool.splice(best, 1)[0];
sinceWide = WIDE.has(it.archetype) ? 0 : sinceWide + 1;
out.push(it);
}
return out;
}
export function createFeed(
view,
{ getSituations, onOpen, onLocate, bindScroll },
{
getSituations,
onOpen,
onOpenRoom,
onWitness,
onCapture,
onLocate,
bindScroll,
globe,
rooms,
rt,
},
) {
view.innerHTML = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
<div class="nav-trailing"><button type="button" class="near-chip" data-act="near" aria-pressed="false">${icon('location')}<span>Near you</span></button><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${FAMILIES.map((f, i) => `<i style="--c:${f.color};--i:${i}"></i>`).join('')}</div></div>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `<i style="--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<h1 class="large-title">Now</h1>
<div class="title-sub"><span class="live-dot"></span><span class="feed-count num">Loading the planet…</span></div>
<div class="feed-tabs" role="tablist" aria-label="Feed sections" data-hscroll>
${SECTIONS.map((s, i) => `<button role="tab" type="button" data-sec="${s.id}" aria-selected="${i === 0}">${s.label}</button>`).join('')}
<span class="feed-tabs-ink" aria-hidden="true"></span>
<div class="feed-hero">
<h1 class="large-title">Now</h1>
<div class="feed-clock tele" aria-hidden="true"><b data-utc></b><span data-date></span></div>
</div>
<div class="title-sub tele"><span class="live-dot"></span><span class="feed-count">Acquiring the planet…</span></div>
<div class="bandbar" role="tablist" aria-label="Altitude" data-hscroll>
<button role="tab" type="button" data-band="all" aria-selected="true"><span class="bb-n">All</span><span class="bb-r tele" data-count="all">—</span></button>
${BANDS.map((b) => `<button role="tab" type="button" data-band="${b.id}" aria-selected="false"><span class="bb-n"><i></i>${b.name}</span><span class="bb-r tele"><span data-count="${b.id}">—</span> · ${BAND_RANGE[b.id]}</span></button>`).join('')}
</div>
<div class="feed-note" hidden></div>
<div class="masonry" role="feed" aria-busy="true"><div class="mcol"></div><div class="mcol"></div></div>
<p class="feed-end" hidden>You’re caught up with the planet.</p>
<div class="feed-body" role="feed" aria-busy="true"></div>
<p class="feed-end tele" hidden>End of feed · the planet keeps turning</p>
</div>`;
const inner = $('.feed-inner', view);
const cols = $$('.mcol', view);
const masonry = $('.masonry', view);
const tabs = $('.feed-tabs', view);
const ink = $('.feed-tabs-ink', view);
const fbody = $('.feed-body', view);
const bandbar = $('.bandbar', view);
const count = $('.feed-count', view);
const note = $('.feed-note', view);
const ptr = $('.ptr', view);
let section = 'now';
let band = 'all';
let near = false;
let all = [];
let userLoc = null;
let hot = [];
let witness = [];
const cardEls = new Map();
let selectedId = null;
const itemsByKey = new Map();
/* ---------- clocks ---------- */
function tickClocks() {
const now = new Date();
const u = utc(now);
const hd = $('[data-utc]', view);
if (hd) hd.textContent = `${u} UTC`.replace('Z UTC', ' UTC');
const dt = $('[data-date]', view);
if (dt)
dt.textContent = now
.toLocaleDateString('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
})
.toUpperCase();
if (view.hidden) return;
$$('[data-clock]', fbody).forEach((el) => (el.textContent = u));
$$('[data-since]', fbody).forEach((el) => {
const s = Math.max(
0,
Math.round((Date.now() - Number(el.dataset.since)) / 1000),
);
el.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
});
}
setInterval(tickClocks, 1000);
tickClocks();
function skeleton() {
cols.forEach((c, ci) => {
c.innerHTML = [0, 1, 2]
.map(
(i) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${ASPECTS[(i + ci * 2) % ASPECTS.length]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('');
fbody.innerHTML = `<div class="masonry">${[0, 1]
.map(
(ci) =>
`<div class="mcol">${[0, 1, 2]
.map(
(i) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${[1.2, 1, 1.35][(i + ci) % 3]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('')}</div>`,
)
.join('')}</div>`;
}
/* ---------- items ---------- */
function withDistance(list) {
return list.map((s) => {
if (!userLoc || !Number.isFinite(s.lat)) return s;
const d = Number.isFinite(s.distanceKm)
? s.distanceKm
: distKm(userLoc, s);
return { ...s, distanceKm: d };
});
}
function cardHtml(s) {
const m = s.metrics?.[0];
const ar = aspectFor(s);
const fam = FAM_NAME[s.family] || s.family;
return `<article class="card ${s.live ? 'is-live' : isReconstruction(s) ? 'is-recon' : s.stale || (!isAlwaysOn(s) && s.section !== 'always') ? 'is-stale' : 'is-evergreen'}" data-id="${esc(s.id)}" data-family="${esc(s.family)}" aria-labelledby="ct-${esc(s.id)}">
<button class="card-hit" type="button" aria-label="Open ${esc(s.title)}, ${esc(s.place || '')}"></button>
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img src="${esc(s.thumbnail || '')}" alt="" loading="lazy" decoding="async" fetchpriority="low" />
<div class="card-badges">
<span class="fam-tag glass-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)}</span>
${s.live ? '<span class="live-pill"><span class="live-dot"></span>LIVE</span>' : isReconstruction(s) ? `<span class="live-pill recon">${icon('threads')}4D</span>` : ''}
</div>
${m ? `<div class="card-metric"><b class="num">${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}</b>${m.unit ? `<small>${esc(m.unit)}</small>` : ''}<span>${esc(m.label)}</span></div>` : ''}
</div>
<div class="card-body">
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place">${icon('pin')}<span>${esc(s.place || '')}</span></div>
<div class="card-foot"><span class="src">${esc(shortSource(s.source))}</span><span class="ago num" data-ts="${esc(s.updatedAt || '')}">${esc(ago(s.updatedAt))}</span></div>
</div>
</article>`;
function situationItems() {
const sits = withDistance(all);
const items = sits.map((s, i) => ({
key: `s:${s.id}`,
type: 'situation',
s,
archetype: archetypeOf(s),
band: bandOf(s),
prio: (s.severity || 0) * 10 - i * 0.6 + (s.live ? 2 : 0),
}));
// The street is the most Citizen-like card: one live eye right after
// the two most severe situations.
const ranked = [...items].sort((a, b) => b.prio - a.prio);
const eye = ranked.find((x) => x.archetype === 'eye');
if (eye && ranked[1] && ranked.indexOf(eye) > 2)
eye.prio = ranked[1].prio - 0.1;
return items;
}
function shortSource(src = '') {
return String(src).split(/[·(]/)[0].trim() || 'Live feed';
function liveWitness() {
return rt.lives().map((l) => ({
id: `live:${l.id}`,
liveId: l.id,
type: 'live',
liveNow: true,
sealed: true,
at: l.startedAt,
startedAt: l.startedAt,
handle: l.handle,
viewers: l.viewers || 0,
frame: l.frame,
title: `${l.handle} is live${l.situationTitle ? ` near ${l.situationTitle}` : ''}`,
sub: `${l.segments || 0} sealed segments · heading ${Math.round(l.headingDeg || 0)}°`,
fix: Number.isFinite(l.lat) ? { lat: l.lat, lon: l.lon } : null,
acc: l.acc,
}));
}
function filtered() {
if (section === 'recon') return all.filter(isReconstruction);
if (section === 'always') return all.filter(isAlwaysOn);
if (section === 'near') {
const at = userLoc || { lon: -122.42, lat: 37.77 };
return [...all]
.filter((s) => Number.isFinite(s.lon))
.sort((a, b) => distKm(at, a) - distKm(at, b))
.slice(0, 8);
function buildItems() {
let items = situationItems();
const lives = liveWitness();
const wit = [...lives, ...witness.slice(0, 4)].map((w) => ({
key: `w:${w.id}`,
type: 'witness',
w,
archetype: 'witness',
band: 'inside',
prio: w.liveNow ? 100 : Date.now() - w.at < 6 * 3600e3 ? 30 : 0,
}));
const roomItems = hot.map((r, i) => ({
key: `r:${r.id}`,
type: 'room',
room: r,
watching: rt.presence(r.id).length,
archetype: 'room',
band: 'inside',
prio: 40 - i * 12,
}));
if (!witness.length && !lives.length) {
const nearest = userLoc
? [...withDistance(all)]
.filter((s) => Number.isFinite(s.distanceKm))
.sort((a, b) => a.distanceKm - b.distanceKm)[0]
: null;
wit.push({
key: 'prompt',
type: 'prompt',
archetype: 'prompt',
band: 'inside',
prio: 28,
p: nearest
? {
title: `You’re ${fmtKm(nearest.distanceKm)} from ${nearest.title}`,
text:
nearest.distanceKm <= presenceRadiusKm(nearest)
? 'A sealed photo from where you stand is the strongest post its room can get.'
: 'Your phone is a sensor too. Seal what you see and it joins the room for the nearest situation.',
tele: `INSIDE · YOU · ${fmtKm(nearest.distanceKm).toUpperCase()} · ${utc(new Date(), { seconds: false })}`,
}
: {
title: 'Be the sensor',
text: 'Seal a photo, go live or upload. Your phone becomes an eye for the God’s eye, with its motion and position signed in.',
tele: `INSIDE · YOU · ${utc(new Date(), { seconds: false })}`,
},
});
}
return all
.filter((s) => !isReconstruction(s) && !isAlwaysOn(s))
.sort(
(a, b) =>
(b.severity ?? 0) - (a.severity ?? 0) ||
new Date(b.updatedAt) - new Date(a.updatedAt),
);
if (near) {
items = items
.filter((x) => Number.isFinite(x.s.distanceKm))
.sort((a, b) => a.s.distanceKm - b.s.distanceKm)
.slice(0, 14)
.map((x, i) => ({ ...x, prio: 100 - i * 5 }));
}
let list = [...items, ...roomItems, ...wit];
if (band !== 'all') list = list.filter((x) => x.band === band);
list.sort((a, b) => b.prio - a.prio);
return near ? list : diversify(list);
}
/* ---------- layout ---------- */
function layout(list) {
const W = fbody.clientWidth || view.clientWidth || 400;
const colW = (W - 16 - GAP) / 2;
const blocks = [];
let cols = [[], []];
let h = [0, 0];
const pending = [];
const canFlush = () => {
const d = Math.abs(h[0] - h[1]);
if (d < 4) return true;
const short = h[0] < h[1] ? 0 : 1;
const last = cols[short].at(-1);
return !!last && d < last.ar * colW * 0.55;
};
const flush = () => {
if (!cols[0].length && !cols[1].length) return;
const d = Math.abs(h[0] - h[1]);
const short = h[0] < h[1] ? 0 : 1;
const last = cols[short].at(-1);
if (last && d >= 4) last.ar = (last.ar * colW + d) / colW;
blocks.push({ kind: 'mason', cols });
cols = [[], []];
h = [0, 0];
};
for (const it of list) {
if (WIDE.has(it.archetype)) {
if (canFlush()) {
flush();
blocks.push({ kind: 'wide', it });
} else pending.push(it);
continue;
}
const c = h[0] <= h[1] ? 0 : 1;
const ar = aspect(it);
const cell = { it, ar };
cols[c].push(cell);
h[c] += ar * colW + (BODY_H[it.archetype] || 84) + GAP;
while (pending.length && canFlush()) {
flush();
blocks.push({ kind: 'wide', it: pending.shift() });
}
}
// A lonely trailing card would leave a hole: let the last wide row go first.
if (pending.length) {
flush();
for (const it of pending) blocks.push({ kind: 'wide', it });
} else if (cols[0].length + cols[1].length)
blocks.push({ kind: 'mason', cols });
return blocks;
}
function render() {
const list = filtered();
const list = buildItems();
itemsByKey.clear();
list.forEach((it) => itemsByKey.set(it.key, it));
const blocks = layout(list);
const keyed = (it, ar) =>
cardHtml(it, ar).replace(
'<article ',
`<article data-key="${esc(it.key)}" `,
);
fbody.innerHTML = blocks
.map((b) =>
b.kind === 'wide'
? `<div class="wide-row">${keyed(b.it, 0.5625)}</div>`
: `<div class="masonry">${b.cols.map((c) => `<div class="mcol">${c.map((cell) => keyed(cell.it, cell.ar)).join('')}</div>`).join('')}</div>`,
)
.join('');
cardEls.clear();
const heights = [0, 0];
const parts = [[], []];
for (const s of list) {
const col = heights[0] <= heights[1] ? 0 : 1;
parts[col].push(cardHtml(s));
heights[col] += aspectFor(s) + 0.42;
}
cols.forEach((c, i) => (c.innerHTML = parts[i].join('')));
$$('.card[data-id]', masonry).forEach((el) => {
$$('.card[data-id]', fbody).forEach((el) => {
cardEls.set(el.dataset.id, el);
el.classList.toggle('is-selected', el.dataset.id === selectedId);
});
masonry.setAttribute('aria-busy', 'false');
fbody.setAttribute('aria-busy', 'false');
$('.feed-end', view).hidden = !list.length;
if (!list.length)
fbody.innerHTML = `<div class="feed-empty tele">Nothing in this band right now${near ? ' near you' : ''}.</div>`;
// counts
const sit = situationItems();
const counts = { all: sit.length + hot.length + witness.length };
for (const b of BANDS)
counts[b.id] = sit.filter((x) => x.band === b.id).length;
counts.inside += hot.length + witness.length;
for (const [k, v] of Object.entries(counts)) {
const el = $(`[data-count="${k}"]`, bandbar);
if (el) el.textContent = String(v);
}
const live = all.filter((s) => s.live).length;
count.textContent = `${live} live · ${all.length} situations · ${new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}`;
if (section === 'near' && !userLoc) {
const eyes = all.filter((s) => archetypeOf(s) === 'eye').length;
count.textContent = `${live} live · ${eyes} eyes open · ${hot.reduce((a, r) => a + r.posts.length, 0)} posts in ${hot.length} hot rooms`;
if (near && !userLoc) {
note.hidden = false;
note.innerHTML = `<div class="near-prompt glass">${icon('location')}<div><b>See what’s around you</b><span>Sorted from San Francisco until you share your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
note.innerHTML = `<div class="near-prompt">${icon('location')}<div><b>See what’s around you</b><span>Distances and the Near lens need your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
} else note.hidden = true;
// Images fade in when decoded.
$$('img', masonry).forEach((img) => {
if (img.complete) img.classList.add('loaded');
else
img.addEventListener('load', () => img.classList.add('loaded'), {
once: true,
});
hydrate();
}
/* ---------- images: composited thumbnails, live eyes ---------- */
const thumbIO = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
thumbIO.unobserve(e.target);
loadThumb(e.target);
}
},
{ root: view, rootMargin: '700px 0px' },
);
const visibleEyes = new Set();
const eyeIO = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visibleEyes.add(e.target);
else visibleEyes.delete(e.target);
}
},
{ root: view, threshold: 0.15 },
);
function markLoaded(img) {
const done = () => img.classList.add('loaded');
if (img.complete && img.naturalWidth) done();
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', () => img.classList.add('failed'), {
once: true,
});
});
}
}
function placeInk() {
const btn = $(`[data-sec="${section}"]`, tabs);
if (!btn) return;
ink.style.width = `${btn.offsetWidth - 20}px`;
ink.style.transform = `translateX(${btn.offsetLeft + 10}px)`;
async function loadThumb(img) {
const card = img.closest('.card');
const it = itemsByKey.get(card?.dataset.key);
const s = it?.s;
if (!s) return;
const media = img.closest('.card-media');
const r = media.getBoundingClientRect();
const w = 360;
const hgt =
Math.round((w * (r.height / Math.max(1, r.width))) / 40) * 40 || 480;
// The plain still shows at once; the composited frame replaces it.
if (s.thumbnail && !img.getAttribute('src')) {
img.src = s.thumbnail;
img.classList.add('still');
markLoaded(img);
}
let url = '';
try {
url = await globe.thumbnail(s, { width: w, height: hgt, dpr: 2 });
} catch {
url = '';
}
if (!img.isConnected || !url || url === img.getAttribute('src')) return;
const pre = new Image();
pre.decoding = 'async';
pre.onload = () => {
if (!img.isConnected) return;
img.src = url;
img.classList.remove('still');
img.classList.add('loaded', 'composited');
};
pre.src = url;
}
function hydrate() {
$$('img[data-thumb]', fbody).forEach((img) => thumbIO.observe(img));
$$('.c-eye', fbody).forEach((card) => {
eyeIO.observe(card);
card._next = Date.now() + Number(card.dataset.refresh || 15000);
const img = $('img[data-eye]', card);
markLoaded(img);
img.addEventListener(
'error',
() => {
const it = itemsByKey.get(card.dataset.key);
if (it?.s?.thumbnail && !img.dataset.fellBack) {
img.dataset.fellBack = '1';
img.src = it.s.thumbnail;
card.classList.add('eye-offline');
}
},
{ once: true },
);
});
$$('.c-witness img, .c-witness video', fbody).forEach((m) => {
if (m.tagName === 'VIDEO') m.classList.add('loaded');
else markLoaded(m);
});
tickClocks();
}
function freshFrame(card) {
const id = card.dataset.cam;
const fromEngine = id ? globe.eyeFrameUrl(id) : null;
if (fromEngine) return fromEngine;
const cur = $('img[data-eye]', card).src;
try {
const u = new URL(cur, location.href);
u.searchParams.set('ts', String(Math.floor(Date.now() / 1000)));
return u.pathname + u.search;
} catch {
return cur;
}
}
// Auto-refresh camera frames while on screen; cross-fade between buffers.
setInterval(() => {
if (view.hidden || document.hidden) return;
const now = Date.now();
for (const card of visibleEyes) {
if (card.classList.contains('eye-offline') || now < (card._next || 0))
continue;
card._next = now + Number(card.dataset.refresh || 15000);
const a = $('img[data-eye]', card);
const b = $('img.eye-b', card);
b.onload = () => {
b.classList.add('loaded', 'front');
a.classList.remove('front');
// swap roles
a.removeAttribute('data-eye');
a.classList.add('eye-b');
b.classList.remove('eye-b');
b.setAttribute('data-eye', '');
card.classList.remove('flash');
void card.offsetWidth;
card.classList.add('flash');
};
b.src = freshFrame(card);
}
}, 1000);
/* ---------- load ---------- */
async function refreshSocial() {
const vis = all.filter((s) => s.kind !== 'reconstruction');
hot = vis.length
? (await rooms.hot(vis, 3).catch(() => [])).filter(Boolean)
: [];
witness = await localWitness({ rooms, situations: all }).catch(() => []);
}
async function load({ quiet = false } = {}) {
@@ -191,49 +543,74 @@ export function createFeed(
try {
const prev = new Map(all.map((s) => [s.id, JSON.stringify(s.metrics)]));
all = await getSituations();
await refreshSocial();
render();
if (quiet)
for (const s of all)
if (prev.has(s.id) && prev.get(s.id) !== JSON.stringify(s.metrics))
cardEls.get(s.id)?.classList.add('pulse');
cardEls.get(String(s.id))?.classList.add('pulse');
} catch (err) {
console.error(err);
count.textContent = 'Could not reach the live feeds';
}
}
tabs.addEventListener('click', (e) => {
const b = e.target.closest('[data-sec]');
if (!b || b.dataset.sec === section) return;
section = b.dataset.sec;
$$('[data-sec]', tabs).forEach((x) =>
async function toggleNear(nb) {
near = !near;
nb.setAttribute('aria-pressed', String(near));
view.classList.toggle('is-near', near);
haptic('tick');
if (near && !userLoc) {
const loc = await onLocate();
if (loc) setLocation(loc);
}
view.scrollTo({ top: 0 });
swap();
}
bandbar.addEventListener('click', async (e) => {
const b = e.target.closest('[data-band]');
if (!b || b.dataset.band === band) return;
band = b.dataset.band;
$$('[data-band]', bandbar).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
bandbar.dataset.sel = band;
haptic('tick');
placeInk();
b.scrollIntoView({
inline: 'nearest',
block: 'nearest',
behavior: 'smooth',
behavior: reducedMotion() ? 'auto' : 'smooth',
});
masonry.classList.remove('swap');
void masonry.offsetWidth;
masonry.classList.add('swap');
render();
swap();
});
function swap() {
fbody.classList.remove('swap');
void fbody.offsetWidth;
fbody.classList.add('swap');
render();
}
view.addEventListener('click', async (e) => {
const hit = e.target.closest('.card-hit');
if (hit) {
const card = hit.closest('.card');
const s = all.find((x) => String(x.id) === card.dataset.id);
if (s) onOpen(s, card);
const hitEl = e.target.closest('.card-hit');
if (hitEl) {
const card = hitEl.closest('.card');
const it = itemsByKey.get(card.dataset.key);
if (!it) return;
haptic('light');
if (it.type === 'situation') onOpen(it.s, card);
else if (it.type === 'room') onOpenRoom(it.room.situation, card);
else if (it.type === 'witness') onWitness(it.w, card);
else if (it.type === 'prompt') onCapture();
return;
}
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'near') return toggleNear(e.target.closest('[data-act]'));
if (act === 'refresh') refresh();
if (act === 'locate') {
userLoc = await onLocate();
const loc = await onLocate();
if (loc) setLocation(loc);
render();
}
});
@@ -300,8 +677,7 @@ export function createFeed(
);
function setPull(d, animate = false) {
const t = animate ? 'transform 420ms var(--spring)' : 'none';
inner.style.transition = t;
inner.style.transition = animate ? 'transform 420ms var(--spring)' : 'none';
inner.style.transform = d ? `translate3d(0, ${d}px, 0)` : '';
ptr.style.transition = animate ? 'opacity 200ms linear' : 'none';
ptr.style.opacity = String(clamp(d / THRESH, 0, 1));
@@ -324,20 +700,74 @@ export function createFeed(
}, wait);
}
// Keep "2 min ago" honest.
// Keep relative times honest.
setInterval(() => {
$$('.ago[data-ts]', view).forEach((el) => {
if (el.dataset.ts)
el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts);
});
if (!view.hidden && !document.hidden) {
$$('.ago[data-ts]', view).forEach((el) => {
if (el.dataset.ts)
el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts);
});
}
}, 30000);
new ResizeObserver(placeInk).observe(tabs);
requestAnimationFrame(placeInk);
// Social changes re-render quietly (posts from other tabs, live broadcasts).
let socialTimer = 0;
const socialChanged = () => {
clearTimeout(socialTimer);
socialTimer = setTimeout(async () => {
await refreshSocial();
if (!view.hidden) {
const y = view.scrollTop;
render();
view.scrollTop = y;
}
}, 250);
};
rooms.on('post', socialChanged);
let liveKey = '';
rt.on('live', (l) => {
const card = $(`[data-key="w:live:${CSS.escape(l.id)}"]`, fbody);
if (card) {
const img = $('img', card);
if (img && l.frame) img.src = l.frame;
const v = $('[data-viewers]', card);
if (v) v.textContent = String(l.viewers || 0);
if (!view.hidden) rt.watch(l.id);
}
const key = rt
.lives()
.map((x) => x.id)
.join(',');
if (key !== liveKey) {
liveKey = key;
socialChanged();
}
});
rt.on('live-end', () => {
liveKey = rt
.lives()
.map((x) => x.id)
.join(',');
socialChanged();
});
rt.on('presence', () => {
for (const r of hot) {
const el = $(`[data-room-watch="${CSS.escape(r.id)}"]`, fbody);
const n = rt.presence(r.id).length;
if (el) el.innerHTML = n > 1 ? `<i></i>${n} here` : '';
}
});
function setLocation(loc) {
userLoc = loc;
if (loc) globe.setViewer?.({ lon: loc.lon, lat: loc.lat });
}
return {
load,
refresh,
render,
refreshSocial: socialChanged,
cardFor: (id) => cardEls.get(String(id)) || null,
situations: () => all,
select(id) {
@@ -347,8 +777,11 @@ export function createFeed(
);
},
setLocation(loc) {
userLoc = loc;
if (section === 'near') render();
setLocation(loc);
render();
},
get location() {
return userLoc;
},
};
}