From a6f98ce3c10a2a7eab8b22bc72b0cf281106c529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:29:14 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH --- mobile/styles/social.css | 3110 +++++++++++++++++++++++++++++++++++++ mobile/ui/attach.js | 37 +- mobile/ui/capture.js | 87 +- mobile/ui/chatSim.js | 1089 +++++++++++++ mobile/ui/clerk.js | 185 +++ mobile/ui/composer.js | 16 +- mobile/ui/crews.js | 617 ++++++++ mobile/ui/emotes.js | 126 ++ mobile/ui/liveChat.js | 1120 +++++++++++++ mobile/ui/needEyes.js | 578 +++++++ mobile/ui/realtime.js | 9 + mobile/ui/record.js | 457 ++++++ mobile/ui/room.js | 515 +++--- mobile/ui/rooms.js | 114 +- mobile/ui/roomsTab.js | 217 +++ mobile/ui/signal.js | 184 +++ mobile/ui/social-dev.css | 77 + mobile/ui/social-dev.html | 63 + mobile/ui/social-dev.js | 155 ++ mobile/ui/social.js | 651 ++++++++ mobile/ui/socialBridge.js | 33 + mobile/ui/you.js | 177 ++- 22 files changed, 9247 insertions(+), 370 deletions(-) create mode 100644 mobile/styles/social.css create mode 100644 mobile/ui/chatSim.js create mode 100644 mobile/ui/clerk.js create mode 100644 mobile/ui/crews.js create mode 100644 mobile/ui/emotes.js create mode 100644 mobile/ui/liveChat.js create mode 100644 mobile/ui/needEyes.js create mode 100644 mobile/ui/record.js create mode 100644 mobile/ui/roomsTab.js create mode 100644 mobile/ui/signal.js create mode 100644 mobile/ui/social-dev.css create mode 100644 mobile/ui/social-dev.html create mode 100644 mobile/ui/social-dev.js create mode 100644 mobile/ui/social.js create mode 100644 mobile/ui/socialBridge.js diff --git a/mobile/styles/social.css b/mobile/styles/social.css new file mode 100644 index 000000000..c6a883a3f --- /dev/null +++ b/mobile/styles/social.css @@ -0,0 +1,3110 @@ +/* + * Social layer, round three (DESIGN.md §12.2, §12.6): Live and Record lanes, + * the heart stream, Need eyes, Signal, crews, the Rooms tab and You. + * Loaded by mobile/ui/social.js (no index.html change needed). + */ + +:root { + --heart: #ff4f73; + --live-red: #ff3b3b; + --tier-new: #a7b0bb; +} + +/* ------------------------------------------------------------------ */ +/* Room sheet */ +/* ------------------------------------------------------------------ */ + +.room-sheet .sheet-head { + padding-top: 2px; + padding-right: 56px; +} + +.rs-top { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.5625rem; + color: var(--ink-3); + white-space: nowrap; + overflow: hidden; +} + +.rs-top .bd { + width: 5px; + height: 5px; + border-radius: 1px; + background: var(--band); +} + +.rs-top > span:first-of-type { + color: var(--band); +} + +.rs-live { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink); +} + +.rs-live i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--live-red); + box-shadow: 0 0 0 0 rgba(255, 59, 59, 0.6); + animation: soc-pulse 1.6s ease-out infinite; +} + +.rs-place { + overflow: hidden; + text-overflow: ellipsis; +} + +.room-sheet .sheet-title { + margin: 4px 0 2px; + font-size: 1.25rem; + line-height: 1.2; + letter-spacing: -0.02em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rs-crew { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 4px 0 2px; + padding: 4px 10px 4px 8px; + border-radius: 999px; + font-size: 0.75rem; + color: var(--ink-2); + background: color-mix(in srgb, var(--hue) 16%, transparent); + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--hue) 50%, transparent); +} + +.rs-crew .i { + color: var(--hue); +} + +.rs-crew .badge-example { + height: 15px; + font-size: 0.5rem; +} + +.rs-ill { + margin-top: 2px; + font-size: 0.5rem; + color: var(--eyes); +} + +@keyframes soc-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 59, 59, 0.55); + } + 100% { + box-shadow: 0 0 0 7px rgba(255, 59, 59, 0); + } +} + +/* Lane switch: Live | Record */ +.lane-switch { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + margin: 10px 0 8px; + padding: 2px; + border-radius: 10px; + background: rgba(118, 128, 150, 0.18); + isolation: isolate; +} + +.lane-switch button { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 650; + color: var(--ink-2); + transition: color var(--dur-2); +} + +.lane-switch button[aria-selected='true'] { + color: var(--ink); +} + +.lane-switch .i { + font-size: 0.95rem; +} + +.ls-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--live-red); + animation: soc-pulse 1.6s ease-out infinite; +} + +.ls-n { + font-size: 0.5625rem; + color: var(--ink-3); +} + +.ls-thumb { + position: absolute; + z-index: 0; + top: 2px; + bottom: 2px; + left: 2px; + width: calc(50% - 2px); + border-radius: 8px; + background: rgba(255, 255, 255, 0.16); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + transition: transform 420ms var(--spring); +} + +.lane-switch[data-lane='record'] .ls-thumb { + transform: translateX(100%); +} + +/* The lanes fill the sheet body. */ +.sheet-body[data-lane='live'] { + padding: 0 !important; + overflow: hidden !important; +} + +.sheet-body[data-lane='record'] { + padding-bottom: calc(var(--safe-b) + 110px) !important; +} + +.room-panel { + min-height: 100%; +} + +.room-panel[data-lane='live'] { + height: 100%; + display: flex; + flex-direction: column; +} + +.room-panel > .lane-switch { + margin: 4px 16px 8px; +} + +.brief-sheet .room-panel[data-lane='record'] > .lane-switch { + margin: 0 0 10px; +} + +/* ------------------------------------------------------------------ */ +/* Live lane */ +/* ------------------------------------------------------------------ */ + +.lc { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + /* The bottom of the sheet can be off-screen at the medium detent. */ + padding-bottom: calc(var(--off, 0px) + 152px + var(--safe-b)); +} + +.lc-pins { + flex: none; + display: flex; + flex-direction: column; + gap: 6px; + padding: 2px 14px 6px; +} + +.lc-first, +.lc-need { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 5px 10px; + border-radius: 8px; + font-size: 0.75rem; + line-height: 1.3; + color: var(--ink-2); + text-align: left; + background: linear-gradient( + 90deg, + rgba(235, 199, 119, 0.14), + rgba(235, 199, 119, 0.04) + ); + box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.35); + --seal-ink: #2a1a00; +} + +.lc-first .i { + flex: none; + font-size: 1rem; + color: var(--b-inside); +} + +.lc-first b { + color: var(--b-inside); + font-weight: 700; +} + +.lc-first span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lc-first.none { + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.lc-first.none .i, +.lc-first.none b { + color: var(--ink-2); +} + +.lc-first .badge-example { + height: 15px; + font-size: 0.5rem; +} + +.lc-need { + background: linear-gradient( + 90deg, + rgba(163, 216, 255, 0.16), + rgba(163, 216, 255, 0.04) + ); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.4); +} + +.lc-need .i { + flex: none; + font-size: 1rem; + color: var(--b-sky); +} + +.lc-need span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lc-need b { + color: var(--b-sky); +} + +.lc-need em { + flex: none; + font-style: normal; + font-size: 0.5rem; + color: var(--ink-3); +} + +.lc-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 0 8px; + -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 22px); + mask-image: linear-gradient(to bottom, transparent 0, #000 22px); + scrollbar-width: none; +} + +.lc-list::-webkit-scrollbar { + display: none; +} + +/* One chat line, Twitch-dense. */ +.cl { + position: relative; + padding: 3px 14px 3px 14px; + font-size: 0.875rem; + line-height: 1.42; + color: var(--ink); + overflow-wrap: anywhere; + cursor: default; + -webkit-tap-highlight-color: transparent; +} + +.cl.in { + animation: cl-in 260ms var(--ease-out); +} + +/* Transform only: a line must never sit invisible if the tab is throttled. */ +@keyframes cl-in { + from { + transform: translateY(8px); + } +} + +.cl:hover { + background: rgba(255, 255, 255, 0.035); +} + +.cl.open { + background: rgba(255, 255, 255, 0.06); +} + +.cl-n { + display: none; +} + +.cl-h { + font-weight: 750; + color: var(--tc, var(--tier-new)); +} + +.cl-c { + color: var(--ink-3); + margin-left: -1px; +} + +.cl-t { + color: rgba(244, 247, 251, 0.94); +} + +.cl .mention { + padding: 0 3px; + border-radius: 3px; + font-weight: 650; + color: var(--ink); + background: rgba(255, 255, 255, 0.1); +} + +.cl .mention.me { + color: #1b0f00; + background: var(--eyes); +} + +.cl.mentions-me { + background: rgba(255, 209, 102, 0.1); + box-shadow: inset 3px 0 0 var(--eyes); +} + +.cl.is-me .cl-h::after { + content: ' (you)'; + font-weight: 500; + color: var(--ink-3); +} + +.cl.is-remote { + background: rgba(124, 196, 255, 0.07); + box-shadow: inset 3px 0 0 var(--sky); +} + +/* Witness glow: a sealed capture nearby. */ +.cl.is-witness .cl-h { + text-shadow: + 0 0 10px rgba(235, 199, 119, 0.75), + 0 0 2px rgba(235, 199, 119, 0.6); +} + +.cl.has-ev { + background: linear-gradient(90deg, rgba(235, 199, 119, 0.1), transparent 70%); +} + +.cl.is-promoted { + box-shadow: inset 3px 0 0 var(--b-inside); +} + +.emote { + display: inline-block; + width: 1.55em; + height: 1.55em; + margin: -0.35em 1px -0.3em; + vertical-align: middle; +} + +.emote svg { + width: 100%; + height: 100%; +} + +.cb { + display: inline-flex; + align-items: center; + justify-content: center; + height: 16px; + min-width: 16px; + margin-right: 3px; + padding: 0 2px; + border-radius: 3px; + vertical-align: -2px; + font-size: 0.7rem; +} + +.cb .i { + width: 11px; + height: 11px; +} + +.cb-mod { + color: #06210f; + background: #34c759; +} + +.cb-first { + color: #1d1406; + background: var(--b-inside); +} + +.cb-wit { + color: var(--b-inside); + --seal-ink: #2a1a00; + background: rgba(235, 199, 119, 0.16); + box-shadow: 0 0 8px rgba(235, 199, 119, 0.5); +} + +.cb-dom { + font-size: 0.5rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.1); + letter-spacing: 0.03em; +} + +.cl-ev, +.cl-corro, +.cl-rec, +.cl-real { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 6px; + padding: 0 5px; + height: 16px; + border-radius: 3px; + vertical-align: 1px; + font-size: 0.5rem; +} + +.cl-ev { + color: var(--b-inside); + --seal-ink: #2a1a00; + box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.55); +} + +.cl-ev .i, +.cl-corro .i, +.cl-rec .i { + width: 9px; + height: 9px; +} + +.cl-corro { + color: var(--ok); + background: rgba(48, 209, 88, 0.1); +} + +.cl-corro.bump { + animation: bump 420ms var(--spring); +} + +@keyframes bump { + 40% { + transform: scale(1.3); + } +} + +.cl-rec { + color: #1d1406; + background: var(--b-inside); +} + +.cl-real { + color: var(--sky); + box-shadow: inset 0 0 0 0.5px rgba(124, 196, 255, 0.5); +} + +.cl-sys { + display: flex; + align-items: center; + gap: 7px; + margin: 4px 12px; + padding: 6px 10px; + border-radius: 8px; + font-size: 0.75rem; + line-height: 1.3; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.05); +} + +.cl-sys > .i { + flex: none; + color: var(--b-inside); +} + +.cl-sys.is-clerk > .i { + color: var(--b-orbit); +} + +.cl-sys span { + flex: 1; +} + +.cl-go { + flex: none; + height: 22px; + padding: 0 8px; + border-radius: 999px; + color: var(--ink); + background: rgba(255, 255, 255, 0.12); +} + +.cl-acts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 10px 6px; + padding: 6px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + animation: cl-in 200ms var(--ease-out); +} + +.cl-acts button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 650; + background: rgba(255, 255, 255, 0.1); +} + +.cl-acts button[aria-pressed='true'] { + color: #05210e; + background: var(--ok); +} + +.cl-acts button.can { + color: #1d1406; + background: var(--b-inside); +} + +.cl-acts button:disabled { + opacity: 0.45; +} + +.cl-who { + flex-basis: 100%; + padding: 0 4px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.lc-more { + position: absolute; + z-index: 2; + left: 50%; + bottom: calc(var(--off, 0px) + 160px + var(--safe-b)); + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 12px; + border-radius: 999px; + font-size: 0.5625rem; + color: #05070b; + background: var(--ink); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); +} + +.lc-more[hidden] { + display: none; +} + +/* Floating reactions */ +.lc-float, +.co-float { + position: absolute; + z-index: 3; + right: 10px; + bottom: calc(var(--off, 0px) + 150px + var(--safe-b)); + width: 90px; + height: 0; + pointer-events: none; +} + +.rf { + position: absolute; + right: var(--x, 20px); + bottom: 0; + width: 38px; + height: 38px; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35)); + will-change: transform, opacity; +} + +.rf.sm { + width: 24px; + height: 24px; + opacity: 0.85; +} + +.rf svg { + width: 100%; + height: 100%; +} + +.lc.bursting .lc-list { + animation: burst-glow 1.2s ease-out; +} + +@keyframes burst-glow { + 0% { + box-shadow: inset 0 0 0 0 rgba(255, 79, 115, 0); + } + 30% { + box-shadow: inset 0 0 40px 0 rgba(255, 79, 115, 0.12); + } + 100% { + box-shadow: inset 0 0 0 0 rgba(255, 79, 115, 0); + } +} + +/* Live dock */ +.lc-dock { + position: relative; + padding: 8px 12px calc(8px + var(--safe-b)); + background: linear-gradient( + to top, + rgba(8, 9, 11, 0.99) 72%, + rgba(8, 9, 11, 0) + ); +} + +.room-sheet .lc-dock, +.brief-sheet .lc-dock { + background: linear-gradient(to top, #08090b 76%, rgba(8, 9, 11, 0)); +} + +.lc-react { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.lr { + position: relative; + display: grid; + place-items: center; + width: 46px; + height: 40px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.07); + transition: transform 160ms var(--ease-out); + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; +} + +.lr svg { + width: 24px; + height: 24px; +} + +.lr:active { + transform: scale(0.88); +} + +.lr .num { + position: absolute; + top: -5px; + right: -4px; + min-width: 18px; + height: 15px; + padding: 0 4px; + border-radius: 8px; + font-size: 0.5625rem; + font-weight: 700; + line-height: 15px; + text-align: center; + color: #fff; + background: rgba(30, 33, 40, 0.95); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.lr .num:empty { + display: none; +} + +.lr[data-react='heart'] { + background: rgba(255, 79, 115, 0.16); +} + +.lc-sp { + flex: 1; +} + +.lc-ask, +.lc-cap { + display: inline-flex; + align-items: center; + gap: 5px; + height: 40px; + padding: 0 12px; + border-radius: 12px; + font-size: 0.8125rem; + font-weight: 650; + background: rgba(163, 216, 255, 0.14); + color: var(--b-sky); +} + +.lc-cap { + width: 44px; + justify-content: center; + padding: 0; + color: #1d1406; + background: var(--b-inside); + font-size: 1.2rem; +} + +.lc-input { + display: flex; + align-items: center; + gap: 6px; + height: 44px; + padding: 0 4px 0 4px; + border-radius: 12px; + background: rgba(118, 128, 150, 0.2); + box-shadow: inset 0 0 0 0.5px var(--hairline); +} + +.lc-input:focus-within { + box-shadow: inset 0 0 0 1.5px rgba(163, 216, 255, 0.55); +} + +.lc-input input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + outline: 0; + font: inherit; + font-size: 1rem; + color: var(--ink); + background: transparent; +} + +.lc-input input::placeholder { + color: var(--ink-3); +} + +.lc-emo { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 10px; +} + +.lc-emo svg { + width: 24px; + height: 24px; +} + +.lc-send { + position: relative; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 10px; + color: #05070b; + background: var(--ink); + font-size: 1.05rem; + overflow: hidden; +} + +.lc-cool { + position: absolute; + inset: 0; + background: rgba(5, 7, 11, 0.55); + transform-origin: left; + transform: scaleX(0); +} + +.lc-send.cooling .lc-cool { + animation: cool var(--cool, 3s) linear forwards; +} + +@keyframes cool { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + +.lc-meta { + display: flex; + align-items: center; + gap: 10px; + margin-top: 7px; + padding: 0 4px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.lc-sim { + padding: 1px 5px; + border-radius: 3px; + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.lc-view { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--ink-2); +} + +.lc-view i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--live-red); +} + +.lc-view b { + color: var(--ink); +} + +.lc-pop { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(100% - 4px); + padding: 8px; + border-radius: 14px; + background: rgba(24, 27, 34, 0.98); + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.5), + inset 0 0 0 0.5px var(--hairline-strong); +} + +.lc-pop[hidden] { + display: none; +} + +.emo-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; +} + +.emo-grid button { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 8px 2px 6px; + border-radius: 10px; + font-size: 0.625rem; + color: var(--ink-2); +} + +.emo-grid button:hover { + background: rgba(255, 255, 255, 0.07); +} + +.emo-grid svg { + width: 30px; + height: 30px; +} + +.men-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.men-list button { + height: 30px; + padding: 0 10px; + border-radius: 999px; + font-weight: 700; + font-size: 0.8125rem; + color: var(--tc); + background: rgba(255, 255, 255, 0.07); +} + +/* ------------------------------------------------------------------ */ +/* Record lane */ +/* ------------------------------------------------------------------ */ + +.rec { + padding-top: 2px; +} + +.room-sheet .sheet-body[data-lane='record'] { + padding-left: 16px; + padding-right: 16px; +} + +.rec-sort { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 10px; +} + +.rec-sort button { + height: 30px; + padding: 0 12px; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 650; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.06); +} + +.rec-sort button[aria-selected='true'] { + color: #05070b; + background: var(--ink); +} + +.rec-sp { + flex: 1; +} + +.rec-n { + font-size: 0.5rem; + color: var(--ink-3); +} + +.rec .example-note { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 0 0 12px; + padding: 9px 11px; + border-radius: 8px; + font-size: 0.75rem; + line-height: 1.45; + color: var(--ink-2); + background: rgba(255, 209, 102, 0.06); +} + +.rec .example-note .badge-example { + flex: none; + height: 16px; + font-size: 0.5rem; +} + +.synth.compact li.more { + display: none; +} + +.sy-more { + margin-top: 4px; + padding: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--tint); +} + +.synth .emote { + width: 1.3em; + height: 1.3em; +} + +.p-wbar.none i { + background: rgba(255, 255, 255, 0.12); +} + +.p-wbar.none i::before { + display: none; +} + +/* The Clerk */ +.clerk { + margin: 0 0 14px; + padding: 12px 14px 10px; + border-radius: 10px; + background: linear-gradient( + 180deg, + rgba(179, 157, 255, 0.1), + rgba(179, 157, 255, 0.03) + ); + box-shadow: inset 0 0 0 0.5px rgba(179, 157, 255, 0.35); +} + +.ck-top { + display: flex; + align-items: center; + gap: 10px; +} + +.ck-av { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 9px; + color: #160b33; + background: var(--b-orbit); + font-size: 1rem; +} + +.ck-who { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.ck-who b { + font-size: 0.9375rem; +} + +.ck-who .tele { + font-size: 0.5rem; + color: var(--ink-3); +} + +.clerk ul { + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.clerk li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + padding: 5px 0 5px 12px; + border-left: 1px solid rgba(179, 157, 255, 0.35); + font-size: 0.8125rem; + line-height: 1.4; + color: var(--ink-2); +} + +.clerk li[data-k='disputed'] { + border-left-color: var(--hazards); +} + +.clerk li[data-k='evidence'] { + border-left-color: var(--b-inside); +} + +.clerk .cite { + min-height: 20px; + padding: 0 5px; + border-radius: 3px; + font-family: var(--mono); + font-size: 0.5625rem; + font-weight: 600; + color: var(--b-orbit); + background: rgba(179, 157, 255, 0.14); +} + +.ck-quotes { + margin-top: 8px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.25); +} + +.ck-q { + display: flex; + gap: 6px; + align-items: baseline; + padding: 3px 4px; + border-radius: 4px; + font-size: 0.75rem; + color: var(--ink-2); +} + +.ck-q.hl { + background: rgba(179, 157, 255, 0.18); + color: var(--ink); +} + +.ck-q .tele { + flex: none; + font-size: 0.5rem; + color: var(--b-orbit); +} + +.ck-q b { + flex: none; +} + +.ck-q .emote { + width: 1.3em; + height: 1.3em; +} + +.linkish { + padding: 0; + font: inherit; + color: var(--tint); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Posts: tiers, weighted votes, quotes, promotions */ +.post .p-who { + color: var(--tc, var(--ink)); +} + +.p-tier { + font-size: 0.5rem; + color: var(--tc, var(--ink-3)); + padding: 1px 4px; + border-radius: 3px; + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--tc) 45%, transparent); +} + +.p-first { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 6px 0 0; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.5625rem; + color: #1d1406; + background: var(--b-inside); + --seal-ink: var(--b-inside); +} + +.p-first .i { + color: #1d1406; +} + +.p-need { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 6px 0 0; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.5625rem; + color: var(--b-sky); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.5); +} + +.post.is-first { + background: linear-gradient( + 90deg, + rgba(235, 199, 119, 0.07), + transparent 60% + ); +} + +.p-text .emote { + width: 1.4em; + height: 1.4em; +} + +.p-quote { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + margin: 8px 0 2px; + padding: 9px 11px; + border-radius: 10px; + text-align: left; + font-size: 0.8125rem; + line-height: 1.38; + color: var(--ink-2); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.p-quote .tele { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.p-promo { + margin: 6px 0 2px; +} + +.p-promo .tele { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 4px; + font-size: 0.5rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.06); +} + +.p-wbar { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 4px; +} + +.p-wbar i { + position: relative; + width: 44px; + height: 4px; + border-radius: 2px; + background: rgba(255, 106, 61, 0.5); + overflow: hidden; +} + +.p-wbar i::before { + content: ''; + position: absolute; + inset: 0; + width: var(--p); + background: var(--ok); +} + +.p-wbar em { + font-style: normal; + font-size: 0.5rem; + color: var(--ink-3); +} + +.badge-sim { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 5px; + font-size: 0.5625rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.badge-ill { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border-radius: 5px; + font-size: 0.5625rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #1b1300; + background: var(--eyes); +} + +/* ------------------------------------------------------------------ */ +/* Need eyes */ +/* ------------------------------------------------------------------ */ + +.need-card { + margin: 0 0 12px; + padding: 12px 14px; + border-radius: 12px; + background: linear-gradient( + 180deg, + rgba(163, 216, 255, 0.1), + rgba(163, 216, 255, 0.03) + ); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.35); +} + +.need-card.is-done { + background: rgba(235, 199, 119, 0.06); + box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.3); +} + +.nc-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.nc-top .i { + color: var(--b-sky); + font-size: 0.85rem; +} + +.nc-top b { + color: var(--b-sky); +} + +.nc-top .badge-example { + margin-left: auto; + height: 15px; + font-size: 0.5rem; +} + +.nc-by { + margin: -4px 0 10px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.nc-q { + margin: 6px 0 8px; + font-size: 1rem; + font-weight: 600; + line-height: 1.35; + letter-spacing: -0.01em; +} + +.nc-acts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.nc-acts .pill-btn.sm { + min-height: 34px; +} + +.nc-reward { + font-size: 0.5625rem; + color: var(--b-inside); +} + +.nc-acts .pill-btn.plain { + margin-left: auto; +} + +.nc-done { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.5625rem; + color: var(--b-inside); + --seal-ink: #2a1a00; +} + +.need-map { + height: 220px; + margin: 4px 0 6px; + border-radius: 12px; + overflow: hidden; +} + +.need-map .minimap { + height: 100%; +} + +.need-sugg { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0 4px; +} + +.nc-done.mine { + color: var(--ink-3); +} + +.need-sugg .chip { + font-weight: 500; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); + height: auto; + min-height: 32px; + padding: 6px 12px; + text-align: left; + white-space: normal; + line-height: 1.3; +} + +.need-shot { + position: relative; + height: 200px; + margin: 4px 0 10px; + border-radius: 12px; + background-size: cover; + background-position: center; + overflow: hidden; +} + +.ns-pin { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -100%); + font-size: 1.8rem; + color: var(--b-sky); + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6)); +} + +.ns-ring { + position: absolute; + left: 50%; + top: 50%; + width: var(--r); + aspect-ratio: 1; + transform: translate(-50%, -50%); + border-radius: 50%; + border: 1.5px solid rgba(163, 216, 255, 0.85); + background: rgba(163, 216, 255, 0.12); +} + +.ns-tele { + position: absolute; + left: 10px; + bottom: 8px; + font-size: 0.5rem; + color: #fff; + text-shadow: 0 1px 3px #000; +} + +.need-why { + margin: 0 0 14px; + padding: 0; + list-style: none; +} + +.need-why li { + display: flex; + gap: 10px; + padding: 7px 0; + font-size: 0.875rem; + line-height: 1.4; + color: var(--ink-2); + border-top: 0.5px solid var(--hairline); + --seal-ink: #2a1a00; +} + +.need-why .i { + flex: none; + margin-top: 2px; + font-size: 1.05rem; + color: var(--b-inside); +} + +.need-why b { + color: var(--ink); +} + +.att-need { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 10px 0 0; + padding: 9px 11px; + border-radius: 10px; + font-size: 0.875rem; + line-height: 1.35; + background: rgba(163, 216, 255, 0.1); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.4); +} + +.att-need .i { + flex: none; + margin-top: 2px; + color: var(--b-sky); +} + +.att-need b { + color: var(--b-sky); +} + +.att-need small { + display: block; + margin-top: 3px; + font-size: 0.5rem; + color: var(--ink-3); +} + +/* Capture: Need eyes near you */ +.cap-needs { + width: min(360px, 100%); + margin-top: 8px; + pointer-events: auto; +} + +.cap-needs[hidden], +.cap[data-mode='live'] .cap-needs { + display: none; +} + +.cn-pill { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 44px; + padding: 6px 12px; + border-radius: 14px; + text-align: left; + color: var(--ink); + box-shadow: + var(--glass-edge), + 0 0 0 1px rgba(163, 216, 255, 0.28); +} + +.cn-pill > .i:first-child { + flex: none; + font-size: 1.2rem; + color: var(--b-sky); +} + +.cn-pill > span { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.cn-pill b { + font-size: 0.8125rem; +} + +.cn-pill small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.6875rem; + color: var(--ink-2); +} + +.cn-list { + margin-top: 6px; + padding: 4px; + border-radius: 14px; +} + +.cn-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + border-radius: 10px; + text-align: left; +} + +.cn-row + .cn-row { + border-top: 0.5px solid var(--hairline); +} + +.cn-row span { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 0.8125rem; + line-height: 1.3; +} + +.cn-row small { + font-size: 0.5rem; + color: var(--ink-3); +} + +.cn-row em { + flex: none; + padding: 6px 10px; + border-radius: 999px; + font-style: normal; + font-size: 0.75rem; + font-weight: 700; + color: #05070b; + background: var(--b-sky); +} + +.cap-target.is-need { + box-shadow: + var(--glass-edge), + 0 0 0 1px rgba(163, 216, 255, 0.5); +} + +/* The byline moment */ +.byline-moment { + position: fixed; + z-index: 200; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(0, 0, 0, 0); + -webkit-backdrop-filter: blur(0); + backdrop-filter: blur(0); + transition: + background 360ms linear, + backdrop-filter 360ms linear; +} + +.byline-moment.show { + background: rgba(0, 0, 0, 0.62); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); +} + +.bm-card { + position: relative; + width: min(380px, 100%); + padding: 28px 22px 18px; + border-radius: 28px; + text-align: center; + background: + radial-gradient( + 120% 70% at 50% -10%, + rgba(235, 199, 119, 0.28), + rgba(235, 199, 119, 0) 60% + ), + #0d0f13; + box-shadow: + inset 0 0.5px 0 rgba(255, 255, 255, 0.25), + inset 0 0 0 0.5px rgba(235, 199, 119, 0.35), + 0 30px 80px rgba(0, 0, 0, 0.6); + transform: translateY(30px) scale(0.92); + opacity: 0; + transition: + transform 560ms var(--spring), + opacity 260ms linear; +} + +.byline-moment.show .bm-card { + transform: none; + opacity: 1; +} + +.bm-seal { + position: relative; + width: 84px; + height: 84px; + margin: 0 auto 12px; + color: var(--b-inside); + --seal-ink: #2a1a00; +} + +.bm-seal .i { + width: 84px; + height: 84px; + filter: drop-shadow(0 0 24px rgba(235, 199, 119, 0.65)); + transform: scale(2.4) rotate(-30deg); + opacity: 0; +} + +.byline-moment.show .bm-seal .i { + animation: stamp 700ms 180ms var(--spring) forwards; +} + +@keyframes stamp { + to { + transform: none; + opacity: 1; + } +} + +.bm-seal i { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 1.5px solid rgba(235, 199, 119, 0.6); + opacity: 0; +} + +.byline-moment.show .bm-seal i { + animation: ring 1.4s 600ms ease-out; +} + +.byline-moment.show .bm-seal i + i { + animation-delay: 850ms; +} + +@keyframes ring { + 0% { + opacity: 0.9; + transform: scale(0.9); + } + 100% { + opacity: 0; + transform: scale(1.9); + } +} + +.bm-kicker { + font-size: 0.5625rem; + color: var(--b-sky); +} + +.bm-card h2 { + margin: 4px 0 6px; + font-family: var(--font-display); + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(180deg, #fff3d6, #ebc777); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.bm-q { + margin: 0 0 10px; + font-size: 0.875rem; + line-height: 1.4; + color: var(--ink-2); +} + +.bm-byline { + min-height: 34px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 0 14px; + padding: 8px 10px; + border-radius: 8px; + font-size: 0.625rem; + color: var(--b-inside); + background: rgba(235, 199, 119, 0.08); + box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.4); +} + +.bm-byline .caret { + width: 6px; + height: 12px; + margin-left: 2px; + background: var(--b-inside); + animation: caret 1s steps(1) infinite; +} + +@keyframes caret { + 50% { + opacity: 0; + } +} + +.bm-rx { + margin: 0 0 14px; + padding: 0; + list-style: none; + text-align: left; +} + +.bm-rx li { + display: flex; + align-items: baseline; + gap: 10px; + padding: 6px 2px; + font-size: 0.8125rem; + line-height: 1.3; + color: var(--ink-2); + border-top: 0.5px solid var(--hairline); + opacity: 0; + transform: translateY(6px); +} + +.byline-moment.show .bm-rx li { + animation: rx-in 380ms calc(900ms + var(--i) * 140ms) var(--ease-out) forwards; +} + +@keyframes rx-in { + to { + opacity: 1; + transform: none; + } +} + +.bm-rx span { + flex: 1; +} + +.bm-rx b, +.ledger .rx-d, +.sig-rules b { + font-weight: 800; + color: var(--ok); +} + +.bm-rx b.neg, +.ledger .rx-d.neg, +.sig-rules b.neg { + color: var(--hazards); +} + +.bm-score { + display: flex; + align-items: baseline; + justify-content: center; + gap: 10px; +} + +.bm-score .tele { + color: var(--ink-3); +} + +.bm-score b { + font-size: 2.4rem; + font-weight: 800; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; +} + +.bm-tier { + font-size: 0.8125rem; + font-weight: 700; + color: var(--tc); +} + +.bm-bar, +.sig-bar { + position: relative; + height: 6px; + margin: 8px 0 6px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.bm-bar i, +.sig-bar i { + position: absolute; + inset: 0 auto 0 0; + width: var(--p); + border-radius: 3px; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--tc) 60%, #fff 0%), + var(--tc) + ); + box-shadow: 0 0 12px var(--tc); +} + +.bm-next { + font-size: 0.5rem; + color: var(--ink-3); +} + +.bm-up { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + padding: 5px 10px; + border-radius: 999px; + font-size: 0.5625rem; + color: #160b33; + background: var(--b-orbit); +} + +.bm-acts { + display: flex; + gap: 8px; + justify-content: center; + margin-top: 16px; +} + +.bm-acts .pill-btn { + flex: 1; +} + +.bm-fine { + margin: 10px 0 0; + font-size: 0.6875rem; + color: var(--ink-4); +} + +/* ------------------------------------------------------------------ */ +/* Moment chat overlay */ +/* ------------------------------------------------------------------ */ + +.co { + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 180px; + height: 100%; + pointer-events: none; +} + +.co-list { + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 1px; + -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 30%); + mask-image: linear-gradient(to bottom, transparent 0, #000 30%); +} + +.co .cl { + padding: 2px 8px; + font-size: 0.8125rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); + transition: opacity 1.2s linear; +} + +.co .cl.old { + opacity: 0.55; +} + +.co .cl:hover { + background: none; +} + +.co-float { + right: 0; + bottom: 52px; +} + +.co-bar { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 0 6px; + pointer-events: auto; +} + +.co-tag { + margin-right: auto; + font-size: 0.5rem; + color: var(--ink-3); +} + +.co-bar button { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 12px; + background: rgba(20, 22, 28, 0.7); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.co-bar button:active { + transform: scale(0.88); +} + +.co-bar svg { + width: 22px; + height: 22px; +} + +/* ------------------------------------------------------------------ */ +/* Crews */ +/* ------------------------------------------------------------------ */ + +.crew-av { + display: grid; + place-items: center; + flex: none; + width: 44px; + height: 44px; + border-radius: 14px; + font-size: 1.35rem; + color: #05070b; + background: + radial-gradient( + 90% 90% at 30% 20%, + rgba(255, 255, 255, 0.55), + transparent 60% + ), + var(--hue); + box-shadow: 0 6px 18px color-mix(in srgb, var(--hue) 35%, transparent); +} + +.crew-av.lg { + width: 64px; + height: 64px; + border-radius: 20px; + font-size: 2rem; +} + +.crew-sheet .sheet-head { + padding-right: 20px; +} + +.crew-hero { + display: flex; + align-items: flex-start; + gap: 14px; + margin: 2px 0 10px; + padding: 14px; + padding-right: 54px; + border-radius: 18px; + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--hue) 22%, transparent), + rgba(255, 255, 255, 0.02) + ); + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--hue) 40%, transparent); +} + +.ch-main { + flex: 1; + min-width: 0; +} + +.ch-area { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.ch-area .badge-example { + height: 14px; + font-size: 0.45rem; +} + +.crew-hero .sheet-title { + margin: 3px 0 4px; + font-size: 1.25rem; + color: var(--ink); +} + +.crew-hero p { + margin: 0 0 8px; + font-size: 0.8125rem; + line-height: 1.35; + color: var(--ink-2); +} + +.ch-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.5625rem; + color: var(--ink-2); +} + +.ch-meta .avs { + display: flex; +} + +.ch-meta .cp-av { + width: 22px; + height: 22px; + margin-right: -6px; + font-size: 0.45rem; + box-shadow: 0 0 0 2px #0a0b0d; +} + +.ch-join { + position: absolute; + top: 56px; + right: 26px; +} + +.crew-tabs { + display: flex; + gap: 4px; + margin: 0 0 8px; + border-bottom: 0.5px solid var(--hairline-strong); +} + +.crew-tabs button { + position: relative; + height: 38px; + padding: 0 12px; + font-size: 0.9375rem; + font-weight: 650; + color: var(--ink-3); +} + +.crew-tabs button[aria-selected='true'] { + color: var(--ink); +} + +.crew-tabs button[aria-selected='true']::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: -0.5px; + height: 3px; + border-radius: 3px 3px 0 0; + background: var(--ink); +} + +.cp-compose { + display: flex; + gap: 8px; + margin: 6px 0 10px; +} + +.cp-compose input { + flex: 1; + min-width: 0; + height: 40px; + padding: 0 12px; + border: 0; + border-radius: 12px; + font: inherit; + color: var(--ink); + background: rgba(118, 128, 150, 0.2); + outline: 0; +} + +.cp-join-hint { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0 10px; + padding: 10px 12px; + border-radius: 12px; + font-size: 0.8125rem; + color: var(--ink-2); + background: rgba(255, 255, 255, 0.04); +} + +.cp { + padding: 12px 0; + border-top: 0.5px solid var(--hairline); +} + +.cp.is-pin { + margin: 0 -10px; + padding: 12px 10px; + border-radius: 12px; + border-top: 0; + background: rgba(255, 255, 255, 0.04); +} + +.cp-head { + display: flex; + align-items: center; + gap: 10px; +} + +.cp-av { + display: grid; + place-items: center; + flex: none; + width: 34px; + height: 34px; + border-radius: 50%; + font-family: var(--mono); + font-size: 0.625rem; + font-weight: 700; + color: #05070b; + background: var(--tc, var(--tier-new)); +} + +.cp-meta { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 8px; +} + +.cp-meta b { + font-size: 0.9375rem; + color: var(--tc); +} + +.cp-mod { + padding: 1px 4px; + border-radius: 3px; + font-size: 0.5rem; + color: #06210f; + background: #34c759; +} + +.cp-tier, +.cp-when { + font-size: 0.5rem; + color: var(--ink-3); +} + +.cp-pin { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.cp p { + margin: 8px 0 6px 44px; + font-size: 0.9375rem; + line-height: 1.45; +} + +.cp .badges { + margin-left: 44px; +} + +.cp-acts { + display: flex; + gap: 16px; + margin-left: 44px; + font-size: 0.5625rem; + color: var(--ink-3); +} + +.cp-acts span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.cp-k { + margin-left: auto; +} + +.cw-card { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 10px; + padding: 14px; + border-radius: 16px; + background: linear-gradient( + 150deg, + color-mix(in srgb, var(--hue) 22%, transparent), + rgba(255, 255, 255, 0.03) + ); + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--hue) 40%, transparent); +} + +.cw-when { + font-size: 0.5rem; + color: var(--hue); +} + +.cw-card b { + font-size: 1.0625rem; +} + +.cw-card > span { + font-size: 0.8125rem; + color: var(--ink-2); +} + +.cw-acts { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.cr-room { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 4px; + text-align: left; + border-top: 0.5px solid var(--hairline); +} + +.cr-room span { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cr-room small { + font-size: 0.5rem; + color: var(--ink-3); +} + +.cr-room .live-dot, +.cr-room > i { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--live-red); +} + +.cm-why { + display: flex; + gap: 8px; + align-items: center; + font-size: 0.8125rem; + color: var(--ink-2); +} + +.cm-row { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 0; + border-top: 0.5px solid var(--hairline); +} + +.cm-row.is-me { + margin: 0 -10px; + padding: 9px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); +} + +.cm-main { + flex: 1; + display: flex; + flex-direction: column; +} + +.cm-main b { + color: var(--tc); +} + +.cm-main small, +.cm-w { + font-size: 0.5rem; + color: var(--ink-3); +} + +.cr-rules { + margin: 6px 0 0; + padding-left: 22px; +} + +.cr-rules li { + padding: 8px 0; + font-size: 0.9375rem; + line-height: 1.45; + border-bottom: 0.5px solid var(--hairline); +} + +/* ------------------------------------------------------------------ */ +/* Rooms tab */ +/* ------------------------------------------------------------------ */ + +.rt-lede { + margin: -4px var(--gutter) 6px; + font-size: 0.9375rem; + line-height: 1.4; + color: var(--ink-2); +} + +.lr-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 var(--gutter); +} + +.lr-row { + display: flex; + gap: 12px; + align-items: stretch; + width: 100%; + padding: 8px; + border-radius: 16px; + text-align: left; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px var(--hairline); + transition: transform var(--dur-1) var(--ease-out); +} + +.lr-row:active { + transform: scale(0.98); +} + +.lr-thumb { + position: relative; + flex: none; + width: 104px; + height: 78px; + border-radius: 10px; + background-color: #10141b; + background-size: cover; + background-position: center; + overflow: hidden; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--band) 35%, transparent); +} + +.lr-thumb::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.35), + transparent 40%, + rgba(0, 0, 0, 0.5) + ); +} + +.lr-live { + position: absolute; + z-index: 1; + top: 5px; + left: 5px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 5px; + border-radius: 3px; + font-size: 0.5rem; + font-weight: 700; + color: #fff; + background: var(--live-red); +} + +.lr-live i { + width: 4px; + height: 4px; + border-radius: 50%; + background: #fff; +} + +.lr-v { + position: absolute; + z-index: 1; + bottom: 5px; + left: 5px; + padding: 1px 5px; + border-radius: 3px; + font-size: 0.5625rem; + color: #fff; + background: rgba(0, 0, 0, 0.6); +} + +.lr-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 1px 2px 0 0; +} + +.lr-main > b { + font-size: 0.9375rem; + line-height: 1.25; + letter-spacing: -0.01em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.lr-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.5rem; + color: var(--ink-3); +} + +.lr-meta > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.heat { + flex: none; + display: inline-flex; + align-items: flex-end; + gap: 1.5px; + height: 12px; +} + +.heat i { + width: 2.5px; + height: calc(var(--h) * 100%); + border-radius: 1px; + background: var(--band); + opacity: 0.85; + animation: heat 1.8s calc(var(--d) * -0.15s) ease-in-out infinite alternate; +} + +@keyframes heat { + to { + transform: scaleY(0.55); + } +} + +.lr-ticker { + height: 18px; + overflow: hidden; + font-size: 0.8125rem; + color: var(--ink-2); + white-space: nowrap; + text-overflow: ellipsis; +} + +.lr-ticker .tk { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.lr-ticker .tk.in { + animation: tick-in 380ms var(--ease-out); +} + +@keyframes tick-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +.lr-ticker .emote { + width: 1.2em; + height: 1.2em; + margin: -0.2em 1px; +} + +.mo-hero { + position: relative; + margin: 0 var(--gutter) 10px; + height: 260px; + border-radius: 20px; + overflow: hidden; + background-size: cover; + background-position: center; + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.35); +} + +.mo-hero::before { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient( + 180deg, + rgba(5, 8, 14, 0.5), + transparent 35%, + rgba(5, 8, 14, 0.92) 78% + ), + linear-gradient(0deg, rgba(120, 170, 220, 0.18), transparent); +} + +.mo-hero .fr { + inset: 10px; +} + +.mo-top { + position: absolute; + top: 12px; + left: 14px; + right: 14px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.5625rem; +} + +.mo-k { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--b-sky); +} + +.mo-real { + color: var(--ok); +} + +.mo-body { + position: absolute; + left: 16px; + right: 16px; + bottom: 14px; +} + +.mo-body h3 { + margin: 0 0 4px; + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 750; + line-height: 1.15; + letter-spacing: -0.02em; +} + +.mo-sub { + font-size: 0.5rem; + color: var(--ink-2); +} + +.mo-acts { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.mo-acts .pill-btn { + min-height: 36px; + padding: 0 14px; +} + +.pill-btn.glass { + color: var(--ink); + background: rgba(255, 255, 255, 0.14); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); +} + +.mo-chips { + display: flex; + gap: 8px; + margin: 0 0 10px; + padding: 0 var(--gutter); + overflow-x: auto; + scrollbar-width: none; +} + +.mo-chips::-webkit-scrollbar { + display: none; +} + +.mo-chip { + flex: none; + display: flex; + align-items: center; + gap: 8px; + max-width: 260px; + padding: 8px 12px; + border-radius: 14px; + text-align: left; + background: rgba(163, 216, 255, 0.08); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.3); +} + +.mo-chip .i { + color: var(--b-sky); +} + +.mo-chip span { + display: flex; + flex-direction: column; + min-width: 0; +} + +.mo-chip b { + font-size: 0.8125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mo-chip small { + font-size: 0.5rem; + color: var(--ink-3); +} + +.mo-clips { + display: flex; + gap: 12px; + align-items: center; + margin: 0 var(--gutter); + padding: 12px 14px; + border-radius: 14px; + border: 1px dashed var(--hairline-strong); + font-size: 0.8125rem; + color: var(--ink-2); +} + +.mo-clips span:last-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.mo-clips b { + color: var(--ink); +} + +.mo-clip-ic { + font-size: 1.2rem; + color: var(--ink-3); +} + +.rooms-tab .need-card { + margin-left: var(--gutter); + margin-right: var(--gutter); +} + +.crew-strip { + display: flex; + gap: 10px; + padding: 0 var(--gutter) 2px; + overflow-x: auto; + scrollbar-width: none; +} + +.crew-strip::-webkit-scrollbar { + display: none; +} + +.crew-card { + flex: none; + display: flex; + flex-direction: column; + gap: 4px; + width: 220px; + padding: 14px; + border-radius: 18px; + text-align: left; + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--hue) 20%, transparent), + rgba(255, 255, 255, 0.03) + ); + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--hue) 40%, transparent); +} + +.crew-card .crew-av { + margin-bottom: 6px; +} + +.crew-card b { + font-size: 1rem; +} + +.crew-card small { + font-size: 0.5rem; + color: var(--ink-3); +} + +.crew-last { + margin-top: 4px; + font-size: 0.75rem; + line-height: 1.35; + color: var(--ink-2); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.crew-last .emote { + width: 1.2em; + height: 1.2em; + margin: -0.2em 1px; +} + +.crew-row { + gap: 12px; +} + +.crew-row .crew-av { + width: 38px; + height: 38px; + border-radius: 12px; + font-size: 1.1rem; +} + +.crew-row .row-sub { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; +} + +/* ------------------------------------------------------------------ */ +/* You: Signal */ +/* ------------------------------------------------------------------ */ + +.sig-card { + position: relative; + margin: 12px var(--gutter) 6px; + padding: 16px 16px 12px; + border-radius: 20px; + background: + radial-gradient( + 110% 80% at 100% 0%, + color-mix(in srgb, var(--tc) 22%, transparent), + transparent 60% + ), + rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px color-mix(in srgb, var(--tc) 40%, transparent); +} + +.sig-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sig-top .tele { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink-3); +} + +.sig-tier { + padding: 4px 10px; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 750; + color: #05070b; + background: var(--tc); +} + +.sig-score { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 4px; +} + +.sig-score b { + font-family: var(--font-display); + font-size: 3rem; + font-weight: 800; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; +} + +.sig-w { + font-size: 0.5625rem; + color: var(--tc); +} + +.sig-bar em { + position: absolute; + top: 0; + bottom: 0; + left: var(--at); + width: 1px; + background: rgba(255, 255, 255, 0.25); +} + +.sig-next { + font-size: 0.5rem; + color: var(--ink-3); +} + +.sig-why { + margin: 10px 0 6px; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--ink-2); +} + +.sig-why b { + color: var(--ink); +} + +.sig-rules summary { + cursor: pointer; + font-size: 0.8125rem; + font-weight: 650; + color: var(--tint); +} + +.sig-rules ul { + margin: 8px 0; + padding: 0; + list-style: none; +} + +.sig-rules li { + display: flex; + gap: 10px; + padding: 5px 0; + font-size: 0.8125rem; + line-height: 1.35; + color: var(--ink-2); + border-top: 0.5px solid var(--hairline); +} + +.sig-rules li b { + flex: none; + width: 34px; + text-align: right; +} + +.sig-rules p { + margin: 4px 0 0; + font-size: 0.75rem; + line-height: 1.6; +} + +.bylines { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 var(--gutter); +} + +.byline { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border-radius: 12px; + text-align: left; + background: rgba(235, 199, 119, 0.08); + box-shadow: inset 0 0 0 0.5px rgba(235, 199, 119, 0.4); + --seal-ink: #2a1a00; +} + +.by-seal { + font-size: 1.6rem; + color: var(--b-inside); +} + +.byline span:last-child { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.byline b { + font-size: 0.5625rem; + color: var(--b-inside); +} + +.byline small { + font-size: 0.8125rem; + color: var(--ink-2); +} + +.ledger .rx { + gap: 12px; +} + +.ledger .rx.tap { + cursor: pointer; +} + +.ledger .rx-d { + flex: none; + width: 42px; + text-align: right; + font-size: 1rem; + font-variant-numeric: tabular-nums; +} + +.ledger .row-title { + white-space: normal; + font-size: 0.875rem; + line-height: 1.35; +} + +.ledger .row-sub { + font-size: 0.6875rem; +} + +.ledger.is-example { + opacity: 0.62; +} + +.ledger .badge-example { + height: 14px; + font-size: 0.45rem; + vertical-align: 1px; +} + +.sig-cta { + display: flex; + align-items: center; + gap: 12px; + margin: 10px var(--gutter) 0; + padding: 12px 14px; + border-radius: 14px; + background: rgba(163, 216, 255, 0.08); + box-shadow: inset 0 0 0 0.5px rgba(163, 216, 255, 0.35); +} + +.sig-cta > .i { + flex: none; + font-size: 1.4rem; + color: var(--b-sky); +} + +.sig-cta span { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.8125rem; + line-height: 1.35; + color: var(--ink-2); +} + +.sig-cta b { + color: var(--ink); +} + +.privacy-card { + display: flex; + gap: 12px; + margin: 0 var(--gutter); + padding: 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + box-shadow: inset 0 0 0 0.5px var(--hairline-strong); +} + +.privacy-card > .i { + flex: none; + font-size: 1.4rem; + color: var(--b-inside); +} + +.privacy-card div { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--ink-2); +} + +.privacy-card b { + font-size: 0.9375rem; + color: var(--ink); +} + +.calm-now .row-title { + color: var(--ok); +} + +.section-h .linkish { + margin-left: auto; + font-size: 0.8125rem; +} + +/* Wide screens: the room sheet floats, chat stays readable. */ +@media (min-width: 900px) { + .room-sheet .cl { + font-size: 0.9375rem; + } + .mo-hero { + height: 320px; + } +} + +@media (prefers-reduced-motion: reduce) { + .heat i, + .rs-live i, + .ls-dot { + animation: none; + } +} diff --git a/mobile/ui/attach.js b/mobile/ui/attach.js index 79a8a5caf..ace974855 100644 --- a/mobile/ui/attach.js +++ b/mobile/ui/attach.js @@ -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 }) { ${it.previewUrl ? (it.isVideo ? `` : ``) : ''}${icon(it.unverified ? 'upload' : 'sealFill')}
${esc(head)}
${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 }))}
-

Where does this belong?

- `; +

${ctx.needEyes ? 'Answer the request' : 'Where does this belong?'}

+ ${ctx.needEyes ? `
${icon('eyes')}Need eyes “${esc(ctx.needEyes.question)}”${esc(ctx.needEyes.by || '')} · ${it.unverified ? 'uploads can’t fulfil a request' : 'first sealed answer gets the byline'}
` : ''}`; sheet.body.innerHTML = `
${ctx.list.map(row).join('')} @@ -172,7 +172,10 @@ export function createAttach({ rooms, globe, getSituations, onPosted }) { `; } - 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) { diff --git a/mobile/ui/capture.js b/mobile/ui/capture.js index 1a300fb02..02f06ea82 100644 --- a/mobile/ui/capture.js +++ b/mobile/ui/capture.js @@ -74,6 +74,7 @@ export function createCapture(
+
LAT—LON— @@ -161,7 +162,7 @@ export function createCapture( let dpr = 1; let lastCapture = null; let netOffset = null; - let target = null; // { s, replyTo } — the room this capture answers + let target = null; // { s, replyTo, needEyes } — the room this capture answers let live = null; // { session, id, t0, pin, frameTimer } const VFOV = 62; const MODES = ['look', 'witness', 'live', 'upload']; @@ -222,6 +223,7 @@ export function createCapture( function enter() { active = true; resize(); + setTimeout(() => needsLoop(), 0); let seen = false; try { seen = sessionStorage.getItem(GATE_KEY) === '1'; @@ -1001,7 +1003,7 @@ export function createCapture(
Heading
${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}
Media
${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB
-
`, +
`, ); } @@ -1038,7 +1040,11 @@ export function createCapture( headingDeg: bundle.session?.heading?.deg ?? null, segments: bundle.session?.segments?.length || 0, }, - { preset: target?.s || null, replyTo: target?.replyTo || null }, + { + preset: target?.s || null, + replyTo: target?.replyTo || null, + needEyes: target?.needEyes || null, + }, ); } @@ -1333,7 +1339,11 @@ export function createCapture( isVideo, headingDeg: exif?.headingDeg ?? null, }, - { preset: target?.s || null, replyTo: target?.replyTo || null }, + { + preset: target?.s || null, + replyTo: target?.replyTo || null, + needEyes: target?.needEyes || null, + }, ); }; } @@ -1347,16 +1357,77 @@ export function createCapture( } /* ---------- reply target ---------- */ - function attachTo(s, { replyTo = null } = {}) { - target = s ? { s, replyTo } : null; + function attachTo(s, { replyTo = null, needEyes = null } = {}) { + target = s ? { s, replyTo, needEyes } : null; targetChip.hidden = !target; + targetChip.classList.toggle('is-need', !!needEyes); if (target) { - targetChip.innerHTML = `${icon('reply')}Answering in ${esc(s.title)}${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}${icon('close')}`; - if (mode === 'look') setMode('witness'); + targetChip.innerHTML = needEyes + ? `${icon('eyes')}Need eyes · ${esc(needEyes.question.length > 46 ? `${needEyes.question.slice(0, 45)}…` : needEyes.question)}${icon('close')}` + : `${icon('reply')}Answering in ${esc(s.title)}${replyTo ? ` · ${esc(replyTo.author.handle)}` : ''}${icon('close')}`; + if (mode === 'look' || mode === 'upload') setMode('witness'); } + renderNeeds(); } targetChip.addEventListener('click', () => attachTo(null)); + /* ---------- Need eyes near you (top of Capture) ---------- */ + const needsEl = $('.cap-needs', view); + let needs = []; + let needsOpen = false; + async function refreshNeeds() { + try { + const m = await import('./social.js'); + const o = observer(); + const ll = o.simulated ? getLocation?.() || m.socialCore().myLoc() : o; + needs = m + .needEyesNear({ lat: ll.lat, lon: ll.lon }, 30) + .filter((r) => r.status === 'open'); + } catch { + needs = []; + } + renderNeeds(); + } + function renderNeeds() { + const list = needs.filter((r) => r.id !== target?.needEyes?.id); + needsEl.hidden = !list.length || mode === 'live'; + if (needsEl.hidden) return; + const r0 = list[0]; + needsEl.innerHTML = `${ + needsOpen + ? `
${list + .slice(0, 4) + .map( + (r) => + ``, + ) + .join('')}
` + : '' + }`; + } + needsEl.addEventListener('click', (e) => { + const b = e.target.closest('[data-cn]'); + if (!b) return; + haptic('tick'); + if (b.dataset.cn === 'toggle') { + needsOpen = !needsOpen; + renderNeeds(); + return; + } + const r = needs.find((x) => x.id === b.dataset.id); + if (!r) return; + needsOpen = false; + attachTo(r.situation, { needEyes: r }); + showStatus('Seal a photo from inside the radius to answer', 3200); + }); + let needsTimer = 0; + const needsLoop = () => { + clearTimeout(needsTimer); + if (!active) return; + refreshNeeds(); + needsTimer = setTimeout(needsLoop, 30000); + }; + return { enter, leave, diff --git a/mobile/ui/chatSim.js b/mobile/ui/chatSim.js new file mode 100644 index 000000000..fb32fb6fc --- /dev/null +++ b/mobile/ui/chatSim.js @@ -0,0 +1,1089 @@ +/** + * Simulated Live chat (DESIGN.md §12.6, §12.8 "Honest labels"). + * + * A believable crowd for the demo: per-genre pools written like real people + * on the ground and in the hobby (pilots, locals, weather nerds, skeptics, + * jokers, moderators), realistic pacing with bursts, and replies to what the + * person types. Every handle is fictional and every message is flagged + * `sim: true`; the lane that shows them is labelled "Simulated chat · demo". + * Numbers quoted in messages come only from the situation's live metrics. + */ + +import { fmtNum } from './dom.js'; +import { headlineOf, fmtKm, cardinal } from './bands.js'; +import { simTier, hashStr } from './signal.js'; + +/* ---------------- Personas (fictional) ---------------- */ + +export const PERSONAS = { + mod: [ + { handle: '@deskmod_rae', mod: true, floor: 900 }, + { handle: '@nightdesk_mod', mod: true, floor: 760 }, + ], + pilot: [ + { handle: '@flaps30', domain: 'Pilot', floor: 420 }, + { handle: '@heavy_tess', domain: 'Pilot', floor: 640 }, + { handle: '@atc_listener', floor: 220 }, + ], + wx: [ + { handle: '@wxdan', domain: 'Met', floor: 520 }, + { handle: '@cape_and_shear', floor: 300 }, + { handle: '@radar_rina', domain: 'Met', floor: 820 }, + ], + local: [ + { handle: '@onthe5th', local: true, floor: 140 }, + { handle: '@harborkid_22', local: true, floor: 90 }, + { handle: '@ridge_rd_amy', local: true, floor: 260 }, + { handle: '@corner_deli_mo', local: true, floor: 40 }, + ], + skeptic: [ + { handle: '@actually_tho', floor: 380 }, + { handle: '@sourcepls', floor: 560 }, + ], + joker: [ + { handle: '@lagspike', floor: 0 }, + { handle: '@pigeonwatch', floor: 60 }, + { handle: '@buffering_bev', floor: 10 }, + ], + orbit: [ + { handle: '@tle_tom', floor: 700 }, + { handle: '@pass_predictor', floor: 330 }, + ], + sea: [{ handle: '@bridgewing_ko', domain: 'Mariner', floor: 610 }], + medic: [ + { handle: '@medic_off_shift', domain: 'First responder', floor: 480 }, + ], + scanner: [ + { handle: '@scanner_sal', floor: 350 }, + { handle: '@freq_hopper', floor: 180 }, + ], + photo: [{ handle: '@longlens_ben', floor: 290 }], + newbie: [ + { handle: '@first_time_here', floor: 0 }, + { handle: '@lurker9', floor: 0 }, + { handle: '@k8_mobile', floor: 20 }, + { handle: '@just_scrolling', floor: 0 }, + ], +}; +const ALL = Object.entries(PERSONAS).flatMap(([role, list]) => + list.map((p) => ({ ...p, role })), +); +export const personaOf = (handle) => ALL.find((p) => p.handle === handle); + +/** Where watchers are (for "hi from …" lines and crowd reactions). */ +export const WORLD = [ + ['Lisbon', 38.72, -9.14, 3], + ['Manila', 14.6, 120.98, 6], + ['Lagos', 6.52, 3.38, 5], + ['São Paulo', -23.55, -46.63, 6], + ['Mumbai', 19.08, 72.88, 7], + ['Jakarta', -6.2, 106.85, 6], + ['Tokyo', 35.68, 139.69, 7], + ['Seoul', 37.57, 126.98, 5], + ['London', 51.51, -0.13, 6], + ['Berlin', 52.52, 13.4, 4], + ['Toronto', 43.65, -79.38, 4], + ['Chicago', 41.88, -87.63, 4], + ['Mexico City', 19.43, -99.13, 6], + ['Sydney', -33.87, 151.21, 4], + ['Nairobi', -1.29, 36.82, 3], + ['Cairo', 30.04, 31.24, 5], + ['Istanbul', 41.01, 28.98, 5], + ['Denver', 39.74, -104.99, 3], + ['Seattle', 47.61, -122.33, 3], + ['Austin', 30.27, -97.74, 3], + ['Houston', 29.76, -95.37, 4], + ['Miami', 25.76, -80.19, 3], + ['New York', 40.71, -74.01, 7], + ['Los Angeles', 34.05, -118.24, 6], + ['Paris', 48.86, 2.35, 5], + ['Madrid', 40.42, -3.7, 4], + ['Warsaw', 52.23, 21.01, 3], + ['Helsinki', 60.17, 24.94, 2], + ['Bangkok', 13.76, 100.5, 5], + ['Singapore', 1.35, 103.82, 4], + ['Taipei', 25.03, 121.57, 4], + ['Auckland', -36.85, 174.76, 2], + ['Bogotá', 4.71, -74.07, 4], + ['Johannesburg', -26.2, 28.05, 3], + ['Dubai', 25.2, 55.27, 3], + ['Reykjavík', 64.15, -21.94, 1], +]; +const WORLD_W = WORLD.reduce((a, w) => a + w[3], 0); +export function worldPoint(rand = Math.random) { + let x = rand() * WORLD_W; + for (const w of WORLD) { + x -= w[3]; + if (x <= 0) + return { + city: w[0], + lat: w[1] + (rand() - 0.5) * 1.6, + lon: w[2] + (rand() - 0.5) * 1.6, + }; + } + return { city: WORLD[0][0], lat: WORLD[0][1], lon: WORLD[0][2] }; +} + +/* ---------------- Genre ---------------- */ + +export function chatGenre(s) { + const k = + `${s?.kind || ''} ${s?.archetype || ''} ${s?.title || ''}`.toLowerCase(); + if (s?.momentId || /circling|moment|climber/.test(k)) return 'circling'; + if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone'; + if (/quake/.test(k)) return 'quake'; + if (/fire|volcano/.test(k)) return 'fire'; + if (/lightning|rain|storm|radar/.test(k)) return 'storm'; + if (/squawk/.test(k)) return 'squawk'; + if (/gnss|jam/.test(k)) return 'gnss'; + if (/launch/.test(k)) return 'launch'; + if (/iss|pass|aurora|orbit|starlink/.test(k)) return 'orbit'; + if (/camera|cctv|eye/.test(k)) return 'eye'; + if (/queue|strait|port|ship|vessel/.test(k)) return 'sea'; + if (/heli|rotor/.test(k)) return 'heli'; + if (/airport|military|jet|flight|tanker/.test(k)) return 'air'; + return 'default'; +} + +/* ---------------- Metric helpers ---------------- */ + +function fmtMetric(m) { + if (!m) return ''; + const v = + typeof m.value === 'number' + ? fmtNum(m.value, m.value % 1 ? 1 : 0) + : String(m.value ?? ''); + return `${v}${m.unit ? ` ${m.unit}` : ''}`.trim(); +} +function vars(s) { + const ms = s.metrics || []; + const hl = headlineOf(s); + const find = (k) => + ms.find((m) => + String(m.label).toLowerCase().startsWith(String(k).toLowerCase()), + ); + return { + place: s.place || s.title || 'there', + title: s.title || '', + m0: fmtMetric(ms[0]), + m1: fmtMetric(ms[1]), + m2: fmtMetric(ms[2]), + l0: String(ms[0]?.label || '').toLowerCase(), + l1: String(ms[1]?.label || '').toLowerCase(), + hl: hl ? `${hl.value}${hl.unit ? ` ${hl.unit}` : ''}` : '', + hll: hl?.label || '', + dir: cardinal(s.media?.headingDeg) || '', + find: (k) => fmtMetric(find(k)), + }; +} + +/** "{m0}" style templates; returns '' if any placeholder is empty. */ +export function fill(t, V, extra = {}) { + let bad = false; + const out = t.replace(/\{([\w:]+)\}/g, (_, key) => { + let v = ''; + if (key.startsWith('m:')) v = V.find(key.slice(2)); + else v = extra[key] ?? V[key] ?? ''; + if (!v) bad = true; + return v; + }); + return bad ? '' : out; +} + +/* ---------------- Pools ---------------- */ +// [role, text]. Text may use {place} {title} {m0} {m1} {hl} {hll} {m:Label}. + +const COMMON = [ + ['newbie', 'first time in a live room, this is wild'], + ['newbie', 'how is everyone seeing this so fast'], + ['newbie', 'what does the gold ring next to some names mean?'], + [ + 'mod', + 'Gold ring = that person has a sealed capture near this. Their chat glows, it doesn’t mean they’re right SealIt', + ], + ['joker', 'my boss thinks I’m in a spreadsheet rn'], + ['joker', 'chat moving faster than my wifi'], + [ + 'skeptic', + 'reminder that chat is not a source. Record lane or it didn’t happen', + ], + ['newbie', 'is the map live or a replay?'], + [ + 'mod', + 'Map is live. Anything with a seal is a real capture; anything that says Example or Simulated is demo content', + ], + ['local', 'refreshing this instead of sleeping, as one does'], + [ + 'photo', + 'if anyone nearby has a long lens, the Need eyes pins are where it’s useful', + ], + ['newbie', 'o7 from the night shift'], + ['joker', 'Receipts or it’s fan fiction'], +]; + +const GREET = [ + 'hi from {city}', + 'watching from {city} o7', + '{city} checking in', + 'evening from {city}', + 'morning from {city}, coffee + this', +]; + +const POOLS = { + circling: [ + [ + 'scanner', + 'NYPD aviation on the Special Ops freq, they’re holding over 34th', + ], + [ + 'local', + 'I can see two of them from my office, both orbiting tight around the top of the building', + ], + [ + 'pilot', + 'that orbit radius looks like ~500 m, classic news-helo orbit for a stable shot', + ], + [ + 'local', + 'there are people on the observation deck pointing UP. something is on the mast', + ], + [ + 'photo', + 'from 5th Ave with a 400mm you can see two figures above the 86th floor deck EyesUp', + ], + ['skeptic', 'two figures according to who? show me a sealed capture'], + [ + 'local', + 'sealed a clip from the 34th St side, posting to Record SealIt', + { evidence: 0.25 }, + ], + ['joker', 'King Kong reboot looking real low budget'], + [ + 'pilot', + 'police heli is lower, around 1,000 ft, news birds stacked above at 1,500', + ], + ['scanner', 'ESU requested to the 86th floor, that’s rope rescue'], + [ + 'mod', + 'Please don’t share names of anyone on the building. Slow mode is on.', + ], + ['newbie', 'why are there 3 helicopters for 2 people'], + [ + 'pilot', + 'one police, two news. the news ones are there for the shot, not the rescue', + ], + ['local', 'crowd forming on 34th, cops pushing everyone back to Broadway'], + [ + 'skeptic', + 'the climbers thing is unconfirmed until someone up close seals it. could be maintenance crew', + ], + ['local', 'maintenance doesn’t wave a banner HeliSpin'], + [ + 'photo', + 'banner says something, can’t read it, the wind keeps flipping it', + ], + ['scanner', 'they’re calling it a “protest climb” on the radio now'], + ['joker', 'imagine commuting to work and it’s this'], + [ + 'local', + 'i’m at 1 Penn, 30th floor, perfect angle. sealing now', + { evidence: 0.6 }, + ], + ], + cyclone: [ + [ + 'wx', + 'eyewall replacement explains why the peak dipped but the wind field got wider', + ], + [ + 'wx', + 'sustained {m:Wind} is still a serious storm, don’t let the dip fool you', + ], + ['local', 'power flickering here twice in the last ten minutes'], + [ + 'local', + 'outer bands hit the waterfront, chairs on the pier are sliding StormMode', + ], + ['skeptic', 'whoever is posting Cat 5 clips: that’s from 2013'], + [ + 'wx', + 'pressure {m:Pressure} — watch whether it falls again after the EWRC', + ], + ['newbie', 'is it going to hit Manila?'], + ['wx', 'the cone is wide this far out. look at the cone, not the line'], + [ + 'mod', + 'If you’re in the warning area: follow PAGASA, not chat. We’re a window, not an alert service.', + ], + ['joker', 'StormMode activated, snacks acquired'], + [ + 'local', + 'my uncle in Aparri says the sea is already over the seawall', + { evidence: null }, + ], + ['sea', 'ships are leaving the anchorage, that’s a tell'], + ['photo', 'IR loop is gorgeous, eye is clearing again EyesUp'], + [ + 'skeptic', + '“my uncle says” is not evidence, but a sealed photo from him would be', + ], + [ + 'local', + 'sealed a clip of the harbour from the 3rd floor, posting', + { evidence: 3.8 }, + ], + ], + storm: [ + ['wx', '{m0} in the last hour, that’s a very electric line'], + [ + 'local', + 'constant flashes to the south, thunder every couple seconds StormMode', + ], + ['local', 'gutters already overflowing on my street'], + ['wx', 'the core is moving with the line, maybe 40 min to the coast'], + ['newbie', 'is it safe to drive home now?'], + [ + 'medic', + 'if you can wait 30–40 min, wait. most crashes in these are in the first wave of rain', + ], + [ + 'skeptic', + 'that “tornado” photo going around is from Oklahoma last spring', + ], + [ + 'photo', + 'got a lightning shot from the parking garage, 8 s exposure', + { evidence: 2.1 }, + ], + ['joker', 'my dog has filed a formal complaint'], + ['wx', 'peak density {m1}, wow'], + ['local', 'power just went out on our block'], + [ + 'mod', + 'Flood photos: please post them to the Record with a seal so the room can use them', + ], + [ + 'local', + 'underpass on 7th is a lake, cars turning around', + { evidence: 0.8 }, + ], + ], + quake: [ + ['local', 'long rolling one, maybe 20 seconds, 6th floor'], + ['local', 'glasses rattled, nothing fell here'], + ['wx', 'depth {m:Depth} — shallow ones feel sharper'], + ['newbie', 'tsunami??'], + [ + 'mod', + 'Tsunami advisories come from the national warning centre. Not from chat. Link is in the Record.', + ], + [ + 'skeptic', + 'first automatic solutions often fix depth at 10 km, wait for the reviewed one', + ], + ['local', 'aftershock just now, small, felt it in the chair'], + ['joker', 'my cat knew 10 seconds before. cats are the real sensors'], + ['medic', 'check gas lines if you’re in an older building'], + ['local', 'sealed a clip of the hallway lights swinging', { evidence: 12 }], + [ + 'skeptic', + 'magnitude keeps getting revised, quote the latest from the brief', + ], + ], + fire: [ + ['local', 'ash on the cars this morning, sky orange to the east'], + ['local', 'smoke is laying right down the valley'], + ['wx', 'wind is pushing the plume east, look at the wind lens'], + ['newbie', 'how many fires is that?'], + [ + 'wx', + 'each hotspot is a ~375 m pixel, {m0} hotspots isn’t that many separate fires', + ], + [ + 'skeptic', + 'the “fire jumped the river” post is not on the perimeter layer', + ], + ['medic', 'close windows, N95 if you have one, AQI will spike tonight'], + [ + 'photo', + 'sealed a photo from the ridge road, glow over the second ridge', + { evidence: 6.5 }, + ], + ['joker', 'my car is now beige, it was black'], + [ + 'mod', + 'Evacuation info: county alerts only. Don’t relay road closures from chat as fact.', + ], + ], + squawk: [ + ['pilot', '7700 is a general emergency. medical diversions use it too'], + [ + 'pilot', + '{m:Altitude} and {m:Speed}, looks like a controlled descent to me', + ], + ['atc', 'they asked for vectors to the nearest suitable, sounded calm'], + ['newbie', 'hijack??'], + ['skeptic', 'not a hijack. that would be 7500. please stop'], + ['scanner', 'tower has fire trucks positioned, that’s standard'], + [ + 'local', + 'heard it low over the harbour, gear down, engines sounded normal', + ], + ['joker', 'pilots when the coffee machine breaks: 7700'], + [ + 'pilot', + 'track turned north a few min ago, lining up for the long runway', + ], + ['mod', 'Please don’t speculate about passengers. Facts in the Record.'], + ], + gnss: [ + [ + 'pilot', + 'flew through there last week, GPS dropped, we were on inertial for ~20 min', + ], + ['newbie', 'does this affect phones?'], + [ + 'wx', + 'the layer is aircraft self-reporting nav accuracy, {m0} aircraft, not phones', + ], + [ + 'skeptic', + 'calling everything jamming overstates it, some are equipment faults', + ], + ['sea', 'ships near the coast report the same pattern at night'], + ['joker', 'my phone thinks I’m in the sea again'], + ['mod', 'Stick to what the data shows. No attribution games in Live.'], + ], + orbit: [ + [ + 'orbit', + 'pass is ~6 min horizon to horizon, look west-southwest at the start', + ], + ['orbit', 'it’s brighter than anything but the Moon tonight EyesUp'], + ['newbie', 'how do I know which dot it is?'], + ['orbit', 'it doesn’t blink and it moves steadily, planes blink'], + ['local', 'clouds here, praying for a gap'], + ['photo', 'tripod out, going for a 6 s exposure streak'], + ['joker', 'waving at the astronauts o7'], + ['skeptic', 'the viral fireball clip is a reentry from last year'], + ['local', 'SAW IT. so bright. sealed photo', { evidence: 0.4 }], + ], + launch: [ + ['orbit', 'weather is the only thing that can scrub this now'], + ['local', 'causeway is packed, cars parked on the shoulder for a mile'], + ['newbie', 'can you see it from Houston?'], + ['orbit', 'not the launch, maybe the plume if it’s clear'], + ['joker', 'LiftOff LiftOff LiftOff'], + ['skeptic', 'T-0 is a window, not a promise'], + [ + 'photo', + 'set up at the beach with 600mm, sealed test shot of the pad', + { evidence: 9 }, + ], + ['mod', 'Reminder: respect the exclusion zone. No drone footage please.'], + ], + eye: [ + ['local', 'walking past now, way busier than the frame shows'], + ['local', 'line out the door at the corner'], + [ + 'skeptic', + 'the camera faces {dir}, the “crowd heading to the square” is walking away from it', + ], + ['newbie', 'is this actually live?'], + [ + 'mod', + 'Frame refreshes every ~15 s from the city’s public camera. Faces are not identifiable at this res, keep it that way.', + ], + ['joker', 'i’m the pixel in the red jacket. hi mom'], + ['local', 'lane closure still on, cones all the way to the light'], + [ + 'photo', + 'sealed a photo from across the street, matches the frame', + { evidence: 0.08 }, + ], + ], + sea: [ + [ + 'sea', + 'anchored in the waiting area since morning, told to hold 12 more hours', + ], + ['sea', 'mostly scheduling, convoys run at set times'], + ['newbie', 'why so many ships?'], + ['skeptic', 'some of those are bunkering, not queuing'], + ['joker', 'this is the DMV of oceans'], + ['sea', 'AIS will show them moving in a column when the convoy starts'], + ], + heli: [ + ['pilot', 'that’s a training pattern, same circuit every 6–7 min'], + ['local', 'they do this all day, you stop hearing it'], + ['newbie', 'is something wrong?'], + ['pilot', 'nope, autorotation practice. looks scary, is routine'], + ['joker', 'HeliSpin HeliSpin HeliSpin'], + ['local', 'one just did a hover check right over the field'], + ['mod', 'Alumni crew is in here tonight o7'], + ], + air: [ + [ + 'pilot', + 'those racetracks are holding patterns, controllers stack arrivals', + ], + ['local', 'watching from the fence, one every couple minutes'], + ['skeptic', 'not a record day, {m0} is high but normal peak'], + ['atc', 'they just switched runways, that’s why the holds'], + ['joker', 'plane spotters are the original live chat'], + [ + 'photo', + 'sealed a clip with the approach lights in frame', + { evidence: 1.4 }, + ], + ], + default: [ + ['local', 'here now, what I see matches the map'], + ['newbie', 'is this normal for this time of day?'], + ['skeptic', '{m0} is within the usual range, don’t overread it'], + ['joker', 'chat is the real content'], + ], +}; + +/** Burst lines: short, loud, many people at once. */ +const BURST = { + circling: [ + 'THEY’RE MOVING', + 'one of them just climbed higher', + 'ESU on the roof now', + 'EyesUp EyesUp', + 'banner is out!!', + 'HeliSpin', + 'oh no oh no', + 'that’s so high', + 'did you see that', + 'they waved', + ], + cyclone: [ + 'eye clearing!!', + 'StormMode', + 'new IR frame is insane', + 'that gust', + 'whoa', + 'pressure dropping again?', + ], + storm: [ + 'THAT FLASH', + 'StormMode', + 'lightning right over us', + 'whoa', + 'power just blinked', + 'that one shook the windows', + ], + quake: [ + 'AFTERSHOCK', + 'felt that', + 'here too', + 'whoa', + 'that was bigger', + 'everyone ok?', + ], + fire: [ + 'new hotspot!', + 'wind shifted', + 'whoa', + 'that plume', + 'eyes on the ridge EyesUp', + ], + squawk: [ + 'ON FINAL', + 'gear down', + 'touchdown?', + 'they’re down!!', + 'o7', + 'o7 crew', + ], + gnss: ['new cell lit up', 'whoa', 'spreading north'], + orbit: [ + 'THERE IT IS', + 'EyesUp EyesUp', + 'so bright', + 'o7 ISS', + 'saw it!!', + 'wow', + ], + launch: [ + 'LiftOff', + 'LIFTOFF', + 'go go go', + 'LiftOff LiftOff', + 'max q', + 'beautiful', + ], + eye: [ + 'crowd surge', + 'whoa', + 'here comes the parade', + 'EyesUp', + 'look at the corner', + ], + sea: ['convoy moving', 'here they go', 'whoa'], + heli: ['autorotation!', 'HeliSpin', 'nice flare', 'o7'], + air: ['go around!', 'whoa', 'heavy on final', 'nice landing'], + default: ['whoa', 'did you see that', 'EyesUp', 'wow'], +}; + +/** Reaction mix per genre (weights for heart, fire, eyes, wow). */ +const MIX = { + circling: [3, 1, 5, 4], + cyclone: [3, 2, 3, 3], + storm: [2, 4, 2, 4], + quake: [4, 0, 3, 2], + fire: [3, 4, 2, 1], + squawk: [6, 0, 4, 1], + gnss: [1, 1, 4, 2], + orbit: [5, 1, 3, 5], + launch: [3, 6, 2, 5], + eye: [4, 2, 4, 2], + sea: [2, 1, 3, 2], + heli: [3, 2, 3, 3], + air: [3, 1, 3, 2], + default: [3, 1, 3, 2], +}; +export function reactionKind(genre, rand = Math.random) { + const w = MIX[genre] || MIX.default; + const kinds = ['heart', 'fire', 'eyes', 'wow']; + let x = rand() * w.reduce((a, b) => a + b, 0); + for (let i = 0; i < 4; i += 1) { + x -= w[i]; + if (x <= 0) return kinds[i]; + } + return 'heart'; +} + +/* ---------------- Replies to the person ---------------- */ + +const REPLIES = [ + { + re: /\b(hi|hey|hello|yo|sup|first)\b/i, + lines: [ + ['local', 'welcome {user} o7'], + [ + 'mod', + 'Welcome {user}. Live is fast, the Record is where evidence goes.', + ], + ['newbie', 'hey {user}'], + ], + }, + { + re: /\b(fake|real|hoax|cgi|staged|lie|lying)\b/i, + lines: [ + [ + 'skeptic', + '{user} fair question. sealed captures only, check the Record lane', + ], + [ + 'mod', + '{user} If you think something is wrong, post a Correction in the Record. Corroborated corrections raise your Signal.', + ], + ['local', '{user} i’m literally here lol'], + ], + }, + { + re: /\b(safe|danger|ok|okay|scared|worried|evacuat)/i, + lines: [ + [ + 'medic', + '{user} follow local officials for safety calls, chat can’t see your street', + ], + [ + 'mod', + '{user} Nothing in this room is an alert. If you’re close, local channels first.', + ], + ], + }, + { + re: /\b(seal|sealed|capture|photo|pic|video|clip)\b/i, + lines: [ + ['photo', '{user} SealIt pls, the room needs eyes from the ground'], + [ + 'local', + '{user} there’s a Need eyes pin near the scene if you’re close', + ], + ], + }, + { + re: /\b(heli|helicopter|chopper|rotor)/i, + lines: [ + [ + 'pilot', + '{user} rotorcraft orbiting low and slow, the track lens shows the circles clearly', + ], + ['scanner', '{user} one of them is police, the others are news'], + ], + }, + { + re: /\b(iss|satellite|starlink|orbit)\b/i, + lines: [ + [ + 'orbit', + '{user} if it doesn’t blink and moves steady, that’s a satellite', + ], + ], + }, + { + re: /\b(rain|flood|storm|lightning|wind|thunder)\b/i, + lines: [ + [ + 'wx', + '{user} radar lens shows the core, the lightning dots are the last 15 min', + ], + ['local', '{user} it’s really coming down here'], + ], + }, + { + re: /\b(where|location|place|address)\b/i, + lines: [ + ['local', '{user} {place}, tap the room title for the map'], + [ + 'mod', + '{user} Exact spots of people stay private. The situation pin is on the globe.', + ], + ], + }, + { + re: /\b(lol|lmao|haha|rofl|💀)\b/i, + lines: [ + ['joker', '{user} LMAO'], + ['joker', '{user} chat is undefeated'], + ], + }, + { + re: /\b(how many|how much|number|count|stats?)\b/i, + lines: [ + ['wx', '{user} brief says {m0}{sep1}{m1}'], + [ + 'skeptic', + '{user} numbers are in the Brief with their source and age, chat rounds things', + ], + ], + }, + { + re: /\?$/, + lines: [ + ['local', '{user} good q, nobody’s confirmed yet'], + [ + 'skeptic', + '{user} post it as a Question in the Record, they float until answered', + ], + [ + 'mod', + '{user} Questions in the Record stay pinned until someone answers with evidence.', + ], + ], + }, +]; +const GENERIC_REPLY = [ + ['local', '{user} yeah'], + ['newbie', '{user} same'], + ['joker', '{user} real'], + ['local', '{user} 100%'], + ['skeptic', '{user} source?'], +]; + +/* ---------------- The simulator ---------------- */ + +let seq = 0; +export function makeMsg(persona, text, extra = {}) { + const tier = simTier(persona.handle, persona.floor || 0); + return { + id: `sim-${Date.now().toString(36)}-${(seq += 1)}`, + handle: persona.handle, + text, + at: Date.now(), + sim: true, + mod: !!persona.mod, + domain: persona.domain || null, + local: !!persona.local, + tier, + ...extra, + }; +} + +function pick(list, rand = Math.random) { + return list[Math.floor(rand() * list.length)]; +} +function personaFor(role, rand) { + if (role === 'atc') return { ...PERSONAS.pilot[2], role }; + const list = PERSONAS[role] || PERSONAS.newbie; + return { ...pick(list, rand), role }; +} + +/** + * @param {object} s situation + * @param {{ onMessage(msg), onReactions?(kind, n, burst), viewers?: number, + * me?: () => string, rand?: () => number }} o + */ +export function createChatSim(s, o) { + const rand = o.rand || Math.random; + const genre = chatGenre(s); + const V = vars(s); + const pool = [...(POOLS[genre] || POOLS.default), ...COMMON]; + const used = new Set(); + let timer = 0; + let burstUntil = 0; + let nextBurst = Date.now() + 9000 + rand() * 12000; + let running = false; + let viewers = o.viewers || viewerCount(s); + const witnessHandles = new Set(); // sim chatters with sealed captures nearby + + function line() { + // Prefer unused lines; recycle once the pool is spent. + let cand = pool.filter((_, i) => !used.has(i)); + if (cand.length < 3) { + used.clear(); + cand = pool; + } + for (let tries = 0; tries < 6; tries += 1) { + const idx = Math.floor(rand() * pool.length); + if (used.has(idx) && tries < 5) continue; + const [role, t, meta] = pool[idx]; + const text = fill(t, V); + if (!text) { + used.add(idx); + continue; + } + used.add(idx); + const p = personaFor(role, rand); + const extra = {}; + if (meta && meta.evidence != null) { + extra.evidence = { sealed: true, km: meta.evidence, example: true }; + witnessHandles.add(p.handle); + } + if (witnessHandles.has(p.handle)) extra.witness = true; + return makeMsg(p, text, extra); + } + return null; + } + + function greet() { + const w = worldPoint(rand); + const p = personaFor(pick(['newbie', 'local', 'joker', 'newbie']), rand); + // Greeters are from elsewhere: never mark them local. + return makeMsg( + { ...p, local: false }, + fill(pick(GREET, rand), V, { city: w.city }), + { + from: w, + }, + ); + } + + function burstLine() { + const list = BURST[genre] || BURST.default; + const p = personaFor( + pick(['newbie', 'local', 'joker', 'photo', 'newbie', 'local'], rand), + rand, + ); + let text = pick(list, rand); + if (rand() < 0.25) text = text.toUpperCase(); + return makeMsg( + p, + text, + witnessHandles.has(p.handle) ? { witness: true } : {}, + ); + } + + function tick() { + if (!running) return; + const now = Date.now(); + if (now > nextBurst) { + burstUntil = now + 3200 + rand() * 2500; + nextBurst = now + 22000 + rand() * 26000; + o.onBurst?.(genre); + } + const bursting = now < burstUntil; + // Messages per second scales gently with viewers; bursts ×4. + const base = Math.min(2.4, Math.max(0.35, viewers / 1400)); + const rate = bursting ? base * 4 + 1.5 : base; + const r = rand(); + const msg = bursting + ? r < 0.75 + ? burstLine() + : line() + : r < 0.14 + ? greet() + : line(); + if (msg) o.onMessage(msg); + // Reactions from the crowd, in proportion to viewers. + if (o.onReactions) { + const n = Math.max( + 0, + Math.round((viewers / 900) * (bursting ? 6 : 1) * (0.4 + rand())), + ); + if (n) o.onReactions(reactionKind(genre, rand), n, bursting); + } + // Viewer count random walk, ±2 %. + viewers = Math.max(12, Math.round(viewers * (1 + (rand() - 0.48) * 0.02))); + o.onViewers?.(viewers); + const gap = (1000 / rate) * (0.35 + rand() * 1.3); + timer = setTimeout(tick, gap); + } + + /** Someone (the person) said something: a few sim people answer. */ + function hear(text, handle) { + const t = String(text || ''); + const rule = REPLIES.find((r) => r.re.test(t)); + const lines = rule ? rule.lines : GENERIC_REPLY; + const n = rule ? 1 + (rand() < 0.45 ? 1 : 0) : rand() < 0.55 ? 1 : 0; + const chosen = [...lines].sort(() => rand() - 0.5).slice(0, n); + chosen.forEach(([role, tpl], i) => { + const txt = fill(tpl, V, { + user: handle, + sep1: V.m1 ? ' and ' : '', + }); + if (!txt) return; + setTimeout( + () => + running && + o.onMessage( + makeMsg(personaFor(role, rand), txt, { replyTo: handle }), + ), + 1400 + i * 1700 + rand() * 1600, + ); + }); + // A mention of evidence draws corroborating eyes. + return chosen.length; + } + + return { + genre, + start() { + if (running) return; + running = true; + // Backfill a little history so the lane is never empty. + const back = []; + for (let i = 0; i < 14; i += 1) { + const m = i % 5 === 2 ? greet() : line(); + if (m) { + m.at = Date.now() - (14 - i) * (2500 + rand() * 4000); + back.push(m); + } + } + o.onBackfill?.(back); + timer = setTimeout(tick, 600); + }, + stop() { + running = false; + clearTimeout(timer); + }, + hear, + burst() { + nextBurst = 0; + }, + get viewers() { + return viewers; + }, + set viewers(v) { + viewers = v; + }, + get running() { + return running; + }, + }; +} + +/** A stable, plausible viewer count for a situation. */ +export function viewerCount(s) { + if (!s) return 0; + const h = hashStr(s.id); + const g = chatGenre(s); + const base = + { + circling: 4800, + cyclone: 2600, + launch: 3900, + squawk: 1800, + storm: 1300, + quake: 1500, + orbit: 1100, + eye: 640, + fire: 900, + }[g] || 380; + return Math.round(base * (0.75 + (h % 500) / 1000) + (s.severity || 0) * 120); +} + +/** + * A Moment's chat on its own clock. Uses moment.chat when the engine + * provides it; otherwise writes one from the genre pool across the window. + * @returns {{ t:number, handle, text, badge?, sim:true }[]} + */ +export function momentChat(m) { + if (Array.isArray(m?.chat) && m.chat.length) + return m.chat + .map((c) => ({ ...c, id: c.id || `${c.t}|${c.handle}`, sim: true })) + .sort((a, b) => a.t - b.t); + const start = +m?.time?.start || Date.now() - 20 * 60e3; + const end = +m?.time?.end || Date.now(); + const s = { + id: m?.id || 'moment', + kind: 'circling', + title: m?.title, + place: m?.place, + momentId: m?.id, + }; + let r = hashStr(s.id); + const rand = () => { + r = + Math.imul(r ^ (r >>> 15), 2246822507) ^ + Math.imul(r ^ (r >>> 13), 3266489909); + return ((r ^= r >>> 16) >>> 0) / 4294967296; + }; + const out = []; + const pool = POOLS.circling; + const V = vars(s); + const n = Math.min(80, Math.max(24, Math.round((end - start) / 15000))); + for (let i = 0; i < n; i += 1) { + const t = start + ((i + rand() * 0.8) / n) * (end - start); + const [role, tpl, meta] = + i % 7 === 3 ? ['newbie', pick(BURST.circling, rand)] : pick(pool, rand); + const p = personaFor(role, rand); + const text = fill(tpl, V) || tpl; + out.push({ + id: `mc-${s.id}-${i}`, + t, + handle: p.handle, + text, + badge: p.mod ? 'mod' : meta?.evidence != null ? 'witness' : undefined, + sim: true, + }); + } + return out.sort((a, b) => a.t - b.t); +} + +export { fmtKm }; + +/** A few representative lines for a room preview (Rooms tab ticker). */ +export function sampleLines(s, n = 6) { + const V = vars(s); + const pool = POOLS[chatGenre(s)] || POOLS.default; + let r = hashStr(s.id); + const rand = () => { + r = (Math.imul(r, 1664525) + 1013904223) >>> 0; + return r / 4294967296; + }; + const out = []; + const idx = pool.map((_, i) => i).sort(() => rand() - 0.5); + for (const i of idx) { + const [role, t, meta] = pool[i]; + const text = fill(t, V); + if (!text) continue; + const p = personaFor(role, rand); + out.push( + makeMsg( + p, + text, + meta?.evidence != null + ? { evidence: { sealed: true, km: meta.evidence } } + : {}, + ), + ); + if (out.length >= n) break; + } + return out; +} diff --git a/mobile/ui/clerk.js b/mobile/ui/clerk.js new file mode 100644 index 000000000..78d795f64 --- /dev/null +++ b/mobile/ui/clerk.js @@ -0,0 +1,185 @@ +/** + * The Clerk (DESIGN.md §12.6): every few minutes it reads the Live lane and + * posts "What chat is saying" into the Record, citing the chat lines it used. + * + * Prototype honesty: this is a local, deterministic summarizer (fixed rules + * over keywords, corroborations and evidence flags), not an AI model. It + * never states anything it cannot cite, and it keeps the cited lines inside + * the summary so the receipts survive after chat scrolls away. + */ + +import { canonical, sha256Hex } from '../capture/provenance.js'; + +const THEMES = [ + { + id: 'aircraft', + re: /\b(heli|helicopter|chopper|orbit|circling|police|news|ft|aviation|track|squawk|7700|gear|runway|final|holding)\b/i, + label: 'the aircraft overhead', + }, + { + id: 'weather', + re: /\b(rain|flash|lightning|thunder|gust|wind|pressure|eye|eyewall|storm|core|radar|flood|underpass)\b/i, + label: 'the weather on the ground', + }, + { + id: 'scene', + re: /\b(street|ave|st\b|floor|deck|corner|block|crowd|roof|banner|building|harbou?r|pier|ridge|road)\b/i, + label: 'what people can see from the street', + }, + { + id: 'shaking', + re: /\b(shak|rolling|aftershock|felt|rattled|swing)/i, + label: 'the shaking people felt', + }, + { + id: 'sky', + re: /\b(iss|pass|bright|satellite|clouds|launch|liftoff|pad|plume)\b/i, + label: 'the sky', + }, + { + id: 'safety', + re: /\b(safe|power|evac|ems|esu|rescue|fire trucks|medic|n95|aqi|gas)\b/i, + label: 'safety', + }, +]; +const DOUBT = + /\b(fake|source\??|not a|unconfirmed|from 20\d\d|last (year|spring)|isn’t|isn't|not evidence|stop|overstates|doesn’t|doesn't)\b/i; +const EVIDENCE = /\b(seal|sealed|sealing|clip|photo|capture)\b/i; + +const clip = (t, n = 90) => { + const s = String(t || '') + .replace(/\s+/g, ' ') + .trim(); + return s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s; +}; +const rankOf = (m) => + (m.corro || 0) * 2 + + (m.evidence ? 4 : 0) + + (m.witness ? 2 : 0) + + (m.tier?.index || 0) + + (m.mod ? 1 : 0); + +/** + * @param {object[]} msgs chat lines in the window (oldest first), each + * { id, n, handle, text, at, corro?, evidence?, witness?, tier?, mod? } + * @param {{ reactions?: Record, viewers?: number, since?: number }} o + * @returns {Promise} a Clerk summary + */ +export async function summarize(msgs, o = {}) { + const lines = msgs.filter((m) => m && m.text && !m.system); + if (lines.length < 5) return null; + const used = new Map(); // id → line + const cite = (m) => { + used.set(m.id, m); + return m.id; + }; + const bullets = []; + + // 1. What most of chat is about. + const counts = THEMES.map((t) => ({ + t, + hits: lines.filter((m) => t.re.test(m.text)), + })) + .filter((x) => x.hits.length >= 2) + .sort((a, b) => b.hits.length - a.hits.length); + if (counts[0]) { + const top = counts[0]; + const best = [...top.hits] + .sort((a, b) => rankOf(b) - rankOf(a)) + .slice(0, 2); + bullets.push({ + kind: 'theme', + text: `${top.hits.length} of ${lines.length} messages are about ${top.t.label}. Most-backed: “${clip(best[0].text, 80)}”${best[1] ? ` and “${clip(best[1].text, 60)}”` : ''}.`, + cites: best.map(cite), + }); + } + if (counts[1]) { + const x = counts[1]; + const best = [...x.hits].sort((a, b) => rankOf(b) - rankOf(a))[0]; + bullets.push({ + kind: 'theme', + text: `Also: ${x.t.label} (${x.hits.length}), e.g. “${clip(best.text, 70)}”.`, + cites: [cite(best)], + }); + } + + // 2. Evidence offered, and whether it reached the Record. + const ev = lines.filter( + (m) => m.evidence || (EVIDENCE.test(m.text) && m.witness), + ); + if (ev.length) { + const promoted = ev.filter((m) => m.promoted); + bullets.push({ + kind: 'evidence', + text: `${ev.length} ${ev.length === 1 ? 'person says they have' : 'people say they have'} a sealed capture${promoted.length ? `; ${promoted.length} ${promoted.length === 1 ? 'is' : 'are'} now in the Record` : '; none is in the Record yet'}.`, + cites: ev.slice(0, 3).map(cite), + }); + } + + // 3. Disputes: doubt lines, strongest first. + const doubt = lines + .filter((m) => DOUBT.test(m.text)) + .sort((a, b) => rankOf(b) - rankOf(a)); + if (doubt.length) + bullets.push({ + kind: 'disputed', + text: `Pushback: “${clip(doubt[0].text, 90)}”${doubt.length > 1 ? ` (${doubt.length} skeptical messages)` : ''}.`, + cites: doubt.slice(0, 2).map(cite), + }); + + // 4. Open questions. + const qs = lines.filter((m) => /\?\s*$/.test(m.text) && !m.mod); + if (qs.length) + bullets.push({ + kind: 'question', + text: `Unanswered in chat: “${clip(qs[qs.length - 1].text, 80)}”${qs.length > 1 ? ` and ${qs.length - 1} more` : ''}.`, + cites: qs.slice(-2).map(cite), + }); + + // 5. Mood, from reactions (counted, not cited lines). + const r = o.reactions || {}; + const rt = Object.values(r).reduce((a, b) => a + b, 0); + if (rt) + bullets.push({ + kind: 'mood', + text: `Reactions: ${['heart', 'eyes', 'wow', 'fire'] + .filter((k) => r[k]) + .map( + (k) => + `${r[k].toLocaleString()} ${k === 'eyes' ? 'eyes' : k === 'wow' ? 'wow' : k === 'fire' ? 'fire' : 'hearts'}`, + ) + .join( + ' · ', + )}${o.viewers ? ` from ${o.viewers.toLocaleString()} watching` : ''}.`, + cites: [], + }); + + if (!bullets.some((b) => b.cites.length)) return null; + const quotes = [...used.values()].map((m) => ({ + id: m.id, + n: m.n, + handle: m.handle, + text: m.text, + at: m.at, + sim: !!m.sim, + })); + const from = lines[0].at; + const to = lines[lines.length - 1].at; + const hash = await sha256Hex( + canonical({ + from, + to, + ids: lines.map((m) => m.id), + bullets: bullets.map((b) => b.text), + }), + ).catch(() => '00000000'); + return { + id: `clerk-${to.toString(36)}`, + kind: 'clerk', + at: Date.now(), + window: { from, to, n: lines.length }, + bullets, + quotes, + hash, + }; +} diff --git a/mobile/ui/composer.js b/mobile/ui/composer.js index 4d0e23817..2825f9cd1 100644 --- a/mobile/ui/composer.js +++ b/mobile/ui/composer.js @@ -52,10 +52,10 @@ export function createComposer({ function render() { const s = ctx.s; - const r = ctx.replyTo; - sheet.head.innerHTML = `
Room·${esc(s.title)}
-

${r ? 'Reply' : 'Post to the room'}

- ${r ? `
${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}
` : ''}`; + const r = ctx.replyTo || ctx.quote; + sheet.head.innerHTML = `
Record·${esc(s.title)}
+

${ctx.quote ? 'Quote post' : r ? 'Reply' : 'Post to the Record'}

+ ${r ? `
${ctx.quote ? 'Quoting · ' : ''}${esc(KIND[r.kind]?.name || '')} · ${esc(r.author.handle)}${esc(r.text.slice(0, 140))}${r.text.length > 140 ? '…' : ''}
` : ''}`; const places = placeOptions(); const t = getTime(); sheet.body.innerHTML = ` @@ -76,7 +76,7 @@ export function createComposer({
${icon('clock')}Anchor in time
${t ? `` : ''}
-
Stored on this device · shared live with your other open tabs
`; +
On the record · hash-chained in the room ledger · shared with your other open tabs
`; } function placeholder(kind) { @@ -89,10 +89,11 @@ export function createComposer({ }[kind]; } - function open(s, { replyTo = null, kind } = {}) { + function open(s, { replyTo = null, kind, quote = null } = {}) { ctx = { s, replyTo, + quote, kind: kind || (replyTo @@ -158,6 +159,7 @@ export function createComposer({ kind: ctx.kind, text, replyTo: ctx.replyTo?.id || null, + quoteOf: ctx.quote?.id || null, anchor, evidence: {}, }); @@ -170,5 +172,5 @@ export function createComposer({ } }); - return { open, close: () => sheet.close() }; + return { open, close: () => sheet.close(), root: sheet.root }; } diff --git a/mobile/ui/crews.js b/mobile/ui/crews.js new file mode 100644 index 000000000..427b1d67e --- /dev/null +++ b/mobile/ui/crews.js @@ -0,0 +1,617 @@ +/** + * Crews (DESIGN.md §12.6): persistent groups, like X communities. A crew + * has its own feed, members, rules and rooms, and can co-watch a Moment. + * + * Seeded crews and their posts are EXAMPLES: fictional handles (the same + * cast as the simulated chat), never real people. Joining, leaving and the + * person's own crew posts are real and stay on this device. + */ + +import { icon } from './icons.js'; +import { $, $$, h, esc, ago, haptic, toast, emitter } from './dom.js'; +import { createSheet } from './sheet.js'; +import { simTier } from './signal.js'; +import { personaOf } from './chatSim.js'; +import { richText } from './emotes.js'; +import { get, put } from '../capture/db.js'; + +const JOIN_KEY = 'gev.mobile.crews.v1'; + +export const CREWS = [ + { + id: 'nyc-scanner', + name: 'NYC Scanner Nerds', + tagline: 'Radio, rotors and rooftops. Five boroughs, receipts first.', + hue: '#a3d8ff', + glyph: 'waveform', + area: 'New York City', + lat: 40.7484, + lon: -73.9857, + members: 2841, + genres: ['circling', 'eye', 'air'], + moment: 'empire-state', + mods: ['@deskmod_rae', '@scanner_sal'], + roster: [ + '@scanner_sal', + '@freq_hopper', + '@onthe5th', + '@corner_deli_mo', + '@actually_tho', + '@longlens_ben', + '@flaps30', + '@lurker9', + ], + rules: [ + 'No names, faces, plates or addresses of private people. Ever.', + 'Scanner audio is a lead, not a source: post it as Context with the time and channel.', + 'Sealed or it’s a rumour. Unverified uploads get a note before they get a repost.', + 'Be first, be right, be kind to the new eyes.', + ], + feed: [ + { + h: '@deskmod_rae', + m: 2900, + k: 'context', + pin: true, + up: 212, + re: 14, + t: 'Rules refresher: no names, no plates, no addresses of private people. Scanner audio is a lead, not a source. Post it as Context with the time and the channel.', + }, + { + h: '@scanner_sal', + m: 22, + k: 'witness', + up: 188, + re: 41, + t: 'Aviation and ESU both up on Special Ops at 14:02. Three helos over Midtown on the map: two holding at ~1,500 ft, one lower doing tight lefts. Room is live, Moment is building.', + }, + { + h: '@onthe5th', + m: 17, + k: 'witness', + sealed: true, + up: 264, + re: 33, + t: 'Sealed a clip from 5th & 34th looking up. Two figures above the 86th floor deck, one holding a banner. Heading in the seal points straight at the mast. SealIt', + }, + { + h: '@actually_tho', + m: 12, + k: 'correction', + up: 97, + re: 8, + t: 'PSA: the “shots fired on 8th Ave” clip going around tonight is a 2021 fireworks video. Reverse search finds it. Note is already bridged in the room.', + }, + { + h: '@freq_hopper', + m: 190, + k: 'question', + up: 31, + re: 12, + t: 'Anyone know why the Citywide 1 feed drops out around 2 am most nights? Trunk maintenance, or something on my end?', + }, + { + h: '@longlens_ben', + m: 410, + k: 'context', + up: 58, + re: 5, + t: 'Best public vantage for Midtown orbits is the High Line at 30th. You get the ESB, Hudson Yards and the heliport approach in one frame.', + }, + ], + events: [ + { + id: 'cw-esb', + title: 'Replay: Climbers on the Empire State', + sub: 'with @scanner_sal annotating the radio timeline', + when: 'Tonight · 9:00 PM ET', + going: 41, + moment: 'empire-state', + }, + ], + }, + { + id: 'gulf-storm', + name: 'Gulf Storm Chasers', + tagline: + 'Radar loops, lightning parties and seawall reports from Brownsville to Key West.', + hue: '#8ab4f8', + glyph: 'bolt', + area: 'Gulf of Mexico', + lat: 28.6, + lon: -91.5, + members: 6120, + genres: ['storm', 'cyclone'], + mods: ['@radar_rina', '@nightdesk_mod'], + roster: [ + '@radar_rina', + '@cape_and_shear', + '@wxdan', + '@harborkid_22', + '@ridge_rd_amy', + '@buffering_bev', + '@k8_mobile', + ], + rules: [ + 'NWS and local officials make the calls. We add eyes, not alerts.', + 'Post radar with its timestamp. A loop without a time is a vibe, not data.', + 'Flood photos must be sealed or carry a place and time.', + 'Don’t drive into water for content.', + ], + feed: [ + { + h: '@radar_rina', + m: 35, + k: 'context', + pin: true, + up: 341, + re: 27, + t: 'Squall line over the central Gulf is the most electric thing on the map tonight. Lightning density near the leading edge is top-decile for October. Room is live, come hang.', + }, + { + h: '@cape_and_shear', + m: 18, + k: 'prediction', + up: 122, + re: 19, + t: 'Houston cell has a 60 dBZ core over the Ship Channel, moving east ~25 mph. If you’re on I-10 east of downtown, it’s on you in about 20 minutes. StormMode', + }, + { + h: '@harborkid_22', + m: 9, + k: 'witness', + sealed: true, + up: 176, + re: 22, + t: 'Galveston seawall at 61st: waves topping the wall at high tide, spray over the road. Sealed clip, 40 s.', + }, + { + h: '@wxdan', + m: 64, + k: 'question', + up: 44, + re: 16, + t: 'Anyone on the Bolivar ferry with a barometer app? Want a ground-truth pressure to compare against the buoy.', + }, + { + h: '@buffering_bev', + m: 120, + k: 'context', + up: 63, + re: 9, + t: 'First lightning party in the Live lane and I have never seen chat move this fast. 10/10 would refresh again.', + }, + ], + events: [ + { + id: 'cw-gulf', + title: 'Live: Gulf lightning party', + sub: 'Live lane open all night, radar on', + when: 'Tonight · from 8:00 PM CT', + going: 233, + genre: 'storm', + }, + ], + }, + { + id: 'novosel', + name: 'Fort Novosel Alumni', + tagline: + 'Rotor school grads, instructors and the people who live under the pattern.', + hue: '#ffb547', + glyph: 'sky', + area: 'Fort Novosel, Alabama', + lat: 31.33, + lon: -85.72, + members: 1307, + genres: ['heli'], + mods: ['@heavy_tess'], + roster: [ + '@heavy_tess', + '@flaps30', + '@atc_listener', + '@medic_off_shift', + '@first_time_here', + ], + rules: [ + 'No tail numbers of private aircraft. Training fleet is fine.', + 'What happens in the pattern is training. Explain it before you alarm anyone.', + 'Old stories welcome. Embellishment noted.', + ], + feed: [ + { + h: '@heavy_tess', + m: 26, + k: 'witness', + up: 89, + re: 11, + t: 'Fourteenth circuit of the day on the map. Brings me right back. Stage field is busy: at least six in the pattern right now. HeliSpin', + }, + { + h: '@flaps30', + m: 21, + k: 'context', + pin: true, + up: 154, + re: 17, + t: 'For the new folks in the room: the “emergency” people keep asking about is an autorotation. It’s practice. The flare at the bottom is the fun part.', + }, + { + h: '@first_time_here', + m: 15, + k: 'question', + up: 12, + re: 6, + t: 'Why do they all fly the same rectangle over and over?', + }, + { + h: '@atc_listener', + m: 14, + k: 'context', + up: 40, + re: 2, + t: 'It’s a traffic pattern: takeoff, crosswind, downwind, base, final. Repeating it is how you get good at the hard part, which is the last 50 ft.', + }, + { + h: '@heavy_tess', + m: 2600, + k: 'context', + up: 71, + re: 23, + t: 'Class reunion in Enterprise in March. Who’s in? The boll weevil statue is still there.', + }, + ], + events: [ + { + id: 'cw-rotor', + title: 'Rotor school at 600×', + sub: 'A full day of pattern work, replayed with commentary', + when: 'Saturday · 10:00 AM CT', + going: 58, + genre: 'heli', + }, + ], + }, + { + id: 'orbit', + name: 'Orbit Watchers', + tagline: + 'Passes, launches, reentries and the occasional very patient explanation of what that light was.', + hue: '#b39dff', + glyph: 'orbit', + area: 'Everywhere, looking up', + lat: 25.99, + lon: -97.15, + members: 9480, + genres: ['orbit', 'launch'], + mods: ['@tle_tom'], + roster: [ + '@tle_tom', + '@pass_predictor', + '@longlens_ben', + '@pigeonwatch', + '@just_scrolling', + '@lagspike', + ], + rules: [ + '“UFO” threads get a Context post with the TLE match. Be kind, everyone starts somewhere.', + 'Pass times in local time and UTC.', + 'Respect launch exclusion zones. No drone footage.', + ], + feed: [ + { + h: '@tle_tom', + m: 48, + k: 'context', + pin: true, + up: 402, + re: 31, + t: 'ISS pass tonight: max elevation 71°, about six minutes horizon to horizon. Start looking WSW. It’s the bright one that doesn’t blink. EyesUp', + }, + { + h: '@pass_predictor', + m: 33, + k: 'prediction', + up: 118, + re: 14, + t: 'Starlink train from yesterday’s launch will be visible about 40 minutes after sunset, low in the north-west. It fades within a week as they raise orbit.', + }, + { + h: '@longlens_ben', + m: 700, + k: 'witness', + sealed: true, + up: 233, + re: 19, + t: 'Sealed a 6 s exposure of last night’s pass over the harbour. The streak ends where the station went into Earth’s shadow, not where it “vanished”.', + }, + { + h: '@pigeonwatch', + m: 300, + k: 'question', + up: 28, + re: 11, + t: 'Saw a row of lights moving in a line last night. Please tell me that’s Starlink and not my sanity.', + }, + { + h: '@tle_tom', + m: 295, + k: 'context', + up: 87, + re: 3, + t: '@pigeonwatch That’s Starlink: a fresh batch still in a low train. Your sanity is fine. LiftOff', + }, + ], + events: [ + { + id: 'cw-launch', + title: 'Watch party: launch window at Starbase', + sub: 'Live lane, countdown, LiftOff spam encouraged', + when: 'Window opens 6:30 PM CT', + going: 1204, + genre: 'launch', + }, + ], + }, +]; +export const CREW = Object.fromEntries(CREWS.map((c) => [c.id, c])); + +const fmtM = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); + +export function crewAvatar(c, cls = '') { + return `${icon(c.glyph)}`; +} + +export function createCrews(core) { + const ev = emitter(); + // First run: you start in one crew so the Rooms tab shows how it works. + let joined = new Set(['nyc-scanner']); + try { + const raw = localStorage.getItem(JOIN_KEY); + if (raw) joined = new Set(JSON.parse(raw)); + } catch { + /* ignore */ + } + const saveJoin = () => { + try { + localStorage.setItem(JOIN_KEY, JSON.stringify([...joined])); + } catch { + /* ignore */ + } + }; + let own = {}; // crew id → posts + const ready = get('kv', 'crewPosts') + .then((r) => (own = r?.posts || {})) + .catch(() => {}); + + const isJoined = (id) => joined.has(id); + function toggle(id) { + if (joined.has(id)) joined.delete(id); + else joined.add(id); + saveJoin(); + ev.emit('change'); + return joined.has(id); + } + async function postTo(id, text) { + await ready; + const p = { + id: `crew-${id}-${Date.now().toString(36)}`, + h: core.rt.me.handle, + at: Date.now(), + k: 'context', + t: text, + up: 0, + re: 0, + self: true, + }; + own[id] = [p, ...(own[id] || [])].slice(0, 50); + await put('kv', { id: 'crewPosts', posts: own }).catch(() => {}); + ev.emit('change'); + return p; + } + const feedOf = (c) => { + const now = Date.now(); + const seeded = c.feed.map((p, i) => ({ + ...p, + id: `${c.id}-ex${i}`, + at: now - p.m * 60e3, + example: true, + })); + return [...(own[c.id] || []), ...seeded].sort( + (a, b) => (b.pin ? 1 : 0) - (a.pin ? 1 : 0) || b.at - a.at, + ); + }; + + /* ---------------- Sheet ---------------- */ + const sheet = createSheet({ + label: 'Crew', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'crew-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.92, 900)) }, + }); + let cur = null; + let tab = 'feed'; + + function postHtml(p) { + const per = personaOf(p.h); + const tier = p.self ? core.signal.tier() : simTier(p.h, per?.floor || 0); + const mod = cur.mods.includes(p.h); + return `
+
${esc(p.h.slice(1, 3).toUpperCase())} + ${esc(p.h)}${mod ? 'Mod' : ''}${esc(tier.name)}${esc(ago(p.at))} + ${p.pin ? `${icon('pin')}Pinned` : p.example ? 'Example' : ''}
+

${richText(p.t, { me: core.rt.me.handle }).html}

+ ${p.sealed ? `
${icon('sealFill')}Sealed
` : ''} +
${icon('agree')}${p.up}${icon('reply')}${p.re}${esc(p.k)}
+
`; + } + + async function rooms() { + const sits = (await core.situations?.().catch(() => [])) || []; + const { chatGenre, viewerCount } = await import('./chatSim.js'); + return sits + .filter((s) => cur.genres.includes(chatGenre(s))) + .slice(0, 4) + .map((s) => ({ s, v: viewerCount(s) })); + } + + async function renderBody() { + const c = cur; + if (tab === 'feed') { + await ready; + sheet.body.innerHTML = `${ + isJoined(c.id) + ? `
` + : `
${icon('users')}Join to post. Anyone can read.
` + }${feedOf(c).map(postHtml).join('')}

Seeded posts are examples by fictional members. Your posts stay on this device.

`; + } else if (tab === 'watch') { + const rs = await rooms(); + sheet.body.innerHTML = `
${icon('play')}Co-watch
+ ${c.events + .map( + ( + e, + ) => `
${esc(e.when)} · ${e.going.toLocaleString()} going
${esc(e.title)}${esc(e.sub)} +
Example
`, + ) + .join('')} +
${icon('now')}Crew rooms live now
+ ${ + rs.length + ? rs + .map( + ({ s, v }) => + ``, + ) + .join('') + : '
No live rooms for this crew right now. Nothing unusual is a valid state.
' + }`; + sheet.body._rooms = rs; + } else if (tab === 'members') { + const list = [...new Set([...c.mods, ...c.roster])]; + sheet.body.innerHTML = `

${icon('sparkles')}Ordered by Signal. Crews show what members got right, never follower counts.

+ ${list + .map((x) => ({ + x, + t: simTier(x, personaOf(x)?.floor || 0), + p: personaOf(x), + })) + .sort((a, b) => b.t.min - a.t.min) + .map( + ({ x, t, p }) => + `
${esc(x.slice(1, 3).toUpperCase())}${esc(x)}${esc(t.name)}${p?.domain ? ` · ${esc(p.domain)}` : ''}${c.mods.includes(x) ? ' · Mod' : ''}×${t.weight.toFixed(1)}
`, + ) + .join('')} + ${isJoined(c.id) ? `
YOU${esc(core.rt.me.handle)}${esc(core.signal.tier().name)} · you×${core.signal.tier().weight.toFixed(1)}
` : ''} +

Fictional members (example). ${fmtM(c.members)} members in the seeded crew.

`; + } else { + sheet.body.innerHTML = `
    ${c.rules.map((r) => `
  1. ${esc(r)}
  2. `).join('')}
+

Every crew inherits the line we don’t cross: we track things that broadcast, never people. No face recognition, no person search.

`; + } + } + + function renderHead() { + const c = cur; + const j = isJoined(c.id); + sheet.head.innerHTML = `
+ ${crewAvatar(c, 'lg')} +
${esc(c.area)} · Crew Example

${esc(c.name)}

${esc(c.tagline)}

+
${c.roster + .slice(0, 4) + .map( + (x) => + `${esc(x.slice(1, 3).toUpperCase())}`, + ) + .join('')}${fmtM(c.members)} members
+ +
+
${['feed', 'watch', 'members', 'rules'].map((t) => ``).join('')}
`; + } + + async function open(id, { tab: t = 'feed' } = {}) { + cur = CREW[id]; + if (!cur) return; + sheet.root.parentElement?.appendChild(sheet.root); + tab = t; + renderHead(); + sheet.body.innerHTML = ''; + sheet.open('large'); + await renderBody(); + } + + sheet.root.addEventListener('click', async (e) => { + if (!cur) return; + if (e.target.closest('[data-join]')) { + const on = toggle(cur.id); + haptic(on ? 'success' : 'tick'); + toast(on ? `Joined ${cur.name}` : `Left ${cur.name}`, { + icon: icon('users'), + }); + renderHead(); + await renderBody(); + return; + } + const ct = e.target.closest('[data-ctab]'); + if (ct) { + tab = ct.dataset.ctab; + $$('[data-ctab]', sheet.head).forEach((b) => + b.setAttribute('aria-selected', String(b === ct)), + ); + haptic('tick'); + await renderBody(); + return; + } + const cw = e.target.closest('[data-cowatch]'); + if (cw) { + const evt = cur.events.find((x) => x.id === cw.dataset.cowatch); + const crew = cur; + sheet.close(); + if (evt?.moment) { + const m = await core.moment(evt.moment); + core.openRoom(m, { lane: 'live', crew }); + } else { + const sits = (await core.situations?.().catch(() => [])) || []; + const { chatGenre } = await import('./chatSim.js'); + const s = sits.find((x) => chatGenre(x) === evt?.genre) || sits[0]; + if (s) core.openRoom(s, { lane: 'live', crew }); + } + return; + } + const rm = e.target.closest('[data-room]'); + if (rm) { + const x = (sheet.body._rooms || []).find( + (y) => String(y.s.id) === rm.dataset.room, + ); + const crew = cur; + sheet.close(); + if (x) core.openRoom(x.s, { lane: 'live', crew }); + } + }); + sheet.root.addEventListener('submit', async (e) => { + const f = e.target.closest('.cp-compose'); + if (!f) return; + e.preventDefault(); + const inp = $('input', f); + const t = inp.value.trim(); + if (!t) return; + await postTo(cur.id, t); + haptic('success'); + toast(`Posted to ${cur.name}`, { icon: icon('users') }); + await renderBody(); + }); + + return { + on: ev.on, + list: () => CREWS.map((c) => ({ ...c, joined: isJoined(c.id) })), + isJoined, + toggle, + open, + feedOf, + postTo, + }; +} + +export { fmtM }; diff --git a/mobile/ui/emotes.js b/mobile/ui/emotes.js new file mode 100644 index 000000000..9fda01d3c --- /dev/null +++ b/mobile/ui/emotes.js @@ -0,0 +1,126 @@ +/** + * Chat emotes and reaction glyphs, hand-drawn as inline SVG so they look + * the same on every platform (no emoji fonts). Emote codes are typed as + * words, Twitch-style: "SealIt", "o7", "HeliSpin". + */ + +import { esc } from './dom.js'; + +const S = (body, vb = '0 0 32 32') => + ``; + +/** Reaction glyphs (the heart stream). Filled, with a soft highlight. */ +export const REACT = { + heart: { + label: 'Heart', + color: '#ff5a7a', + svg: S( + ``, + ), + }, + fire: { + label: 'Fire', + color: '#ff8a3d', + svg: S( + ``, + ), + }, + eyes: { + label: 'Eyes', + color: '#a3d8ff', + svg: S( + ``, + ), + }, + wow: { + label: 'Wow', + color: '#ffd166', + svg: S( + ``, + ), + }, +}; +export const REACT_KINDS = ['heart', 'fire', 'eyes', 'wow']; + +/** Chat emotes. */ +export const EMOTES = { + SealIt: { + hint: 'Seal it or it didn’t happen', + svg: S( + ``, + ), + }, + o7: { + hint: 'Salute', + svg: S( + ``, + ), + }, + EyesUp: { + hint: 'Look up', + svg: S( + ``, + ), + }, + HeliSpin: { + hint: 'Helicopter circling', + svg: S( + ``, + ), + }, + Receipts: { + hint: 'Show the receipts', + svg: S( + ``, + ), + }, + Doubt: { + hint: 'Doubt', + svg: S( + ``, + ), + }, + StormMode: { + hint: 'Storm mode', + svg: S( + ``, + ), + }, + LiftOff: { + hint: 'Launch', + svg: S( + ``, + ), + }, +}; +export const EMOTE_CODES = Object.keys(EMOTES); + +export const reactSvg = (kind) => (REACT[kind] || REACT.heart).svg; + +/** + * Chat line → HTML: escapes text, turns emote codes into glyphs and + * @mentions into highlighted handles. + * @param {string} text + * @param {{ me?: string }} o + */ +export function richText(text, { me = '' } = {}) { + const parts = String(text || '').split(/(\s+)/); + let mentionsMe = false; + const html = parts + .map((w) => { + if (/^\s+$/.test(w)) return w; + const bare = w.replace(/[.,!?]+$/, ''); + const tail = w.slice(bare.length); + if (EMOTES[bare]) + return `${EMOTES[bare].svg}${esc(tail)}`; + if (/^@[\w.-]{2,}$/.test(bare)) { + const mine = + me && bare.toLowerCase() === String(me).toLowerCase().trim(); + if (mine) mentionsMe = true; + return `${esc(bare)}${esc(tail)}`; + } + return esc(w); + }) + .join(''); + return { html, mentionsMe }; +} diff --git a/mobile/ui/liveChat.js b/mobile/ui/liveChat.js new file mode 100644 index 000000000..42868a19f --- /dev/null +++ b/mobile/ui/liveChat.js @@ -0,0 +1,1120 @@ +/** + * The Live lane (DESIGN.md §12.6): Twitch chat on the evidence. + * + * One hub per room keeps the chat log, the simulated crowd, the reaction + * tallies, corroborations and the Clerk's timer, so closing and reopening a + * room feels like walking back into a party that kept going. Views attach to + * a hub: the room sheet's Live lane and the Moment player's overlay. + * + * Real people: the person's own lines, and lines from other tabs over + * BroadcastChannel. Everything else is simulated and labelled as such. + */ + +import { icon } from './icons.js'; +import { $, $$, h, esc, haptic, toast, reducedMotion, emitter } from './dom.js'; +import { fmtKm, utc, distKm } from './bands.js'; +import { + createChatSim, + viewerCount, + worldPoint, + chatGenre, + momentChat, + personaOf, + makeMsg, + PERSONAS, +} from './chatSim.js'; +import { + REACT, + REACT_KINDS, + EMOTES, + EMOTE_CODES, + reactSvg, + richText, +} from './emotes.js'; +import { simTier, tierOf, hashStr } from './signal.js'; +import { summarize } from './clerk.js'; +import { get, put } from '../capture/db.js'; + +const CLERK_FIRST_MS = 45e3; +const CLERK_EVERY_MS = 3 * 60e3; +const PROMOTE_AT = 6; // Signal-weighted corroboration that promotes a line +const MAX_LOG = 400; +const MAX_DOM = 160; + +/* ---------------- Globe reactions (feature-detected) ---------------- */ + +export function globeReact(globe, p) { + const fn = globe?.react || globe?.raw?.react; + if (typeof fn !== 'function') return false; + try { + fn.call(globe?.react ? globe : globe.raw, p); + return true; + } catch { + return false; + } +} +function globeHeat(globe, pts) { + const fn = globe?.attentionHeat || globe?.raw?.attentionHeat; + if (typeof fn !== 'function') return; + try { + fn.call(globe?.attentionHeat ? globe : globe.raw, pts); + } catch { + /* optional */ + } +} + +/** A rough home from the time zone, when location is not shared. */ +const TZ = { + 'America/Los_Angeles': [34.05, -118.24], + 'America/Denver': [39.74, -104.99], + 'America/Chicago': [41.88, -87.63], + 'America/New_York': [40.71, -74.01], + 'America/Toronto': [43.65, -79.38], + 'America/Sao_Paulo': [-23.55, -46.63], + 'Europe/London': [51.51, -0.13], + 'Europe/Paris': [48.86, 2.35], + 'Europe/Berlin': [52.52, 13.4], + 'Asia/Tokyo': [35.68, 139.69], + 'Asia/Kolkata': [19.08, 72.88], + 'Asia/Singapore': [1.35, 103.82], + 'Australia/Sydney': [-33.87, 151.21], +}; +export function guessLoc() { + try { + const z = Intl.DateTimeFormat().resolvedOptions().timeZone; + const v = TZ[z]; + if (v) return { lat: v[0], lon: v[1], guessed: true }; + } catch { + /* ignore */ + } + return { lat: 37.77, lon: -122.42, guessed: true }; +} + +/* ---------------- The hub ---------------- */ + +/** + * @param {object} core social core (globe, rt, rooms, signal, needs, ev) + * @param {object} s situation (or a Moment adapted to one) + */ +export function createRoomHub(core, s) { + const ev = emitter(); + const id = String(s.id); + const log = []; + let n = 0; + let views = 0; + let stopTimer = 0; + let clerkTimer = 0; + let lastClerkAt = 0; + const clerk = []; + // Tallies start where a room this size would be after a few minutes. + const v0 = viewerCount(s); + const reactions = { + heart: Math.round(v0 * 0.21), + fire: Math.round(v0 * 0.05), + eyes: Math.round(v0 * 0.12), + wow: Math.round(v0 * 0.08), + }; + let windowReactions = { heart: 0, fire: 0, eyes: 0, wow: 0 }; + let viewers = viewerCount(s); + const corroBy = new Map(); // msg id → Set(handles) + let autoPromoted = 0; + let firstEyes = null; + const genre = chatGenre(s); + + const sim = createChatSim(s, { + viewers, + onMessage: (m) => push(m), + onBackfill: (list) => list.forEach((m) => push(m, { quiet: true })), + onReactions: (kind, count, burst) => crowd(kind, count, burst), + onViewers: (v) => { + viewers = v; + ev.emit('viewers', v); + }, + onBurst: () => ev.emit('burst'), + }); + + function push(m, { quiet = false } = {}) { + if (!m || !m.text) return null; + if (log.some((x) => x.id === m.id)) return null; + n += 1; + const msg = { corro: 0, ...m, n }; + log.push(msg); + if (log.length > MAX_LOG) log.splice(0, log.length - MAX_LOG); + // Simulated crowd corroborates evidence lines over the next seconds. + if (msg.sim && msg.evidence && !quiet) simCorroborate(msg); + ev.emit('msg', { msg, quiet }); + return msg; + } + + function simCorroborate(msg) { + let k = 0; + const tick = () => { + if (!sim.running || k > 6) return; + k += 1; + const pool = [ + ...PERSONAS.local, + ...PERSONAS.photo, + ...PERSONAS.pilot, + ...PERSONAS.wx, + ...PERSONAS.newbie, + ]; + const p = pool[Math.floor(Math.random() * pool.length)]; + if (p) addCorro(msg, p.handle, simTier(p.handle, p.floor).weight); + setTimeout(tick, 1800 + Math.random() * 3200); + }; + setTimeout(tick, 2500 + Math.random() * 2500); + } + + function addCorro(msg, handle, w = 1) { + const set = corroBy.get(msg.id) || new Set(); + if (set.has(handle)) return false; + set.add(handle); + corroBy.set(msg.id, set); + msg.corro = Math.round(((msg.corro || 0) + w) * 10) / 10; + msg.corroN = set.size; + ev.emit('update', msg); + if ( + msg.sim && + msg.evidence && + msg.corro >= PROMOTE_AT && + !msg.promoted && + autoPromoted < 1 + ) { + autoPromoted += 1; + promote(msg, { auto: true }).catch(() => {}); + } + return true; + } + + /* ---- reactions ---- */ + function at(lonlat, jitter = 0) { + return { + lon: lonlat.lon + (Math.random() - 0.5) * jitter, + lat: lonlat.lat + (Math.random() - 0.5) * jitter, + }; + } + function crowd(kind, count, burst) { + reactions[kind] += count; + windowReactions[kind] += count; + // Locals near the situation, the rest of the world wherever it is. + const local = Math.round(count * 0.35); + if (local && Number.isFinite(s.lat)) + globeReact(core.globe, { + ...at(s, burst ? 0.12 : 0.05), + kind, + count: local, + }); + let rest = count - local; + for (let i = 0; i < 3 && rest > 0; i += 1) { + const c = i === 2 ? rest : Math.ceil(rest / 2); + rest -= c; + globeReact(core.globe, { ...worldPoint(), kind, count: c }); + } + ev.emit('react', { kind, count, crowd: true, burst }); + } + function react(kind, { remote = false } = {}) { + reactions[kind] += 1; + windowReactions[kind] += 1; + const loc = remote ? worldPoint() : core.myLoc(); + globeReact(core.globe, { lon: loc.lon, lat: loc.lat, kind, count: 1 }); + if (!remote) core.rt.react(id, kind); + ev.emit('react', { kind, count: 1, self: !remote, remote }); + } + + /* ---- the person ---- */ + function meMsg(text) { + const score = core.signal.cached; + return { + id: `me-${core.rt.tab}-${Date.now().toString(36)}`, + handle: core.rt.me.handle, + text, + at: Date.now(), + self: true, + tier: tierOf(score), + witness: !!core.myWitness?.(id), + first: firstEyes?.self || false, + }; + } + function send(text) { + const t = String(text || '') + .trim() + .slice(0, 300); + if (!t) return null; + const m = push(meMsg(t)); + if (m) { + core.rt.chat(id, { ...m, self: false }); + sim.hear(t, core.rt.me.handle); + } + return m; + } + function corroborate(msg) { + const w = tierOf(core.signal.cached).weight; + const ok = addCorro(msg, core.rt.me.handle, w); + if (ok) core.rt.corro(id, msg.id, w); + return ok; + } + + /** Bridge: a Live line becomes a Record post. */ + async function promote(msg, { auto = false } = {}) { + if (msg.promoted) return msg.promoted; + const eligible = msg.evidence || (msg.corro || 0) >= 3; + if (!eligible && !auto) return null; + // The same words promoted twice are one record. + const existing = (await core.rooms.room(s)).posts.find( + (p) => p.promotedFrom && p.promotedFrom.text === msg.text, + ); + if (existing) { + msg.promoted = existing.id; + ev.emit('update', msg); + return existing.id; + } + const persona = personaOf(msg.handle); + const kind = msg.evidence + ? 'witness' + : /\?\s*$/.test(msg.text) + ? 'question' + : 'context'; + const post = await core.rooms.post(s, { + id: `${id}~promo~${hashStr(`${msg.handle}|${msg.text}`).toString(36)}`, + kind, + text: msg.text, + at: msg.at, + author: msg.self + ? undefined + : { + handle: msg.handle, + sim: !!msg.sim, + example: !!msg.sim, + domain: persona?.domain || msg.domain || undefined, + local: !!(persona?.local || msg.local), + }, + evidence: msg.evidence + ? { + sealed: true, + wasThere: Number.isFinite(msg.evidence.km) + ? msg.evidence.km < 5 + : true, + distanceKm: msg.evidence.km ?? null, + example: !!msg.sim, + } + : {}, + votes: { up: msg.corroN || 0, dn: 0 }, + promotedFrom: { + msgId: msg.id, + n: msg.n, + handle: msg.handle, + text: msg.text, + at: msg.at, + corro: msg.corro || 0, + corroN: msg.corroN || 0, + auto, + sim: !!msg.sim, + }, + }); + msg.promoted = post.id; + ev.emit('update', msg); + push({ + id: `sys-promo-${msg.id}`, + system: true, + handle: 'Record', + text: `${auto ? 'Promoted by corroboration' : 'Promoted'}: ${msg.handle}’s ${msg.evidence ? 'sealed capture' : 'line'} is now in the Record (${(msg.corro || 0).toFixed(1)} Signal-weighted).`, + at: Date.now(), + ref: post.id, + }); + if (msg.self) + core.signal.award({ + id: `promoted:${post.id}`, + rule: 'promoted', + text: `Your chat line was promoted to the Record in ${s.title}`, + ref: { room: id, post: post.id }, + }); + core.ev.emit('promote', { room: id, post, msg }); + return post.id; + } + + /* ---- clerk ---- */ + async function runClerk({ backdate = 0 } = {}) { + const since = lastClerkAt || 0; + const lines = log.filter((m) => m.at > since && !m.system); + const sum = await summarize(lines.slice(-120), { + reactions: windowReactions, + viewers, + }); + if (!sum) return null; + if (backdate) sum.at -= backdate; + lastClerkAt = Date.now(); + windowReactions = { heart: 0, fire: 0, eyes: 0, wow: 0 }; + sum.room = id; + clerk.unshift(sum); + clerk.splice(6); + put('kv', { id: `clerk:${id}`, list: clerk }).catch(() => {}); + push({ + id: `sys-clerk-${sum.id}`, + system: true, + clerk: true, + handle: 'Clerk', + text: `Posted “What chat is saying” to the Record, citing ${sum.quotes.length} lines.`, + at: Date.now(), + ref: sum.id, + }); + ev.emit('clerk', sum); + core.ev.emit('clerk', sum); + return sum; + } + function scheduleClerk(ms) { + clearTimeout(clerkTimer); + clerkTimer = setTimeout(async () => { + await runClerk(); + scheduleClerk(CLERK_EVERY_MS); + }, ms); + } + + /* ---- lifecycle ---- */ + let loaded = false; + async function load() { + if (loaded) return; + loaded = true; + const rec = await get('kv', `clerk:${id}`).catch(() => null); + if (rec?.list) + clerk.push(...rec.list.filter((x) => !clerk.some((c) => c.id === x.id))); + computeFirst(); + } + async function computeFirst() { + try { + const r = await core.rooms.room(s); + const sealed = r.posts + .filter((p) => p.evidence?.sealed && p.evidence?.wasThere) + .sort((a, b) => (a.anchor?.t || a.at) - (b.anchor?.t || b.at)); + const p = sealed[0]; + firstEyes = p + ? { + handle: p.author.handle, + at: p.anchor?.t || p.at, + km: p.evidence.distanceKm, + example: !!(p.example || p.author?.example), + self: !!p.author?.self, + post: p.id, + } + : null; + if (s.momentCaptures?.length) { + const fc = + s.momentCaptures.find((c) => c.firstEyes) || + [...s.momentCaptures].sort((a, b) => a.t - b.t)[0]; + if (fc && (!firstEyes || fc.t < firstEyes.at)) + firstEyes = { + handle: fc.handle, + at: fc.t, + km: Number.isFinite(fc.lat) ? distKm(fc, s) : null, + example: true, + }; + } + ev.emit('first', firstEyes); + } catch { + /* ignore */ + } + } + + function start() { + clearTimeout(stopTimer); + if (sim.running) return; + sim.start(); + load().then(async () => { + // One summary from what was already said, so the Record has one. + if (!clerk.length) await runClerk({ backdate: 95e3 }); + scheduleClerk(CLERK_FIRST_MS); + }); + const pts = []; + for (let i = 0; i < 40; i += 1) pts.push({ ...worldPoint(), weight: 0.5 }); + if (Number.isFinite(s.lat)) + for (let i = 0; i < 20; i += 1) pts.push({ ...at(s, 0.3), weight: 1 }); + globeHeat(core.globe, pts); + } + function stop() { + sim.stop(); + clearTimeout(clerkTimer); + globeHeat(core.globe, null); + } + + return { + id, + s, + genre, + log, + clerk, + reactions, + on: ev.on, + off: ev.off, + push, + send, + react, + corroborate, + promote, + runClerk, + addCorro, + get viewers() { + return viewers + Math.max(0, core.rt.presence(id).length - 1); + }, + get firstEyes() { + return firstEyes; + }, + refreshFirst: computeFirst, + attach() { + views += 1; + start(); + }, + detach() { + views = Math.max(0, views - 1); + if (!views) { + clearTimeout(stopTimer); + stopTimer = setTimeout(stop, 15000); + } + }, + burst: () => sim.burst(), + corroCount: (mid) => corroBy.get(mid)?.size || 0, + meCorroborated: (mid) => !!corroBy.get(mid)?.has(core.rt.me.handle), + }; +} + +/* ---------------- Rendering ---------------- */ + +const fmtN = (v) => + v >= 10000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toLocaleString(); + +export function badgesHtml(m) { + const b = []; + if (m.mod) + b.push(`${icon('lock')}`); + if (m.first) + b.push( + `${icon('eyes')}`, + ); + if (m.witness || m.evidence) + b.push( + `${icon('sealFill')}`, + ); + if (m.domain) + b.push( + `${esc(m.domain.split(' ')[0].slice(0, 5))}`, + ); + return b.join(''); +} + +export function lineHtml(m, me) { + if (m.system) + return `
${icon(m.clerk ? 'sparkles' : 'sendUp')}${esc(m.text)}${m.ref ? `` : ''}
`; + const tier = m.tier || simTier(m.handle); + const { html, mentionsMe } = richText(m.text, { me }); + const cls = [ + 'cl', + m.self ? 'is-me' : '', + m.remote ? 'is-remote' : '', + mentionsMe ? 'mentions-me' : '', + m.witness || m.evidence ? 'is-witness' : '', + m.promoted ? 'is-promoted' : '', + m.evidence ? 'has-ev' : '', + ] + .filter(Boolean) + .join(' '); + const corro = m.corro + ? `${icon('agree')}${m.corro.toFixed(1)}` + : ''; + return `
+ ${badgesHtml(m)}${esc(m.handle.replace(/^@/, ''))}: ${html}${m.evidence ? `${icon('sealFill')}${Number.isFinite(m.evidence.km) ? esc(fmtKm(m.evidence.km)) : 'SEALED'}` : ''}${corro}${m.promoted ? `${icon('sendUp')}Record` : ''}${m.remote ? 'other tab' : ''} +
`; +} + +function actionsHtml(m, hub) { + const can = m.evidence || (m.corro || 0) >= 3; + const mine = hub.meCorroborated(m.id); + return `
+ + + + ${m.sim ? 'Simulated' : m.self ? 'You' : m.remote ? 'Real · other tab' : ''} · ${esc(utc(new Date(m.at)))}${m.tier ? ` · ${esc(m.tier.name)}` : ''} +
`; +} + +/** + * Mount the Live lane into a sheet body + dock. + * @param {object} core + * @param {ReturnType} hub + * @param {{ body: HTMLElement, dock: HTMLElement, onRecord?(ref), onNeedEyes?(), onCapture?() }} o + */ +export function mountLiveLane(core, hub, o) { + const me = core.rt.me.handle; + const root = + h(`
+
+
+ + +
`); + const dockEl = h(`
+ +
+ ${REACT_KINDS.map((k) => ``).join('')} + + + +
+
+ + + +
+
Simulated chat · demo— watching
+
`); + o.body.appendChild(root); + o.dock.appendChild(dockEl); + const list = $('.lc-list', root); + const pins = $('.lc-pins', root); + const floatL = $('.lc-float', root); + const more = $('.lc-more', root); + const input = $('input', dockEl); + const pop = $('.lc-pop', dockEl); + const sendBtn = $('.lc-send', dockEl); + let stuck = true; + let unread = 0; + let openId = null; + let coolUntil = 0; + + /* ---- pins: First eyes + Need eyes ---- */ + function renderPins() { + const f = hub.firstEyes; + const need = core.needs?.forRoom?.(hub.id)?.[0]; + pins.innerHTML = `${ + f + ? `
${icon('sealFill')}First eyes ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}${f.example ? 'Example' : ''}
` + : `
${icon('seal')}No first eyes yet. The first sealed capture from here gets the byline.
` + }${ + need + ? `` + : '' + }`; + } + renderPins(); + const offFirst = hub.on('first', renderPins); + const offNeed = core.needs?.on?.('change', renderPins); + + /* ---- lines ---- */ + function atBottom() { + return list.scrollHeight - list.scrollTop - list.clientHeight < 48; + } + function scrollDown() { + list.scrollTop = list.scrollHeight; + } + function add(m, { quiet } = {}) { + const el = h(lineHtml(m, me)); + if (!quiet && !reducedMotion()) el.classList.add('in'); + list.appendChild(el); + while (list.childElementCount > MAX_DOM) list.firstElementChild.remove(); + if (stuck) scrollDown(); + else if (!quiet) { + unread += 1; + more.hidden = false; + $('span', more).textContent = + `${unread} new message${unread === 1 ? '' : 's'}`; + } + if (!quiet && el.classList.contains('mentions-me')) haptic('light'); + } + for (const m of hub.log.slice(-80)) add(m, { quiet: true }); + requestAnimationFrame(scrollDown); + const offMsg = hub.on('msg', ({ msg, quiet }) => add(msg, { quiet })); + const offUpd = hub.on('update', (m) => { + const el = list.querySelector(`[data-msg="${CSS.escape(m.id)}"]`); + if (!el) return; + const wasOpen = openId === m.id; + const nel = h(lineHtml(m, me)); + el.replaceWith(nel); + if (wasOpen) openActions(nel, m); + if (m.corro) { + const c = $('.cl-corro', nel); + c?.classList.add('bump'); + } + }); + list.addEventListener( + 'scroll', + () => { + stuck = atBottom(); + if (stuck) { + unread = 0; + more.hidden = true; + } + }, + { passive: true }, + ); + more.addEventListener('click', () => { + stuck = true; + scrollDown(); + more.hidden = true; + unread = 0; + }); + + /* ---- line actions ---- */ + function closeActions() { + $$('.cl-acts', list).forEach((x) => x.remove()); + $$('.cl.open', list).forEach((x) => x.classList.remove('open')); + openId = null; + } + function openActions(el, m) { + closeActions(); + openId = m.id; + el.classList.add('open'); + el.insertAdjacentHTML('afterend', actionsHtml(m, hub)); + if (stuck) scrollDown(); + else { + // Scroll only the chat list (scrollIntoView would move the sheet too). + const acts = el.nextElementSibling; + const over = + acts.getBoundingClientRect().bottom - + list.getBoundingClientRect().bottom; + if (over > 0) list.scrollTop += over + 8; + } + } + list.addEventListener('click', async (e) => { + const go = e.target.closest('[data-go-record]'); + if (go) { + o.onRecord?.(go.dataset.goRecord); + return; + } + const act = e.target.closest('[data-cl]'); + if (act) { + const mid = act.closest('.cl-acts').dataset.for; + const m = hub.log.find((x) => x.id === mid); + if (!m) return; + haptic('tick'); + if (act.dataset.cl === 'reply') { + input.value = `${m.handle} `; + input.focus(); + closeActions(); + } else if (act.dataset.cl === 'corro') { + if (hub.corroborate(m)) { + toast( + `Corroborated · your Signal weighs ×${tierOf(core.signal.cached).weight.toFixed(1)}`, + { icon: icon('agree') }, + ); + } + } else if (act.dataset.cl === 'promote') { + const ok = await hub.promote(m); + if (ok) { + haptic('success'); + toast('Promoted to the Record', { icon: icon('sendUp') }); + closeActions(); + } else + toast( + `Needs a sealed capture or ${3} Signal-weighted corroborations first`, + { icon: icon('info') }, + ); + } + return; + } + const line = e.target.closest('.cl:not(.cl-sys)'); + if (line) { + const m = hub.log.find((x) => x.id === line.dataset.msg); + if (!m) return; + if (openId === m.id) closeActions(); + else openActions(line, m); + } + }); + + /* ---- pins actions ---- */ + pins.addEventListener('click', (e) => { + const nb = e.target.closest('[data-need]'); + if (nb) core.needs?.openDetail?.(nb.dataset.need); + }); + + /* ---- reactions ---- */ + function float(kind, { small = false, from } = {}) { + if (reducedMotion()) return; + const el = h( + `${reactSvg(kind)}`, + ); + floatL.appendChild(el); + const dx = (Math.random() - 0.5) * 60; + // Rise through the visible chat, not over the room's header. + const room = Math.max(120, list.clientHeight + 30); + const rise = Math.min( + room, + small ? 180 + Math.random() * 140 : 260 + Math.random() * 160, + ); + el.animate( + [ + { transform: 'translate(0,0) scale(.4)', opacity: 0 }, + { + transform: `translate(${dx * 0.4}px,-30px) scale(${small ? 0.8 : 1.15})`, + opacity: 1, + offset: 0.15, + }, + { + transform: `translate(${-dx * 0.5}px,-${rise * 0.6}px) scale(${small ? 0.7 : 1})`, + opacity: 0.9, + offset: 0.6, + }, + { + transform: `translate(${dx}px,-${rise}px) scale(${small ? 0.5 : 0.8})`, + opacity: 0, + }, + ], + { + duration: small + ? 2200 + Math.random() * 900 + : 2600 + Math.random() * 800, + easing: 'cubic-bezier(.2,.6,.3,1)', + }, + ).finished.then( + () => el.remove(), + () => el.remove(), + ); + if (floatL.childElementCount > 60) floatL.firstElementChild?.remove(); + } + const counts = () => + REACT_KINDS.forEach((k) => { + const el = $(`[data-rn="${k}"]`, dockEl); + if (el) el.textContent = hub.reactions[k] ? fmtN(hub.reactions[k]) : ''; + }); + counts(); + const offReact = hub.on('react', ({ kind, count, crowd, self, burst }) => { + if (self) { + float(kind); + if (Math.random() < 0.5) setTimeout(() => float(kind), 90); + } else { + const k = Math.min(crowd ? (burst ? 5 : 2) : 1, count); + for (let i = 0; i < k; i += 1) + setTimeout(() => float(kind, { small: true }), i * 120); + } + counts(); + }); + let held = 0; + dockEl.addEventListener('pointerdown', (e) => { + const b = e.target.closest('[data-react]'); + if (!b) return; + // Hold to stream hearts. + clearInterval(held); + held = setInterval(() => hub.react(b.dataset.react), 160); + const up = () => { + clearInterval(held); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', up); + }; + addEventListener('pointerup', up); + addEventListener('pointercancel', up); + }); + + /* ---- viewers + slow mode ---- */ + const slow = () => (hub.viewers >= 1000 ? 3 : 0); + function meta() { + $('[data-viewers]', dockEl).textContent = hub.viewers.toLocaleString(); + const sm = slow(); + const real = core.rt.presence(hub.id).length - 1; + $('[data-slow]', dockEl).textContent = + real > 0 + ? `${real} real ${real === 1 ? 'person' : 'people'} here (your tabs)` + : sm + ? `Slow mode ${sm} s` + : ''; + } + meta(); + const offView = hub.on('viewers', meta); + const offBurst = hub.on('burst', () => root.classList.add('bursting')); + const burstOff = setInterval(() => root.classList.remove('bursting'), 6000); + + /* ---- input, mentions, emotes ---- */ + function showPop(kind) { + if (kind === 'emotes') { + pop.innerHTML = `
${EMOTE_CODES.map((c) => ``).join('')}
`; + pop.hidden = false; + pop.dataset.kind = 'emotes'; + } else pop.hidden = true; + } + function mentionPop() { + const m = /(^|\s)(@[\w.-]*)$/.exec(input.value); + if (!m) { + if (pop.dataset.kind === 'mention') pop.hidden = true; + return; + } + const q = m[2].toLowerCase(); + const seen = []; + for (let i = hub.log.length - 1; i >= 0 && seen.length < 5; i -= 1) { + const x = hub.log[i]; + if (x.system || x.self) continue; + if (x.handle.toLowerCase().startsWith(q) && !seen.includes(x.handle)) + seen.push(x.handle); + } + if (!seen.length) { + pop.hidden = true; + return; + } + pop.dataset.kind = 'mention'; + pop.innerHTML = `
${seen.map((x) => ``).join('')}
`; + pop.hidden = false; + } + input.addEventListener('input', mentionPop); + input.addEventListener('focus', () => { + stuck = true; + scrollDown(); + }); + dockEl.addEventListener('click', (e) => { + const r = e.target.closest('[data-react]'); + if (r) { + haptic('tick'); + hub.react(r.dataset.react); + return; + } + const em = e.target.closest('[data-emote]'); + if (em) { + input.value = + `${input.value.replace(/\s*$/, ' ')}${em.dataset.emote} `.trimStart(); + input.focus(); + pop.hidden = true; + return; + } + const mn = e.target.closest('[data-mention]'); + if (mn) { + input.value = input.value.replace(/@[\w.-]*$/, `${mn.dataset.mention} `); + input.focus(); + pop.hidden = true; + return; + } + const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'emotes') { + if (!pop.hidden && pop.dataset.kind === 'emotes') pop.hidden = true; + else showPop('emotes'); + } else if (act === 'need-eyes') o.onNeedEyes?.(); + else if (act === 'capture-reply') o.onCapture?.(); + }); + $('form', dockEl).addEventListener('submit', (e) => { + e.preventDefault(); + const now = Date.now(); + if (now < coolUntil) { + toast(`Slow mode · ${Math.ceil((coolUntil - now) / 1000)} s`, { + icon: icon('clock'), + }); + return; + } + const m = hub.send(input.value); + if (!m) return; + input.value = ''; + pop.hidden = true; + stuck = true; + scrollDown(); + haptic('tick'); + const sm = slow(); + if (sm) { + coolUntil = now + sm * 1000; + sendBtn.style.setProperty('--cool', `${sm}s`); + sendBtn.classList.remove('cooling'); + void sendBtn.offsetWidth; + sendBtn.classList.add('cooling'); + setTimeout(() => sendBtn.classList.remove('cooling'), sm * 1000); + } + }); + + hub.attach(); + return { + root, + dock: dockEl, + input, + float, + destroy() { + [offMsg, offUpd, offReact, offView, offBurst, offFirst, offNeed].forEach( + (f) => typeof f === 'function' && f(), + ); + clearInterval(burstOff); + clearInterval(held); + hub.detach(); + root.remove(); + dockEl.remove(); + }, + }; +} + +/* ---------------- Moment overlay ---------------- */ + +/** + * Twitch-style chat over the Moment player. Replays moment.chat on the + * Moment's clock: from 'moment' events ({ type:'chat', item }) when the + * engine sends them, and from the clock ('playback' / setTime) otherwise. + */ +export function mountChatOverlay(core, el, ctx = {}) { + const root = h(`
+
+ +
+ Chat replay · simulated + ${REACT_KINDS.map((k) => ``).join('')} +
+
`); + el.appendChild(root); + const list = $('.co-list', root); + const floatL = $('.co-float', root); + let moment = null; + let timeline = []; + let cursor = 0; + let lastT = 0; + const shown = new Set(); + const globe = ctx.globe || core.globe; + let seqN = 0; + let driven = false; // a player calls push(): ignore the engine's own clock + const full = ctx.moment || null; + + function line(m, { quiet = false } = {}) { + const key = m.id || `${m.t}|${m.handle}`; + if (shown.has(key)) return; + shown.add(key); + seqN += 1; + const persona = personaOf(m.handle); + const msg = { + id: key, + n: seqN, + handle: m.handle || '@someone', + text: m.text, + at: m.t || Date.now(), + sim: m.sim !== false, + mod: m.badge === 'mod' || !!persona?.mod, + witness: m.badge === 'witness' || m.badge === 'first', + first: m.badge === 'first', + domain: persona?.domain || null, + tier: simTier(m.handle, persona?.floor || 0), + self: !!m.self, + }; + const node = h(lineHtml(msg, core.rt.me.handle)); + if (!quiet) node.classList.add('in'); + list.appendChild(node); + while (list.childElementCount > 9) list.firstElementChild.remove(); + // Lines fade after a while, as on a stream overlay. + setTimeout(() => node.classList.add('old'), 9000); + // Chat and hearts go together. + if (!quiet && Math.random() < 0.6) crowdFloat(); + } + function crowdFloat(kind) { + const k = + kind || + ['heart', 'eyes', 'wow', 'heart', 'fire'][Math.floor(Math.random() * 5)]; + if (!reducedMotion()) { + const e2 = h(`${reactSvg(k)}`); + floatL.appendChild(e2); + const dx = (Math.random() - 0.5) * 50; + e2.animate( + [ + { transform: 'translate(0,0) scale(.4)', opacity: 0 }, + { + transform: `translate(${dx * 0.3}px,-30px) scale(.9)`, + opacity: 1, + offset: 0.15, + }, + { transform: `translate(${dx}px,-220px) scale(.6)`, opacity: 0 }, + ], + { duration: 2400, easing: 'cubic-bezier(.2,.6,.3,1)' }, + ).finished.then( + () => e2.remove(), + () => e2.remove(), + ); + } + if (moment && Number.isFinite(moment.lat)) + globeReact(globe, { + lon: moment.lon + (Math.random() - 0.5) * 0.02, + lat: moment.lat + (Math.random() - 0.5) * 0.02, + kind: k, + count: 1 + Math.floor(Math.random() * 3), + }); + } + + function setTime(t) { + const tt = +t; + if (!Number.isFinite(tt) || !timeline.length) return; + if (tt < lastT - 1000) { + // Seeked backwards: rewind the overlay. + list.innerHTML = ''; + shown.clear(); + cursor = 0; + while (cursor < timeline.length && timeline[cursor].t <= tt - 30000) { + shown.add(timeline[cursor].id); + cursor += 1; + } + } + lastT = tt; + let k = 0; + while (cursor < timeline.length && timeline[cursor].t <= tt) { + const m = timeline[cursor]; + cursor += 1; + // Big jumps forward show only the last few lines. + if (timeline.length - cursor < 400 && k < 12) line(m); + else shown.add(m.id); + k += 1; + } + } + + /** + * setMoment(m): a new Moment, or (from a player that drives the overlay) + * the same Moment with `chat` holding the lines said so far after a seek. + */ + function setMoment(m) { + const same = moment && m && moment.id === m.id; + moment = m; + if (!same || !timeline.length) + timeline = m + ? momentChat(full?.id === m?.id && full.chat?.length ? full : m) + : []; + list.innerHTML = ''; + shown.clear(); + cursor = 0; + lastT = +m?.time?.start || 0; + // Lines already said: show the last few, quietly. + const said = Array.isArray(m?.chat) ? m.chat : []; + for (const c of said) shown.add(c.id || `${c.t}|${c.handle}`); + for (const c of said.slice(-6)) { + shown.delete(c.id || `${c.t}|${c.handle}`); + line(c, { quiet: true }); + } + if (said.length) lastT = said[said.length - 1].t; + } + + const onMoment = (e) => { + if (!e || driven) return; + if (e.type === 'chat' && e.item) + line({ ...e.item, id: e.item.id || `${e.item.t}|${e.item.handle}` }); + else if (Number.isFinite(+e.t) && e.type !== 'chat') setTime(e.t); + if (e.type === 'capture') + for (let i = 0; i < 6; i += 1) + setTimeout(() => crowdFloat('wow'), i * 90); + }; + const onPlayback = (e) => + !driven && e && Number.isFinite(+e.t) && setTime(e.t); + const onTime = (e) => !driven && e?.time && setTime(+new Date(e.time)); + globe?.on?.('moment', onMoment); + globe?.on?.('playback', onPlayback); + globe?.on?.('time', onTime); + + root.addEventListener('click', (e) => { + const b = e.target.closest('[data-react]'); + if (!b) return; + haptic('tick'); + crowdFloat(b.dataset.react); + const loc = core.myLoc(); + globeReact(globe, { + lon: loc.lon, + lat: loc.lat, + kind: b.dataset.react, + count: 1, + }); + }); + + if (ctx.moment) setMoment(ctx.moment); + + return { + push(msg) { + driven = true; + line({ ...msg, t: msg.t || Date.now() }); + }, + setMoment, + setTime, + destroy() { + globe?.off?.('moment', onMoment); + globe?.off?.('playback', onPlayback); + globe?.off?.('time', onTime); + root.remove(); + }, + }; +} + +export { makeMsg }; diff --git a/mobile/ui/needEyes.js b/mobile/ui/needEyes.js new file mode 100644 index 000000000..a2d066d91 --- /dev/null +++ b/mobile/ui/needEyes.js @@ -0,0 +1,578 @@ +/** + * Need eyes (DESIGN.md §12.2): any room can ask for coverage at a place + * ("Is the 7th St underpass flooded?"). People nearby get one quiet ask; + * fulfilling it with a sealed capture earns Signal and the byline. This is + * the incentive Citizen never had: curiosity turned into coverage. + * + * Seeded requests are EXAMPLES tied to live situations (fictional askers); + * requests the person makes are real, stored on this device and shared with + * their other tabs. + */ + +import { icon } from './icons.js'; +import { + $, + $$, + h, + esc, + ago, + haptic, + toast, + emitter, + esriThumb, + reducedMotion, +} from './dom.js'; +import { createSheet } from './sheet.js'; +import { createMiniMap } from './minimap.js'; +import { distKm, fmtKm, llShort, utc, bandOf } from './bands.js'; +import { genre as roomGenre } from './rooms.js'; +import { get, put } from '../capture/db.js'; +import { tierOf, RULE, hashStr } from './signal.js'; + +/** Per-genre questions, written like people actually ask. */ +const ASKS = { + circling: [ + 'What are the helicopters circling? Anyone on 34th St with a view up?', + 'Can someone at street level see what’s on the building?', + ], + lightning: [ + 'Anyone on the coast with a view of the line? Need a sealed photo of the shelf cloud.', + ], + storm: [ + 'Is the 7th St underpass flooded? Radar has the core right over it.', + 'Are the streetlights out along the waterfront?', + ], + cyclone: [ + 'Is the seawall at the harbour overtopping yet?', + 'How high is the water at the fish port right now?', + ], + fire: [ + 'Flames or just smoke from the ridge road? Need a photo facing east.', + 'Is the smoke reaching the school on the valley floor?', + ], + quake: [ + 'Any visible damage to the old brick blocks downtown?', + 'Is the bridge open? Need a photo from either end.', + ], + squawk: [ + 'Anyone at the airport fence to see the landing?', + 'Are there fire trucks on the taxiway?', + ], + orbit: [ + 'Clear enough to see the pass tonight? Need a sky photo facing west.', + 'Is it cloudy where you are? Need a sky check before the pass.', + ], + eye: [ + 'Is the lane closure still on? The camera angle can’t tell.', + 'How long is the line at the corner right now?', + ], + sea: ['How many ships can you count from the breakwater?'], + air: [ + 'Which runway are they landing on? Need a photo from the viewing area.', + ], + gnss: ['Anyone flying through tonight with a nav display photo?'], + recon: ['Can anyone photograph the upstream bridge abutment?'], + default: ['What does it look like from street level right now?'], +}; +const ASKERS = [ + '@riverside.example', + '@lena.example', + '@datadesk.example', + '@metdesk.example', + '@skeptic.example', +]; + +/** Modal sheets created early must still open above later ones. */ +export const front = (sh) => sh.root.parentElement?.appendChild(sh.root); + +export function createNeeds(core) { + const ev = emitter(); + let seeds = []; // examples from situations + let near = new Map(); // key → examples near a point + let mine = []; // the person's requests and fulfilments + let done = {}; // id → fulfilment + const ready = (async () => { + const rec = await get('kv', 'needEyes').catch(() => null); + mine = rec?.list || []; + done = rec?.done || {}; + ev.emit('change'); + })(); + async function save() { + await put('kv', { id: 'needEyes', list: mine, done }).catch(() => {}); + } + core.rt.on('need', (req) => { + if (!req?.id) return; + const i = mine.findIndex((x) => x.id === req.id); + if (i >= 0) mine[i] = { ...mine[i], ...req }; + else mine.unshift({ ...req, remote: true }); + if (req.fulfilledBy) done[req.id] = req.fulfilledBy; + ev.emit('change'); + }); + + /** Seeds: one example request for each of the most active situations. */ + function seedFrom(sits) { + const out = []; + const seen = new Set(); + for (const s of sits) { + if (!Number.isFinite(s.lat)) continue; + const g = roomGenre(s); + const k = /lightning/.test(s.kind || '') ? 'lightning' : g; + if (seen.has(k) || out.length >= 14) continue; + seen.add(k); + const list = ASKS[k] || ASKS[g] || ASKS.default; + const hs = hashStr(s.id); + const q = list[hs % list.length]; + // A spot near the situation, not on top of it (a street, a pier). + const km = + g === 'cyclone' + ? 60 + : g === 'eye' + ? 0.15 + : g === 'circling' + ? 0.25 + : 1.2; + const ang = ((hs % 360) * Math.PI) / 180; + const dLat = (km / 111) * Math.cos(ang); + const dLon = + (km / (111 * Math.cos((s.lat * Math.PI) / 180))) * Math.sin(ang); + out.push({ + id: `need-${s.id}`, + room: String(s.id), + roomTitle: s.title, + situation: s, + question: q, + lat: s.lat + dLat, + lon: s.lon + dLon, + radiusKm: g === 'cyclone' ? 25 : 2, + at: + (Number(new Date(s.updatedAt)) || Date.now()) - + (8 + (hs % 30)) * 60e3, + by: ASKERS[hs % ASKERS.length], + asked: 12 + (hs % 70), + example: true, + genre: g, + }); + } + seeds = out; + ev.emit('change'); + } + core + .situations?.() + .then(seedFrom) + .catch(() => {}); + + /** Example requests near any point (Your sky, a street check). */ + function nearSeeds(ll) { + if (!ll || !Number.isFinite(ll.lat)) return []; + const key = `${ll.lat.toFixed(1)},${ll.lon.toFixed(1)}`; + if (near.has(key)) return near.get(key); + const place = (title, dLat, dLon, extra = {}) => ({ + id: `place-near-${key}-${hashStr(title).toString(36)}`, + kind: 'place', + family: 'eyes', + band: 'inside', + archetype: 'sensor', + title, + place: llShort(ll.lat + dLat, ll.lon + dLon, 2), + lat: ll.lat + dLat, + lon: ll.lon + dLon, + live: true, + updatedAt: Date.now(), + source: 'Need eyes request', + metrics: [], + layers: [], + camera: { + lon: ll.lon + dLon, + lat: ll.lat + dLat, + height: 3000, + heading: 0, + pitch: -45, + }, + adhoc: true, + ...extra, + }); + const sky = place('Sky check before tonight’s ISS pass', 0.006, -0.004, { + kind: 'iss', + band: 'orbit', + }); + const street = place('Street check near you', -0.009, 0.007); + const list = [ + { + id: `need-near-${key}-sky`, + room: sky.id, + roomTitle: sky.title, + situation: sky, + question: + 'Is the sky clear enough for the ISS pass? Need a sealed photo facing west.', + lat: sky.lat, + lon: sky.lon, + radiusKm: 5, + at: Date.now() - 14 * 60e3, + by: '@pass_predictor', + asked: 23, + example: true, + genre: 'orbit', + }, + { + id: `need-near-${key}-street`, + room: street.id, + roomTitle: street.title, + situation: street, + question: + 'Traffic sensors show a slowdown on the main road. Crash or roadworks?', + lat: street.lat, + lon: street.lon, + radiusKm: 2, + at: Date.now() - 6 * 60e3, + by: '@datadesk.example', + asked: 9, + example: true, + genre: 'default', + }, + ]; + near.set(key, list); + return list; + } + + const decorate = (r) => { + const loc = core.getLocation?.() || null; + const km = loc ? distKm(loc, r) : NaN; + const f = done[r.id] || r.fulfilledBy || null; + return { + ...r, + km, + distLabel: + Number.isFinite(km) && km <= 150 + ? `${fmtKm(km)} from you` + : `${fmtKm(r.radiusKm)} radius`, + status: f ? 'fulfilled' : 'open', + fulfilledBy: f, + }; + }; + function allList() { + const ids = new Set(); + const nearAll = [...near.values()].flat(); + return [...mine, ...seeds, ...nearAll] + .filter((r) => (ids.has(r.id) ? false : ids.add(r.id))) + .map(decorate); + } + function forRoom(roomId) { + return allList().filter( + (r) => r.room === String(roomId) && r.status === 'open', + ); + } + function nearPoint(ll, km = 25) { + if (!ll || !Number.isFinite(ll.lat)) return []; + nearSeeds(ll); + return allList() + .map((r) => ({ ...r, km: distKm(ll, r) })) + .filter((r) => r.km <= Math.max(km, r.radiusKm || 0)) + .sort((a, b) => + a.status === b.status ? a.km - b.km : a.status === 'open' ? -1 : 1, + ) + .map((r) => ({ ...r, distLabel: `${fmtKm(r.km)} away` })); + } + const byId = (id) => allList().find((r) => r.id === id) || null; + + /* ---------------- UI: card ---------------- */ + function cardHtml(r) { + const f = r.fulfilledBy; + return `
+
${icon('eyes')}Need eyes· ${esc(r.distLabel || '')}${r.example ? 'Example' : ''}
+

“${esc(r.question)}”

+
${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} nearby asked once
+ ${ + f + ? `
${icon('sealFill')}Fulfilled · ${esc(f.handle)} · ${esc(utc(new Date(f.at)))}${Number.isFinite(f.km) ? ` · ${esc(fmtKm(f.km))}` : ''}
` + : r.self + ? `
${icon('clock')}Your request · waiting for a sealed answer · expires in 2 h
` + : `
+${RULE.fulfil.delta} Signal · byline
` + } +
`; + } + + /* ---------------- UI: create ---------------- */ + const createS = createSheet({ + label: 'Ask for eyes', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'need-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.9, 820)) }, + }); + let cctx = null; + function openCreate(s) { + front(createS); + const g = roomGenre(s); + cctx = { s, center: { lat: s.lat, lon: s.lon }, radius: 2 }; + createS.head.innerHTML = `
Need eyes·${esc(s.title)}
+

Ask for eyes on a spot

+
People within the radius get one quiet ask. The first sealed answer gets the byline and Signal.
`; + createS.body.innerHTML = `
+
Your question
+ +
${(ASKS[g] || ASKS.default).map((q) => ``).join('')}
+
Ask people within
+
${[0.5, 2, 10].map((v) => ``).join('')}
+

Calm by default: each person gets at most one ask a day, only inside the radius, never with a sound. Requests expire after 2 hours.

+ `; + createS.open('large'); + requestAnimationFrame(() => { + const mm = createMiniMap($('.need-map', createS.body), { + lat: s.lat, + lon: s.lon, + spanDeg: g === 'cyclone' ? 1.2 : 0.03, + label: 'Drag the map to the spot', + onChange: (c) => (cctx.center = c), + }); + cctx.mm = mm; + }); + } + createS.root.addEventListener('click', async (e) => { + if (!cctx) return; + const sg = e.target.closest('[data-sugg]'); + if (sg) { + $('.need-q', createS.body).value = sg.dataset.sugg; + haptic('tick'); + return; + } + const rb = e.target.closest('[data-radius] button'); + if (rb) { + cctx.radius = Number(rb.dataset.v); + $$('[data-radius] button', createS.body).forEach((b) => + b.setAttribute('aria-pressed', String(b === rb)), + ); + haptic('tick'); + return; + } + if (e.target.closest('[data-act="ask"]')) { + const q = $('.need-q', createS.body).value.trim(); + if (!q) { + $('.need-q', createS.body).focus(); + toast('Write the question first'); + return; + } + const s = cctx.s; + const req = { + id: `need-${core.rt.tab}-${Date.now().toString(36)}`, + room: String(s.id), + roomTitle: s.title, + situation: slim(s), + question: q, + lat: cctx.center.lat, + lon: cctx.center.lon, + radiusKm: cctx.radius, + at: Date.now(), + by: core.rt.me.handle, + self: true, + asked: Math.max(3, Math.round(cctx.radius * 17 + (hashStr(q) % 20))), + genre: roomGenre(s), + }; + mine.unshift(req); + await save(); + core.rt.need(req); + ev.emit('change'); + haptic('success'); + createS.close(); + toast( + `Asked · about ${req.asked} people nearby get one quiet ask (simulated)`, + { + icon: icon('eyes'), + }, + ); + core.hub(s)?.push({ + id: `sys-need-${req.id}`, + system: true, + handle: 'Need eyes', + text: `${req.by} asked for eyes: “${q}” · ${fmtKm(req.radiusKm)} radius`, + at: Date.now(), + }); + } + }); + const slim = (s) => ({ + id: s.id, + kind: s.kind, + family: s.family, + band: s.band, + archetype: s.archetype, + title: s.title, + place: s.place, + lat: s.lat, + lon: s.lon, + live: s.live, + updatedAt: s.updatedAt, + source: s.source, + metrics: s.metrics || [], + layers: s.layers || [], + camera: s.camera, + momentId: s.momentId, + adhoc: s.adhoc, + }); + + /* ---------------- UI: detail ---------------- */ + const detailS = createSheet({ + label: 'Need eyes', + detents: ['large'], + modal: true, + dismissible: true, + closeButton: true, + className: 'need-sheet opaque', + heights: { large: (H) => Math.round(Math.min(H * 0.72, 640)) }, + }); + function openDetail(id) { + const r = byId(id); + if (!r) return; + front(detailS); + detailS.head.innerHTML = `
Need eyes·${esc(r.roomTitle || '')}
+

“${esc(r.question)}”

+
Asked by ${esc(r.by)} · ${esc(ago(r.at))} · ${r.asked} people nearby asked once${r.example ? ' · Example' : ''}
`; + detailS.body.innerHTML = `
${icon('pin')}${esc(llShort(r.lat, r.lon, 4))} · ${esc(fmtKm(r.radiusKm))} RADIUS
+
    +
  • ${icon('sealFill')}Answer with a sealed capture from inside the radius. Text answers don’t fulfil a request.
  • +
  • ${icon('eyes')}First eyes on a request is a permanent byline on the record.
  • +
  • ${icon('sparkles')}+${RULE.fulfil.delta} Signal, more if sensors corroborate it. Signal weighs your votes and notes.
  • +
+ ${ + r.fulfilledBy + ? `
${icon('sealFill')}Fulfilled by ${esc(r.fulfilledBy.handle)} · ${esc(utc(new Date(r.fulfilledBy.at)))}
` + : `` + }`; + 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(``); + document.body.appendChild(el); + haptic('seal'); + requestAnimationFrame(() => el.classList.add('show')); + // Type the byline. + const tgt = $('[data-type]', el); + const text = o.byline || ''; + let i = 0; + const typer = setInterval( + () => { + i += 2; + tgt.textContent = text.slice(0, i); + if (i >= text.length) clearInterval(typer); + }, + reducedMotion() ? 0 : 28, + ); + // Count the score up. + const sc = $('[data-score]', el); + const bar = $('.bm-bar i', el); + const start = performance.now() + 700; + const dur = reducedMotion() ? 1 : 1600; + const step = (now) => { + const k = Math.max(0, Math.min(1, (now - start) / dur)); + const e = 1 - (1 - k) ** 3; + const v = Math.round(o.before + (o.after - o.before) * e); + sc.textContent = v.toLocaleString(); + const tv = tierOf(v); + bar.style.setProperty('--p', `${Math.round(tv.progress * 100)}%`); + if (k < 1 && el.isConnected) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + const close = () => { + clearInterval(typer); + el.classList.remove('show'); + setTimeout(() => el.remove(), 380); + }; + el.addEventListener('click', (e) => { + const b = e.target.closest('[data-bm]'); + if (b?.dataset.bm === 'record') { + close(); + o.onRecord?.(); + } else if (b || e.target === el) close(); + }); + return { close, el }; +} diff --git a/mobile/ui/realtime.js b/mobile/ui/realtime.js index 5dc7a3da2..509e7849f 100644 --- a/mobile/ui/realtime.js +++ b/mobile/ui/realtime.js @@ -97,6 +97,11 @@ export function createRealtime() { else if (m.t === 'react') ev.emit('react', { room: m.room, kind: m.kind, remote: true }); else if (m.t === 'rate') ev.emit('rate', m); + else if (m.t === 'chat') + ev.emit('chat', { room: m.room, msg: { ...m.msg, remote: true } }); + else if (m.t === 'corro') + ev.emit('corro', { room: m.room, id: m.id, w: m.w, remote: true }); + else if (m.t === 'need') ev.emit('need', m.req); else if (m.t === 'live') { const cur = lives.get(m.live.id); lives.set(m.live.id, { @@ -161,6 +166,10 @@ export function createRealtime() { post: (post) => send({ t: 'post', post }), react: (r, kind) => send({ t: 'react', room: r, kind }), rate: (payload) => send({ t: 'rate', ...payload }), + /** Live lane: one chat line to every other tab in the same room. */ + chat: (r, msg) => send({ t: 'chat', room: r, msg }), + corro: (r, id, w) => send({ t: 'corro', room: r, id, w }), + need: (req) => send({ t: 'need', req }), /* live broadcasts */ liveTick: (live, frame) => send({ t: 'live', live, frame }), liveEnd: (id) => send({ t: 'live-end', id }), diff --git a/mobile/ui/record.js b/mobile/ui/record.js new file mode 100644 index 000000000..0963c1d73 --- /dev/null +++ b/mobile/ui/record.js @@ -0,0 +1,457 @@ +/** + * The Record lane (DESIGN.md §12.6): Reddit's threads and X's quote-posts, + * on the evidence. Considered posts with kinds (§11.3), threaded replies, + * quote-posts, sealed attachments, Corroborate / Dispute weighted by Signal, + * sort by Evidence / New / Disputed, community notes with bridging, the + * Known / Disputed / Unknown synthesis, and the Clerk's "What chat is + * saying" summaries with citations back to Live lines. + */ + +import { icon } from './icons.js'; +import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js'; +import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js'; +import { llShort, utc, fmtKm } from './bands.js'; +import { mediaUrl } from './witness.js'; +import { simTier, tierOf, hashStr } from './signal.js'; +import { richText } from './emotes.js'; + +export const SORTS = [ + { id: 'evidence', name: 'Evidence' }, + { id: 'new', name: 'New' }, + { id: 'disputed', name: 'Disputed' }, +]; + +/** Center an element inside its scroller without moving outer layers. */ +function centerIn(sc, el, smooth) { + const top = + el.getBoundingClientRect().top - + sc.getBoundingClientRect().top + + sc.scrollTop - + sc.clientHeight / 2 + + el.offsetHeight / 2; + sc.scrollTo({ top: Math.max(0, top), behavior: smooth ? 'smooth' : 'auto' }); +} + +/** Signal-weighted tallies: seeded voters carry a stable average weight. */ +export function weighted(p, core) { + const h = hashStr(p.id); + const wUp = 1 + (h % 90) / 100; + const wDn = 1 + ((h >>> 8) % 60) / 100; + const myW = tierOf(core.signal.cached).weight; + const sim = core.simCorro?.(p.id) || []; + const simW = sim.reduce((a, x) => a + x.w, 0); + const baseUp = Math.max(0, (p.votes?.up || 0) - (p.myVote === 'up' ? 1 : 0)); + const baseDn = Math.max(0, (p.votes?.dn || 0) - (p.myVote === 'dn' ? 1 : 0)); + const up = baseUp * wUp + (p.myVote === 'up' ? myW : 0) + simW; + const dn = baseDn * wDn + (p.myVote === 'dn' ? myW : 0); + return { + up, + dn, + nUp: (p.votes?.up || 0) + sim.length, + nDn: p.votes?.dn || 0, + share: up / Math.max(0.001, up + dn), + myW, + }; +} + +const disputeScore = (p, r, core) => { + const w = weighted(p, core); + const corr = r.repliesOf(p).filter((x) => x.kind === 'correction').length; + return w.dn / Math.max(1, w.up + w.dn) + corr * 0.4; +}; + +function authorTier(p, core) { + if (p.author?.self) return tierOf(core.signal.cached); + return simTier(p.author?.handle || '', p.author?.domain ? 300 : 0); +} + +function citeChip(c, label) { + if (!c) return ''; + const l = + label || (/^O\d/.test(c) ? `OBS ${c.slice(1)}` : c === 'O-cam' ? 'CAM' : c); + return ``; +} + +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 ``; +} + +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 sayingClerk · ${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('')}
+ +

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 ? ` · ` : ''}

+
`; +} + +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)}` : ``}
+
`; +} + +function quoteHtml(q, r) { + if (!q) return ''; + const k = KIND[q.kind] || KIND.context; + return ``; +} + +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) + ? `` + : ''; + 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('')}
` : ''} +
+ + + ${w.up.toFixed(0)}:${w.dn.toFixed(0)} + + ${anchor} + + +
+ ${(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 + ? `` + : `Attached capture`, + ); + } +} + +/** + * 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( + `
`, + ); + 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) => ``).join('')}${r.posts.length} posts
+ ${synthHtml(r)} + ${clerk[0] ? clerkHtml(clerk[0], { older: clerk.length - 1 }) : ''} + ${ + showOlder + ? clerk + .slice(1) + .map((c) => clerkHtml(c)) + .join('') + : '' + } + ${needs.map((n) => core.needs.cardHtml(n)).join('')} + ${r.hasExamples ? '
Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''} + ${sort === 'evidence' && r.openQ.length ? `
${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r, core, { first })).join('')}` : ''} +
${icon(sort === 'disputed' ? 'dispute' : sort === 'new' ? 'clock' : 'sealFill')}${sort === 'evidence' ? 'Evidence first sealed, corroborated, close beats loud' : sort === 'new' ? 'Newest first' : 'Most disputed weighted by Signal'}
+ ${list.length ? list.map((p) => postHtml(p, r, core, { first })).join('') : '
Nothing disputed here. Votes and corrections move posts into this view.
'} +
${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
`; + hydrateMedia(root); + pinPosts(); + if (keep) scroller().scrollTop = sc; + if (focusPost) { + const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, root); + if (pe) centerIn(scroller(), pe, false); + pe?.classList.add('flash'); + } + return r; + } + + /* ---- globe pins for anchored posts ---- */ + let pinIds = []; + function clearPins() { + for (const id of pinIds) core.globe?.removePin?.(id); + pinIds = []; + } + function pinPosts() { + clearPins(); + if (!core.globe?.addPin) return; + for (const p of r.posts) { + if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue; + const kind = p.evidence?.live + ? 'live' + : p.kind === 'witness' + ? 'witness' + : 'post'; + const id = core.globe.addPin({ + id: `post:${p.id}`, + lon: p.anchor.lon, + lat: p.anchor.lat, + kind: p.hologram ? 'hologram' : kind, + label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`, + headingDeg: p.evidence?.headingDeg, + }); + if (id) pinIds.push(id); + } + } + + function flash(el) { + if (!el) return; + centerIn(scroller(), el, !reducedMotion()); + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + } + + root.addEventListener('click', async (e) => { + const srt = e.target.closest('[data-sort]'); + if (srt) { + sort = srt.dataset.sort; + haptic('tick'); + await render({ keep: false }); + scroller().scrollTop = 0; + return; + } + if (e.target.closest('[data-act="synth-all"]')) { + $('.synth', root)?.classList.remove('compact'); + e.target.closest('[data-act]').remove(); + return; + } + if (e.target.closest('[data-act="older-clerk"]')) { + showOlder = !showOlder; + await render(); + return; + } + const need = e.target.closest('[data-need-act]'); + if (need) { + core.needs.act(need.dataset.needAct, need.dataset.need); + return; + } + const cite = e.target.closest('[data-cite]'); + if (cite) { + const id = cite.dataset.cite; + if (id.startsWith('chat:')) { + const box = cite.closest('.clerk'); + const qs = $('.ck-quotes', box); + qs.hidden = false; + const q = $(`[data-q="${CSS.escape(id.slice(5))}"]`, qs); + $$('.ck-q', qs).forEach((x) => x.classList.toggle('hl', x === q)); + haptic('tick'); + return; + } + const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0]; + const el = target ? $(`[data-post="${CSS.escape(target)}"]`, root) : null; + if (el) flash(el); + else + toast( + `${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`, + { icon: icon('info') }, + ); + return; + } + const jump = e.target.closest('[data-jump]'); + if (jump) { + flash($(`[data-post="${CSS.escape(jump.dataset.jump)}"]`, root)); + return; + } + const postEl = e.target.closest('[data-post]'); + const post = postEl + ? r.posts.find((p) => p.id === postEl.dataset.post) + : null; + if (!post) return; + const anchor = e.target.closest('[data-anchor]'); + if (anchor && post.anchor) { + haptic('light'); + o.flyTo?.(post.anchor, s); + if (post.anchor.t) o.scrub?.(post.anchor.t); + return; + } + const vote = e.target.closest('[data-vote]'); + if (vote) { + haptic('tick'); + const was = post.myVote; + await core.rooms.vote(post, vote.dataset.vote); + if (was !== vote.dataset.vote) { + const wgt = tierOf(core.signal.cached).weight; + toast( + `${vote.dataset.vote === 'up' ? 'Corroborated' : 'Disputed'} · your vote weighs ×${wgt.toFixed(1)}`, + { icon: icon(vote.dataset.vote === 'up' ? 'agree' : 'dispute') }, + ); + } + await render(); + return; + } + if (e.target.closest('[data-reply]')) { + o.onCompose(s, { replyTo: post }); + return; + } + if (e.target.closest('[data-quote]')) { + o.onCompose(s, { quote: post }); + return; + } + const rate = e.target.closest('[data-rate]'); + if (rate) { + const nid = rate.closest('[data-note]').dataset.note; + const note = post.notes.find((n) => n.id === nid); + const mine = await core.rooms.rate(note, rate.dataset.rate === '1'); + const { why } = await core.rooms.myCluster(); + haptic('success'); + const st = noteStatus(note, mine); + if (st.shown && mine.helpful) + core.signal.award({ + id: `note:${note.id}`, + rule: 'note', + text: `A note you rated helpful is bridged in ${s.title}`, + ref: { room: r.id, post: post.id }, + }); + toast( + `Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`, + { icon: icon('bridge') }, + ); + await render(); + } + }); + dockEl.addEventListener('click', (e) => { + const act = e.target.closest('[data-act]')?.dataset.act; + if (act === 'capture-reply') o.onCapture(s, {}); + else if (act === 'write') o.onCompose(s, {}); + else if (act === 'need-eyes') o.onNeedEyes?.(); + }); + + const offPost = core.rooms.on('post', (p) => { + if (p.room === String(s.id)) render(); + }); + const offClerk = hub?.on('clerk', () => render()); + const offNeeds = core.needs?.on?.('change', () => render()); + const offSig = core.on?.( + 'simcorro', + (x) => x.room === String(s.id) && render(), + ); + + const ready = render({ keep: false, focusPost: o.focusPost }); + return { + root, + ready, + render, + get room() { + return r; + }, + destroy() { + clearPins(); + [offPost, offClerk, offNeeds, offSig].forEach( + (f) => typeof f === 'function' && f(), + ); + root.remove(); + dockEl.remove(); + }, + }; +} diff --git a/mobile/ui/room.js b/mobile/ui/room.js index a64d8e127..d3764a4f0 100644 --- a/mobile/ui/room.js +++ b/mobile/ui/room.js @@ -1,25 +1,139 @@ /** - * The room view inside a situation's sheet: presence, a pinned Known / - * Disputed / Unknown synthesis, open questions, evidence-first posts with - * earned badges, community notes with the bridging rule drawn in the open, - * anchors that fly the globe and scrub the ribbon, floating reactions, and - * a reply bar that offers Capture before text. + * A room with two lanes (DESIGN.md §12.6): Live (Twitch: fast chat, + * emotes, the heart stream rising from the globe) and Record (Reddit and + * X: threaded, evidence-ranked, Signal-weighted, with notes and the + * synthesis). The bridge between them is promotion and the Clerk. + * + * `createRoomPanel` mounts both lanes into any sheet (body + dock). + * `createRoomView` keeps the round-two contract the world's brief sheet + * uses: render(s, el, opts) · onClick(e, { flyTo, scrub }) · replyBar() · + * reactionRail() · close() · current. */ import { icon } from './icons.js'; -import { $, $$, h, esc, ago, haptic, toast, reducedMotion } from './dom.js'; -import { KIND, badgesFor, noteStatus, BRIDGE } from './rooms.js'; -import { avatar } from './cards.js'; -import { llShort, utc, fmtKm } from './bands.js'; -import { mediaUrl } from './witness.js'; +import { $, h, esc, haptic } from './dom.js'; +import { fmtKm } from './bands.js'; +import { mountLiveLane } from './liveChat.js'; +import { mountRecordLane } from './record.js'; -const REACTIONS = [ - { id: 'seen', icon: 'eyes', label: 'Seeing it too' }, - { id: 'concern', icon: 'hazards', label: 'Concerned' }, - { id: 'thanks', icon: 'hand', label: 'Thanks' }, - { id: 'q', icon: 'question', label: 'Need answers' }, -]; +const fmtV = (v) => + v >= 10000 ? `${(v / 1000).toFixed(1)}k` : v.toLocaleString(); +/** + * @param {object} core social core + * @param {object} s situation (or adapted Moment) + * @param {{ body, dock, switchHost?, lane?, focusPost?, flyTo?, scrub?, + * onCompose(s,o), onCapture(s,o), onLane?(lane) }} o + */ +export function createRoomPanel(core, s, o) { + const hub = core.hub(s); + let lane = o.lane === 'record' ? 'record' : 'live'; + let view = null; + const sw = h(`
+ + + +
`); + const host = o.switchHost || o.body; + if (o.switchHost) o.switchHost.appendChild(sw); + const wrap = h(`
`); + if (!o.switchHost) wrap.appendChild(sw); + o.body.appendChild(wrap); + core.rt.join(hub.id); + + function counts() { + $('[data-ls="live"]', sw).textContent = fmtV(hub.viewers); + core.rooms + .room(s) + .then((r) => { + const el = $('[data-ls="record"]', sw); + if (el) el.textContent = String(r.posts.length + hub.clerk.length); + }) + .catch(() => {}); + } + counts(); + const offV = hub.on('viewers', () => { + const el = $('[data-ls="live"]', sw); + if (el) el.textContent = fmtV(hub.viewers); + }); + const offC = hub.on('clerk', counts); + const offP = core.rooms.on('post', (p) => p.room === hub.id && counts()); + + function mount() { + view?.destroy(); + wrap.dataset.lane = lane; + o.body.dataset.lane = lane; + o.dock.dataset.lane = lane; + $$sel(sw).forEach((b) => + b.setAttribute('aria-selected', String(b.dataset.lane === lane)), + ); + sw.dataset.lane = lane; + const common = { + s, + body: wrap, + dock: o.dock, + onNeedEyes: () => core.needs.openCreate(s), + onCapture: (x, y) => o.onCapture(x || s, y || {}), + }; + if (lane === 'live') + view = mountLiveLane(core, hub, { + ...common, + onCapture: () => o.onCapture(s, {}), + onRecord: (ref) => setLane('record', { focusPost: ref }), + }); + else + view = mountRecordLane(core, hub, { + ...common, + flyTo: o.flyTo, + scrub: o.scrub, + focusPost: o.focusPost, + onCompose: o.onCompose, + }); + o.onLane?.(lane); + } + function setLane(l, { focusPost } = {}) { + if (l === lane && !focusPost) return; + lane = l; + if (focusPost) o.focusPost = focusPost; + haptic('tick'); + mount(); + } + sw.addEventListener('click', (e) => { + const b = e.target.closest('[data-lane]'); + if (b) setLane(b.dataset.lane); + }); + mount(); + void host; + + return { + hub, + get lane() { + return lane; + }, + get view() { + return view; + }, + setLane, + destroy() { + view?.destroy(); + view = null; + offV(); + offC(); + offP(); + sw.remove(); + wrap.remove(); + delete o.body.dataset.lane; + delete o.dock.dataset.lane; + core.rt.leave(); + }, + }; +} +const $$sel = (sw) => [...sw.querySelectorAll('[data-lane]')]; + +/** + * Round-two contract for the world's brief sheet (world.js). It needs the + * social core; `getCore` resolves it lazily so world.js need not change. + */ export function createRoomView({ rooms, rt, @@ -27,340 +141,65 @@ export function createRoomView({ getChrome, onCapture, onCompose, + getCore, }) { - let cur = null; // { s, r, el } - let pinIds = []; + let cur = null; // { s, el, panel } + let coreP = null; + const core = async () => + (coreP = + coreP || + (getCore + ? Promise.resolve(getCore()) + : import('./social.js').then((m) => + m.socialCore({ rooms, rt, globe }), + ))); - function clearPins() { - for (const id of pinIds) globe.removePin(id); - pinIds = []; - getChrome()?.setExtraPips([]); + async function render(s, el, { focusPost, lane } = {}) { + const c = await core(); + cur?.panel?.destroy(); + el.innerHTML = ''; + const sheet = el.closest('.sheet'); + const dock = sheet?.querySelector('.sheet-dock') || el; + dock.innerHTML = ''; + const panel = createRoomPanel(c, s, { + body: el, + dock, + lane: lane || (focusPost ? 'record' : 'live'), + focusPost, + onCompose: (x, y) => onCompose(x, y), + onCapture: (x, y) => onCapture(x, y), + flyTo: null, + scrub: (t) => getChrome?.()?.ribbon?.scrubTo?.(t), + }); + cur = { s, el, panel, r: await rooms.room(s) }; + return cur.r; } - function pinPosts(r) { - clearPins(); - for (const p of r.posts) { - if (!p.anchor || !Number.isFinite(p.anchor.lat)) continue; - const kind = p.evidence?.live - ? 'live' - : p.kind === 'witness' - ? 'witness' - : 'post'; - const id = globe.addPin({ - id: `post:${p.id}`, - lon: p.anchor.lon, - lat: p.anchor.lat, - kind: p.hologram ? 'hologram' : kind, - label: `${r.numbering.get(p.id)} ${KIND[p.kind]?.name || ''}`, - headingDeg: p.evidence?.headingDeg, - }); - if (id) pinIds.push(id); - } - getChrome()?.setExtraPips( - r.posts - .filter((p) => p.anchor?.t) - .map((p) => ({ - at: p.anchor.t, - label: r.numbering.get(p.id), - color: p.kind === 'witness' ? '#ebc777' : '#f4f7fb', - })), - ); - } - - /* ---------------- rendering ---------------- */ - - function citeChip(c) { - if (!c) return ''; - const label = /^O\d/.test(c) - ? `OBS ${c.slice(1)}` - : c === 'O-cam' - ? 'CAM' - : c; - return ``; - } - - function synthHtml(r) { - const sy = r.synth; - const sec = (id, title, list) => - list.length - ? `
${title}${list.length}
    ${list - .map( - (l) => - `
  • ${esc(l.text)}${l.cites - .filter(Boolean) - .map((c) => citeChip(c)) - .join('')}
  • `, - ) - .join('')}
` - : ''; - return `
-
${icon('sparkles')}Local synthesis${sy.n} posts · ${sy.obs} observations · ${esc(sy.hash.slice(0, 8))}
- ${sec('known', 'Known', sy.known)}${sec('disputed', 'Disputed', sy.disputed)}${sec('unknown', 'Unknown', sy.unknown)} -

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.

-
`; - } - - 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)}` : ``}
-
`; - } - - 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) - ? `` - : ''; - const media = - p.evidence?.captureId || p.evidence?.uploadId - ? `
${p.evidence.unverified ? 'Unverified upload' : `${icon('sealFill')}`}
` - : ''; - return `
-
- ${icon(k.icon)} - ${esc(k.name)}${esc(p.author.handle)}${p.author.self ? ' (you)' : ''}${esc(ago(p.at))} · ${esc(num)} - ${p.example ? 'Example' : ''} -
-

${esc(p.text)}

- ${media} - ${badges.length ? `
${badges.map((b) => `${icon(b.id === 'there' ? 'location' : b.id === 'sealed' ? 'sealFill' : b.id === 'local' ? 'home' : b.id === 'unverified' ? 'hazards' : 'checkmark')}${esc(b.label)}${b.detail ? ` ${esc(b.detail)}` : ''}`).join('')}
` : ''} -
- ${anchor} - - - - -
- ${(p.notes || []).map((n) => noteHtml(n, p)).join('')} - ${replies.length ? `
${replies.map((x) => postHtml(x, r, depth + 1)).join('')}
` : ''} -
`; - } - - function presenceHtml(r) { - const here = rt.presence(r.id); - const ex = r.participants.filter((a) => a.example).slice(0, 4); - return `
- ${here.map((a, i) => avatar({ handle: a.handle, self: a.self }, i).replace('class="av', 'class="av live')).join('')} - ${here.length} watching${here.length > 1 ? ' · live across your tabs' : ''} - ${ex.map(avatar).join('')} -
`; - } - - async function render(s, el, { focusPost } = {}) { - const r = await rooms.room(s); - cur = { s, r, el }; - rt.join(r.id); - el.innerHTML = `
- ${presenceHtml(r)} - ${synthHtml(r)} - ${r.hasExamples ? '
Example Fictional people (handles end in .example) show how a room works. Only the sensor numbers are live; your own posts are real and stay on this device.
' : ''} - ${r.openQ.length ? `
${icon('question')}Open questions float until answered
${r.openQ.map((p) => postHtml(p, r)).join('')}` : ''} -
${icon('sealFill')}Evidence first then recency, never followers
- ${r.ordered - .filter((p) => !r.openQ.includes(p)) - .map((p) => postHtml(p, r)) - .join('')} -
${r.posts.length} posts · ledger ${esc(r.synth.hash.slice(0, 12))}
-
`; - hydrateMedia(el); - pinPosts(r); - if (focusPost) { - const pe = $(`[data-post="${CSS.escape(focusPost)}"]`, el); - pe?.scrollIntoView({ block: 'center' }); - pe?.classList.add('flash'); - } - return r; - } - - async function hydrateMedia(el) { - for (const m of $$('.p-media[data-media]', el)) { - const u = await mediaUrl(m.dataset.media, m.dataset.store); - if (!u) continue; - m.insertAdjacentHTML( - 'afterbegin', - m.dataset.video - ? `` - : `Attached capture`, - ); - } - } - - /* ---------------- reactions ---------------- */ - - function reactionRail() { - return `
${REACTIONS.map((x) => ``).join('')}
`; - } - - function floatReaction(host, kind) { - if (!host || reducedMotion()) return; - const x = REACTIONS.find((r) => r.id === kind) || REACTIONS[0]; - const el = h(`${icon(x.icon)}`); - el.style.setProperty('--dx', `${Math.round(Math.random() * 24 - 12)}px`); - host.appendChild(el); - el.animate( - [ - { transform: 'translate(0, 0) scale(.6)', opacity: 0 }, - { - transform: 'translate(var(--dx), -40px) scale(1.1)', - opacity: 1, - offset: 0.2, - }, - { - transform: 'translate(calc(var(--dx) * -1), -190px) scale(.9)', - opacity: 0, - }, - ], - { duration: 2200, easing: 'cubic-bezier(.2,.7,.3,1)' }, - ).finished.then(() => el.remove()); - } - - /* ---------------- events ---------------- */ - - async function rerender(keepScroll) { - if (!cur) return; - const sc = keepScroll?.scrollTop; - await render(cur.s, cur.el); - if (keepScroll) keepScroll.scrollTop = sc; - } - - async function onClick(e, { scroller, flyTo, scrub }) { - if (!cur) return false; - const r = cur.r; - const postEl = e.target.closest('[data-post]'); - const post = postEl - ? r.posts.find((p) => p.id === postEl.dataset.post) - : null; - const anchor = e.target.closest('[data-anchor]'); - if (anchor && post?.anchor) { - haptic('light'); - flyTo(post.anchor, cur.s); - if (post.anchor.t) scrub(post.anchor.t); - return true; - } - const vote = e.target.closest('[data-vote]'); - if (vote && post) { - haptic('tick'); - await rooms.vote(post, vote.dataset.vote); - await rerender(scroller); - return true; - } - if (e.target.closest('[data-reply]') && post) { - onCompose(cur.s, { replyTo: post }); - return true; - } - const rate = e.target.closest('[data-rate]'); - if (rate && post) { - const nid = rate.closest('[data-note]').dataset.note; - const note = post.notes.find((n) => n.id === nid); - const mine = await rooms.rate(note, rate.dataset.rate === '1'); - const { why } = await rooms.myCluster(); - haptic('success'); - const st = noteStatus(note, mine); - toast( - `Rated as cluster ${mine.cluster} (${why})${st.shown ? ' · note now bridged' : ''}`, - { icon: icon('bridge') }, - ); - await rerender(scroller); - return true; - } - const cite = e.target.closest('[data-cite]'); - if (cite) { - const id = cite.dataset.cite; - const target = [...r.numbering.entries()].find(([, v]) => v === id)?.[0]; - const el = target - ? $(`[data-post="${CSS.escape(target)}"]`, cur.el) - : null; - if (el) { - el.scrollIntoView({ - block: 'center', - behavior: reducedMotion() ? 'auto' : 'smooth', - }); - el.classList.remove('flash'); - void el.offsetWidth; - el.classList.add('flash'); - } else - toast( - `${id}: ${id.startsWith('O') ? 'sensor observation from the brief' : 'cited source'}`, - { icon: icon('info') }, - ); - return true; - } - const react = e.target.closest('[data-react]'); - if (react) { - haptic('tick'); - rt.react(r.id, react.dataset.react); - floatReaction(react.closest('.sheet-dock'), react.dataset.react); - return true; - } - const tog = e.target.closest('[data-act="react-toggle"]'); - if (tog) { - const dock = tog.closest('.sheet-dock'); - const open = !dock.classList.contains('react-open'); - dock.classList.toggle('react-open', open); - tog.setAttribute('aria-expanded', String(open)); - haptic('tick'); - return true; - } - if (e.target.closest('[data-act="capture-reply"]')) { - onCapture(cur.s); - return true; - } - if (e.target.closest('[data-act="write"]')) { - onCompose(cur.s, {}); - return true; - } - return false; - } - - rt.on('react', ({ room, kind }) => { - if (cur && room === cur.r.id) - floatReaction(document.querySelector('.brief-sheet .sheet-dock'), kind); - }); - rt.on('presence', () => { - if (!cur) return; - const pr = $('.presence', cur.el); - if (pr) pr.outerHTML = presenceHtml(cur.r); - }); - rooms.on('post', (p) => { - if (cur && p.room === cur.r.id) { - const sc = cur.el.closest('.sheet-body'); - rerender(sc); - } - }); - return { render, - onClick, - reactionRail, - replyBar: () => - `
`, + /** Anchors need the world's camera; everything else the lanes handle. */ + async onClick(e, { flyTo, scrub } = {}) { + if (!cur) return false; + const a = e.target.closest?.('[data-anchor]'); + if (!a) return false; + const r = await rooms.room(cur.s); + const p = r.posts.find((x) => x.id === a.dataset.anchor); + if (!p?.anchor) return false; + haptic('light'); + flyTo?.(p.anchor, cur.s); + if (p.anchor.t) scrub?.(p.anchor.t); + return true; + }, + reactionRail: () => '', + replyBar: () => '', close() { - clearPins(); + cur?.panel?.destroy(); cur = null; - rt.leave(); }, get current() { return cur; }, fmtKm, + esc, }; } diff --git a/mobile/ui/rooms.js b/mobile/ui/rooms.js index 94fc31ce1..fab743162 100644 --- a/mobile/ui/rooms.js +++ b/mobile/ui/rooms.js @@ -76,8 +76,9 @@ const [HARBOR, MET, PILOT, MEDIC, RIVER, SKEPTIC, ORBIT, LENA, DATA] = EX; * text(s, v) → string, w was-there, sl sealed, re reply-to index, * note {text, r: 'AA+B+…'}, up/dn votes, an anchor offset {dLat,dLon}. */ -function genre(s) { +export function genre(s) { const k = `${s.kind || ''} ${s.archetype || ''}`; + if (s.momentId || /circling|moment/.test(k)) return 'circling'; if (/cyclone|typhoon|hurricane/.test(k)) return 'cyclone'; if (/quake/.test(k)) return 'quake'; if (/fire/.test(k)) return 'fire'; @@ -93,6 +94,67 @@ function genre(s) { } const T = { + circling: [ + { + k: 'witness', + a: RIVER, + m: 16, + w: 1, + sl: 1, + up: 47, + dn: 2, + d: [0.002, -0.003], + text: (s, v) => + `${v(0) ? `${v(0)} overhead` : 'Helicopters overhead'} for about fifteen minutes now. Two are holding high and steady, one is lower and tighter with a searchlight. Sealed clip from street level, looking up.`, + note: { + text: 'The clip’s heading points at the orbit centre and its seal time falls inside the detection window. ADS-B shows the same three tracks.', + r: 'A+A+A+B+B+', + }, + }, + { + k: 'question', + a: LENA, + m: 13, + up: 22, + text: () => + 'Does anyone know why they’re circling? Nothing on local news yet and the street is blocked at the corner.', + }, + { + k: 'context', + a: PILOT, + m: 11, + re: 1, + up: 39, + text: () => + 'The low one is flying a tight left-hand orbit, which is what police aviation does over a scene. The two higher ones at about 1,500 ft are holding still for camera shots. That pattern means something on the ground, not an aircraft in trouble.', + note: { + text: 'Consistent with the tracks: one orbit at ~400 m radius and ~1,000 ft, two at ~650 m and ~1,500 ft.', + r: 'A+A+B+B+B+', + }, + }, + { + k: 'correction', + a: SKEPTIC, + m: 8, + up: 26, + dn: 4, + text: () => + 'A post going around says it’s a shooting. Nothing in this room supports that, and the “scanner audio” people are quoting has no source. Wait for a sealed capture or an official statement.', + note: { + text: 'No sealed capture or official source here mentions a shooting.', + r: 'A+A+B+B+', + }, + }, + { + k: 'prediction', + a: DATA, + m: 4, + up: 9, + dn: 3, + text: () => + 'The news helicopters leave within thirty minutes of police clearing the scene; the police one stays longest.', + }, + ], cyclone: [ { k: 'witness', @@ -705,7 +767,43 @@ function metricText(s) { }; } +/** Posts a Moment ships with (engine `moment.posts`), as example seeds. */ +function momentSeeds(s) { + const caps = s.momentCaptures || []; + return s.momentPosts.map((mp, i) => { + const at = Number(mp.t) || Date.now(); + const cap = caps.find((c) => c.handle === mp.handle); + const there = mp.kind === 'witness' && !!cap; + const votes = mp.votes || {}; + return { + id: `${s.id}~mp${i + 1}`, + room: String(s.id), + kind: KIND[mp.kind] ? mp.kind : 'context', + text: String(mp.text || ''), + at, + author: { handle: mp.handle, example: true, cluster: i % 2 ? 'B' : 'A' }, + example: true, + evidence: { + wasThere: there, + sealed: there || !!cap?.sealed, + example: true, + distanceKm: cap ? 0.3 : null, + }, + votes: { + up: typeof votes === 'number' ? votes : votes.up || 0, + dn: votes.dn || 0, + }, + replyTo: null, + anchor: cap + ? { lat: cap.lat, lon: cap.lon, t: Number(cap.t) || at } + : null, + notes: [], + }; + }); +} + function seedPosts(s) { + if (s.momentPosts?.length) return momentSeeds(s); const tpl = T[genre(s)] || T.default; const v = metricText(s); const base = Number(new Date(s.updatedAt)) || Date.now(); @@ -1075,20 +1173,26 @@ export function createRooms({ rt }) { async function post(s, draft) { const m = await mineMap(); - const id = `${s.id}~${rt.tab}~${Date.now().toString(36)}`; + const id = draft.id || `${s.id}~${rt.tab}~${Date.now().toString(36)}`; + const dup = (m.get(String(s.id)) || []).find((x) => x.id === id); + if (dup) return dup; const p = { id, room: String(s.id), kind: draft.kind || 'witness', text: String(draft.text || '').slice(0, 2000), - at: Date.now(), - author: { + at: draft.at || Date.now(), + author: draft.author || { handle: rt.me.handle, self: true, local: !!(rt.me.home && distKm(rt.me.home, s) < 50), }, evidence: draft.evidence || {}, - votes: { up: 0, dn: 0 }, + quoteOf: draft.quoteOf || null, + promotedFrom: draft.promotedFrom || null, + needEyes: draft.needEyes || null, + momentId: draft.momentId || s.momentId || null, + votes: draft.votes || { up: 0, dn: 0 }, replyTo: draft.replyTo || null, anchor: draft.anchor || null, hologram: !!draft.hologram, diff --git a/mobile/ui/roomsTab.js b/mobile/ui/roomsTab.js new file mode 100644 index 000000000..eb890ff0c --- /dev/null +++ b/mobile/ui/roomsTab.js @@ -0,0 +1,217 @@ +/** + * The Rooms tab (DESIGN.md §12.7): live rooms now (watching, heat, a live + * line of chat), Moments (today's hero, live detections, your clips), Need + * eyes near you, and crews: yours, and ones to discover. + */ + +import { icon } from './icons.js'; +import { $, esc, ago, haptic, toast, esriThumb, reducedMotion } from './dom.js'; +import { BAND_COLOR, bandOf } from './bands.js'; +import { viewerCount, sampleLines, chatGenre } from './chatSim.js'; +import { richText } from './emotes.js'; +import { simTier, hashStr } from './signal.js'; +import { crewAvatar, fmtM } from './crews.js'; + +const fmtV = (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)); + +export function createRoomsTab(el, core) { + let tickers = []; + let tickTimer = 0; + let live = []; + let moments = []; + el.classList.add('rooms-tab'); + core.ctx.bindScroll?.(el); + el.addEventListener( + 'scroll', + () => el.classList.toggle('is-scrolled', el.scrollTop > 44), + { passive: true }, + ); + + function heatBars(s) { + let x = hashStr(s.id); + const bars = []; + for (let i = 0; i < 12; i += 1) { + x = (Math.imul(x, 1103515245) + 12345) >>> 0; + bars.push(0.25 + ((x >>> 8) % 100) / 133 + i / 30); + } + return ``; + } + + 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 ``; + } + + function momentHero(m) { + const ms = Math.max( + 1, + Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3), + ); + return `
+ +
${icon('sparkles')}Today’s Moment${m.illustrative ? 'Illustrative' : 'Detected · ADS-B'}
+
+

${esc(m.title)}

+
${esc(m.place || '')} · ${ms} min · ${(m.tracks || []).length || 3} aircraft · ${(m.captures || []).length} sealed captures
+
+ + + +
+
+
`; + } + + async function render() { + const sits = (await core.situations()) || []; + moments = await core.moments(); + const scored = sits + .filter((s) => Number.isFinite(s.lat)) + .map((s) => ({ s, v: viewerCount(s) * (1 + (s.severity || 0) * 0.2) })) + .sort((a, b) => b.v - a.v); + // Diversity: one per chat genre. + const seen = new Set(); + live = []; + for (const x of scored) { + const g = chatGenre(x.s); + if (seen.has(g)) continue; + seen.add(g); + live.push(x.s); + if (live.length >= 6) break; + } + const detections = sits + .filter((s) => s.momentId || /circling/.test(s.kind || '')) + .slice(0, 4); + const loc = core.myLoc(); + const needs = core.needs + .near(loc, 60) + .filter((r) => r.status === 'open') + .slice(0, 3); + const crews = core.crews.list(); + const mine = crews.filter((c) => c.joined); + const other = crews.filter((c) => !c.joined); + const hero = moments[0]; + el.innerHTML = ` + +

Rooms

+

Live is fast. The Record is true. Signal rewards being right, early, with receipts.

+ +
Live now ${live.reduce((a, s) => a + viewerCount(s), 0).toLocaleString()} watching
+
${live.map(liveRow).join('') || '
Nothing unusual right now. That’s a valid state.
'}
+ +
Moments 4D replays with chat and the record
+ ${hero ? momentHero(hero) : ''} + ${ + detections.length || moments.length > 1 + ? `
${[ + ...moments + .slice(1) + .map( + (m) => + ``, + ), + ...detections.map( + (s) => + ``, + ), + ].join('')}
` + : '' + } +
${icon('clock')}Your clipsSelect a range on the time machine and “Clip this Moment”. It lands here with its receipts.
+ +
Need eyes near you ${loc?.guessed ? 'approximate · share location for distances' : 'one quiet ask each'}
+ ${needs.length ? needs.map((n) => core.needs.cardHtml(n)).join('') : '
No open requests near you. Calm is a feature.
'} + +
Your crews ${mine.length ? `${mine.length} joined` : 'communities, not followers'}
+
${mine.map((c) => ``).join('') || '
Join a crew to see its feed here.
'}
+ ${ + other.length + ? `
Discover crews
${other.map((c) => `
${crewAvatar(c)}${esc(c.name)}${esc(c.tagline)}
`).join('')}
` + : '' + } +

Simulated chat and example crews are labelled. Your posts, votes, captures and Signal are real and stay on this device.

`; + // Tickers: a real-feeling line of each room's chat. + tickers = live.map((s) => ({ s, lines: sampleLines(s, 8), i: 0 })); + tickAll(true); + clearInterval(tickTimer); + tickTimer = setInterval(() => tickAll(false), 2600); + } + + function tickAll(first) { + if (!el.isConnected || (el.offsetParent === null && !first)) return; + tickers.forEach((t, k) => { + const node = $(`[data-tick="${k}"]`, el); + if (!node || !t.lines.length) return; + // Stagger so the list shimmers rather than blinks. + if (!first && Math.random() < 0.45) return; + const m = t.lines[t.i % t.lines.length]; + t.i += 1; + const tier = m.tier || simTier(m.handle); + node.innerHTML = `${esc(m.handle.slice(1))} ${richText(m.text).html}`; + }); + } + + el.addEventListener('click', async (e) => { + const room = e.target.closest('[data-room]'); + if (room) { + const sits = await core.situations(); + const s = sits.find((x) => String(x.id) === room.dataset.room); + if (s) core.openRoom(s, { lane: 'live' }); + return; + } + const mo = e.target.closest('[data-mo]'); + if (mo) { + haptic('light'); + const m = + moments.find((x) => x.id === mo.dataset.id) || + (await core.moment(mo.dataset.id)); + if (mo.dataset.mo === 'play') { + if (core.ctx.onPlayMoment) core.ctx.onPlayMoment(m); + else core.openRoom(m, { lane: 'live' }); + } else + core.openRoom(m, { + lane: mo.dataset.mo === 'record' ? 'record' : 'live', + }); + return; + } + const nd = e.target.closest('[data-need-act]'); + if (nd) { + core.needs.act(nd.dataset.needAct, nd.dataset.need); + return; + } + const jn = e.target.closest('[data-join]'); + if (jn) { + core.crews.toggle(jn.dataset.join); + haptic('success'); + toast('Joined', { icon: icon('users') }); + render(); + return; + } + const cr = e.target.closest('[data-crew]'); + if (cr) core.crews.open(cr.dataset.crew); + }); + const offN = core.needs.on('change', () => el.isConnected && render()); + const offC = core.crews.on('change', () => el.isConnected && render()); + + render(); + return { + refresh: render, + destroy() { + clearInterval(tickTimer); + offN?.(); + offC?.(); + el.innerHTML = ''; + }, + }; +} diff --git a/mobile/ui/signal.js b/mobile/ui/signal.js new file mode 100644 index 000000000..f2581d813 --- /dev/null +++ b/mobile/ui/signal.js @@ -0,0 +1,184 @@ +/** + * Signal (DESIGN.md §12.2): reputation for being right, early, with + * receipts. It rises when sealed captures are corroborated (cited by + * others, matched by sensor data), when a Need eyes request is fulfilled + * and when a note you rated ends up bridged; it falls when your claims are + * corrected. Every change is a receipt with its reason and its source, so + * the score is never a mystery. Follower counts do not exist. + * + * Simulated participants get a deterministic tier from their handle, so the + * chat's colours and vote weights are stable across reloads. + */ + +import { emitter } from './dom.js'; +import { get, put } from '../capture/db.js'; + +export const TIERS = [ + { id: 'new', name: 'New eyes', min: 0, color: '#a7b0bb', weight: 1 }, + { id: 'observer', name: 'Observer', min: 100, color: '#8fd0ff', weight: 1.2 }, + { id: 'spotter', name: 'Spotter', min: 300, color: '#6fe3b4', weight: 1.5 }, + { + id: 'corroborated', + name: 'Corroborated', + min: 700, + color: '#f2c96d', + weight: 1.9, + }, + { id: 'anchor', name: 'Anchor', min: 1500, color: '#c7a6ff', weight: 2.4 }, +]; + +/** What each kind of receipt is worth, in the open. */ +export const RULES = [ + { id: 'sealed', delta: 10, text: 'You posted a sealed capture to a room' }, + { id: 'there', delta: 15, text: 'Your capture was inside the event radius' }, + { + id: 'sensor', + delta: 20, + text: 'Sensor data matched your capture (ADS-B, radar, quakes)', + }, + { id: 'first', delta: 50, text: 'First eyes: the first sealed capture' }, + { id: 'fulfil', delta: 40, text: 'You fulfilled a Need eyes request' }, + { + id: 'corroborated', + delta: 3, + text: 'Someone corroborated your post (× their Signal weight)', + }, + { + id: 'note', + delta: 5, + text: 'A note you rated helpful became bridged', + }, + { id: 'promoted', delta: 8, text: 'Your chat message was promoted' }, + { + id: 'corrected', + delta: -25, + text: 'A bridged correction showed your claim was wrong', + }, + { + id: 'disputed', + delta: -5, + text: 'An unverified upload you posted was disputed', + }, +]; +export const RULE = Object.fromEntries(RULES.map((r) => [r.id, r])); + +export function tierOf(score = 0) { + let i = 0; + for (let k = 0; k < TIERS.length; k += 1) if (score >= TIERS[k].min) i = k; + const tier = TIERS[i]; + const next = TIERS[i + 1] || null; + const progress = next + ? (score - tier.min) / Math.max(1, next.min - tier.min) + : 1; + return { + ...tier, + index: i, + next, + progress: Math.max(0, Math.min(1, progress)), + }; +} + +export function hashStr(s) { + let x = 2166136261; + const str = String(s || ''); + for (let i = 0; i < str.length; i += 1) + x = Math.imul(x ^ str.charCodeAt(i), 16777619); + return x >>> 0; +} + +/** + * A stable simulated Signal score for a fictional handle. Personas can pin + * a minimum (moderators and domain experts are rarely New eyes). + */ +export function simScore(handle, floor = 0) { + const h = hashStr(handle); + // Skewed: most chatters are New eyes or Observers, few are Anchors. + const u = (h % 1000) / 1000; + const raw = Math.round(1900 * u ** 2.3); + return Math.max(floor, raw); +} + +export function simTier(handle, floor = 0) { + return tierOf(simScore(handle, floor)); +} + +/** Vote weight shown next to a corroboration: "× 1.5". */ +export const weightOf = (score) => tierOf(score).weight; + +export function createSignal({ me } = {}) { + const ev = emitter(); + let st = null; // Promise<{ id, receipts: [] }> + + const load = () => + (st = + st || + get('kv', 'signal') + .catch(() => null) + .then((r) => r || { id: 'signal', receipts: [] })); + + async function save() { + const s = await load(); + await put('kv', s).catch(() => {}); + } + + async function receipts() { + const s = await load(); + return [...s.receipts].sort((a, b) => b.at - a.at); + } + + async function score() { + const s = await load(); + return Math.max( + 0, + Math.round(s.receipts.reduce((a, r) => a + (r.delta || 0), 0)), + ); + } + + /** + * Record a receipt. Idempotent by id, so replays never double count. + * @param {{ id: string, rule: string, delta?: number, text: string, + * ref?: object, source?: string }} r + */ + async function award(r) { + const s = await load(); + if (s.receipts.some((x) => x.id === r.id)) return null; + const before = await score(); + const rec = { + at: Date.now(), + delta: r.delta ?? RULE[r.rule]?.delta ?? 0, + ...r, + }; + s.receipts.push(rec); + await save(); + const after = await score(); + const t0 = tierOf(before); + const t1 = tierOf(after); + const out = { + receipt: rec, + before, + after, + tier: t1, + up: t1.index > t0.index, + }; + ev.emit('change', out); + return out; + } + + /** The person's tier right now (sync best effort for rendering). */ + let cached = 0; + score().then((v) => (cached = v)); + ev.on('change', (o) => (cached = o.after)); + + return { + on: ev.on, + off: ev.off, + receipts, + score, + award, + get cached() { + return cached; + }, + tier: () => tierOf(cached), + me, + }; +} diff --git a/mobile/ui/social-dev.css b/mobile/ui/social-dev.css new file mode 100644 index 000000000..18d0ad9b7 --- /dev/null +++ b/mobile/ui/social-dev.css @@ -0,0 +1,77 @@ +/* Social harness only: a minimal shell around the social mount points. */ +html, +body { + height: 100%; + margin: 0; + background: #050607; + color: var(--ink); + font-family: var(--font); + overflow: hidden; +} +.sd-app { + position: fixed; + inset: 0; +} +.sd-app .stage { + position: absolute; + inset: 0; +} +.sd-lead { + position: absolute; + inset: 0; + z-index: 2; + background: var(--ground); +} +.sd-app[data-tab='globe'] .sd-lead, +.sd-app[data-tab='overlay'] .sd-lead { + display: none; +} +.sd-lead .view { + position: absolute; + inset: 0; + overflow-y: auto; + padding-bottom: 120px; +} +.sd-app .capture { + position: absolute; + inset: 0; + z-index: 3; +} +.sd-tabs { + position: absolute; + z-index: 50; + left: 50%; + bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + display: flex; + gap: 2px; + padding: 4px; + border-radius: 999px; + background: rgba(20, 22, 28, 0.82); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.14); +} +.sd-tabs button { + height: 40px; + padding: 0 12px; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 650; + color: var(--ink-2); +} +.sd-tabs button[aria-pressed='true'] { + color: #05070b; + background: var(--ink); +} +.sd-overlay { + position: absolute; + z-index: 4; + left: 10px; + width: min(360px, calc(100% - 20px)); + bottom: 70px; + height: 340px; +} +.sd-overlay[hidden] { + display: none; +} diff --git a/mobile/ui/social-dev.html b/mobile/ui/social-dev.html new file mode 100644 index 000000000..ec7fdfcce --- /dev/null +++ b/mobile/ui/social-dev.html @@ -0,0 +1,63 @@ + + + + + + Social harness + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+ +
+ + +
+ + diff --git a/mobile/ui/social-dev.js b/mobile/ui/social-dev.js new file mode 100644 index 000000000..6001d46fc --- /dev/null +++ b/mobile/ui/social-dev.js @@ -0,0 +1,155 @@ +/** + * Social harness (dev only): mounts the Rooms tab, a room in both lanes, the + * You tab, Capture with Need eyes, and the Moment chat overlay against the + * offline stub globe, so the social layer can be tested on its own. + * /mobile/ui/social-dev.html?tab=rooms|you|globe|capture|overlay + * &room=|moment&lane=live|record&lat=&lon= + */ + +import { $, $$ } from './dom.js'; +import { mountGlobe } from './globeHost.js'; +import { createRealtime } from './realtime.js'; +import { createRooms } from './rooms.js'; +import { createAttach } from './attach.js'; +import { + configureSocial, + mountRoomsTab, + mountYouTab, + openRoom, + chatOverlay, + needEyesNear, + momentRoom, +} from './social.js'; + +const q = new URLSearchParams(location.search); +const app = $('#app'); +let loc = + q.get('lat') && q.get('lon') + ? { lat: Number(q.get('lat')), lon: Number(q.get('lon')) } + : null; + +const g = await mountGlobe($('#globe'), { preferStub: true }); +const rt = createRealtime(); +const rooms = createRooms({ rt }); +const getSituations = () => g.situations(loc ? { observer: loc } : undefined); + +let capture = null; +const core = configureSocial({ + globe: g, + rt, + rooms, + getSituations, + getLocation: () => loc, + onShowGlobe: (s) => { + if (tab !== 'globe' && tab !== 'overlay') select('globe'); + if (Number.isFinite(s.lat)) + g.flyTo({ + lon: s.lon, + lat: s.lat, + height: s.momentId ? 30e3 : 400e3, + duration: 1.2, + }); + }, + onCapture: async (s, o) => { + await select('capture'); + capture?.attachTo(s, o); + }, + onPlayMoment: (m) => { + select('overlay'); + playOverlay(m); + }, +}); + +const rtab = mountRoomsTab($('#view-rooms'), {}); +const you = mountYouTab($('#view-you'), {}); + +const attach = createAttach({ + rooms, + globe: g, + getSituations, + onPosted: () => { + rtab.refresh(); + you.render(); + }, +}); + +let tab = 'rooms'; +async function select(t) { + if (t === tab) return; + const prev = tab; + tab = t; + app.dataset.tab = t; + $$('.sd-tabs [data-sd]').forEach((b) => + b.setAttribute('aria-pressed', String(b.dataset.sd === t)), + ); + $('#view-rooms').hidden = t !== 'rooms'; + $('#view-you').hidden = t !== 'you'; + $('#view-capture').hidden = t !== 'capture'; + $('.sd-overlay').hidden = t !== 'overlay'; + if (prev === 'capture') capture?.leave(); + if (t === 'capture') { + if (!capture) { + const { createCapture } = await import('./capture.js'); + capture = createCapture($('#view-capture'), { + globe: g, + rt, + attach, + getLocation: () => loc, + onSealed: () => you.render(), + onVerify: () => {}, + }); + window.__sd.capture = capture; + } + capture.enter(); + } + if (t === 'you') you.render(); + if (t === 'overlay' && !overlay) playOverlay((await core.moments())[0]); +} +$('.sd-tabs').addEventListener('click', (e) => { + const b = e.target.closest('[data-sd]'); + if (b) select(b.dataset.sd); +}); + +/* Moment chat overlay on a simple clock. */ +let overlay = null; +let clock = 0; +function playOverlay(m) { + overlay?.destroy(); + clearInterval(clock); + const el = $('.sd-overlay'); + el.innerHTML = ''; + overlay = chatOverlay(el, { globe: g, moment: m }); + let t = +m.time.start; + overlay.setTime(t); + g.flyTo({ lon: m.lon, lat: m.lat, height: 5000, duration: 1 }); + clock = setInterval(() => { + t += 15000; + if (t > +m.time.end) t = +m.time.start; + overlay.setTime(t); + }, 700); +} + +window.__sd = { + g, + rt, + rooms, + core, + openRoom, + select, + needEyesNear, + momentRoom, + setLoc: (l) => (loc = l), + playOverlay, +}; + +const t0 = q.get('tab'); +if (t0) await select(t0); +const rid = q.get('room'); +if (rid) { + const target = + rid === 'moment' + ? (await core.moments())[0] + : (await getSituations()).find((s) => String(s.id) === rid); + if (target) await openRoom(target, { lane: q.get('lane') || 'live' }); +} +document.documentElement.dataset.ready = '1'; diff --git a/mobile/ui/social.js b/mobile/ui/social.js new file mode 100644 index 000000000..956aeb239 --- /dev/null +++ b/mobile/ui/social.js @@ -0,0 +1,651 @@ +/** + * God's Eye social layer (DESIGN.md §12.2, §12.3, §12.6): the mount points + * the shell integrates, and the one shared social core behind them. + * + * configureSocial(ctx) → core (call once from main.js) + * mountRoomsTab(el, ctx) → { refresh(), destroy() } + * openRoom(situationOrMoment, { lane: 'live'|'record', focusPost?, crew?, detent? }) + * → Promise<{ sheet, panel, close() }> + * mountYouTab(el, ctx) → { render(), destroy() } + * chatOverlay(el, ctx) → { push(msg), setMoment(moment), setTime(t), destroy() } + * needEyesNear(lonlat, km = 25) → Request[] (sync; open requests first) + * onSocial(event, fn) → unsubscribe ('moment-capture' | 'promote' | 'clerk' | 'signal') + * momentCaptures(momentId) → Promise (the person's captures posted into a Moment) + * + * ctx (every key optional, read late so it can be filled in after boot): + * { globe, store, rt, rooms, getSituations(), getLocation(), bindScroll(el), + * onCapture(situation, { needEyes?, replyTo? }), onOpenSituation(s), + * onPlayMoment(moment), onShowGlobe(situation), onVerify(bundle), + * onCredits(), watch: { summary()? , open()? }, sheetHost } + * Pass main.js's `rt` and `rooms` so the app keeps one realtime channel. + */ + +import { icon } from './icons.js'; +import { h, esc, emitter, toast, haptic, store as domStore } from './dom.js'; +import { createSheet } from './sheet.js'; +import { createRealtime } from './realtime.js'; +import { createRooms } from './rooms.js'; +import { createSignal, tierOf, RULE, simTier } from './signal.js'; +import { createRoomHub, mountChatOverlay, guessLoc } from './liveChat.js'; +import { createRoomPanel } from './room.js'; +import { createNeeds, celebrate } from './needEyes.js'; +import { createCrews } from './crews.js'; +import { createComposer } from './composer.js'; +import { PERSONAS, viewerCount } from './chatSim.js'; +import { BAND_NAME, bandOf, fmtKm, utc, distKm } from './bands.js'; +import { get, put } from '../capture/db.js'; + +/* ---------------- Styles (no index.html change needed) ---------------- */ +function ensureCss() { + if ( + typeof document === 'undefined' || + document.getElementById('gev-social-css') + ) + return; + const l = document.createElement('link'); + l.id = 'gev-social-css'; + l.rel = 'stylesheet'; + l.href = new URL('../styles/social.css', import.meta.url).href; + document.head.appendChild(l); +} + +/* ---------------- Moments ---------------- */ + +/** The hero Moment when the engine has none (illustrative, labelled). */ +function heroFallback() { + const end = Date.now() - 4 * 60e3; + return { + id: 'empire-state', + title: 'Climbers on the Empire State Building', + place: 'Midtown Manhattan, New York', + lon: -73.9857, + lat: 40.7484, + time: { start: end - 18 * 60e3, end }, + illustrative: true, + tracks: [ + { id: 'n1', kind: 'helicopter', label: 'News 1', operator: 'News' }, + { id: 'n2', kind: 'helicopter', label: 'News 2', operator: 'News' }, + { id: 'p1', kind: 'helicopter', label: 'Police', operator: 'Police' }, + ], + captures: [], + chat: [], + posts: [], + sources: [ + { name: 'Illustrative reconstruction', detail: 'Synthetic tracks' }, + ], + }; +} + +export function isMoment(t) { + return ( + !!t && + !t.momentPosts && + !!(t.tracks || t.cameraPath || (t.time && t.captures)) + ); +} + +/** A Moment as a room: the same Live and Record lanes, on its evidence. */ +export function momentRoom(m) { + const tracks = m.tracks || []; + const caps = m.captures || []; + const mins = Math.max( + 1, + Math.round(((+m.time?.end || 0) - (+m.time?.start || 0)) / 60e3), + ); + return { + id: `moment:${m.id}`, + momentId: m.id, + moment: m, + kind: 'circling', + family: 'sky', + band: 'sky', + archetype: 'track', + title: m.title, + place: m.place, + subtitle: m.illustrative + ? 'Illustrative reconstruction' + : 'Detected from live ADS-B', + lon: m.lon, + lat: m.lat, + live: !m.illustrative, + illustrative: !!m.illustrative, + updatedAt: +m.time?.end || Date.now(), + time: m.time, + source: m.illustrative ? 'Illustrative reconstruction' : 'ADS-B detection', + severity: 2, + metrics: [ + { label: 'Aircraft circling', value: tracks.length || 3 }, + { label: 'Sealed captures', value: caps.length }, + { label: 'Window', value: mins, unit: 'min' }, + ], + momentPosts: m.posts?.length ? m.posts : null, + momentCaptures: caps, + camera: m.cameraPath?.[0] || { + lon: m.lon, + lat: m.lat, + height: 2500, + heading: 0, + pitch: -35, + }, + layers: [], + }; +} + +/* ---------------- The core ---------------- */ + +let core = null; + +/** + * The shared social core. The first call builds it; later calls merge ctx. + * @param {object} ctx + */ +export function socialCore(ctx = {}) { + ensureCss(); + if (core) { + Object.assign(core.ctx, strip(ctx)); + if (ctx.globe && !core.globe) core.globe = ctx.globe; + return core; + } + core = buildCore(ctx); + return core; +} +export const configureSocial = socialCore; +/** Accept the shell's names too (openSituation, openMoment, openWatch). */ +const strip = (o) => { + const x = Object.fromEntries( + Object.entries(o || {}).filter(([, v]) => v != null), + ); + if (!x.onOpenSituation && x.openSituation) + x.onOpenSituation = x.openSituation; + if (!x.onPlayMoment && x.openMoment) x.onPlayMoment = x.openMoment; + if (!x.watch && x.openWatch) x.watch = { open: x.openWatch }; + else if (x.watch && !x.watch.open && x.openWatch) + x.watch = Object.assign(Object.create(x.watch), { open: x.openWatch }); + return x; +}; + +function buildCore(ctx) { + const ev = emitter(); + const c = { + ctx: strip(ctx), + ev, + on: ev.on, + off: ev.off, + hubs: new Map(), + }; + c.globe = ctx.globe || null; + c.store = ctx.store || domStore; + c.rt = ctx.rt || createRealtime(); + c.rooms = ctx.rooms || createRooms({ rt: c.rt }); + c.signal = createSignal({ me: c.rt.me }); + c.signal.on('change', (x) => ev.emit('signal', x)); + + c.situations = async () => { + try { + if (c.ctx.getSituations) return (await c.ctx.getSituations()) || []; + if (c.globe?.situations) return (await c.globe.situations()) || []; + } catch { + /* ignore */ + } + return []; + }; + c.getLocation = () => c.ctx.getLocation?.() || null; + c.myLoc = () => c.getLocation() || c.rt.me.home || guessLoc(); + c.hub = (s) => { + if (!s) return null; + const id = String(s.id); + if (!c.hubs.has(id)) c.hubs.set(id, createRoomHub(c, s)); + return c.hubs.get(id); + }; + c.onCapture = (s, o = {}) => { + roomSheet?.close(); + if (c.ctx.onCapture) return c.ctx.onCapture(s, o); + // Fallback: switch to Capture and pre-attach through the shell's handle. + const cap = typeof window !== 'undefined' ? window.__gev?.capture : null; + if (c.ctx.selectTab) { + c.ctx.selectTab('capture'); + setTimeout(() => (window.__gev?.capture || cap)?.attachTo?.(s, o), 60); + return null; + } + toast('Open Capture to answer with a sealed photo', { + icon: icon('viewfinder'), + }); + return null; + }; + + /* Moments, feature-detected on the engine. */ + const eng = () => c.globe?.raw || c.globe; + c.moments = async () => { + try { + const fn = c.globe?.moments || eng()?.moments; + if (typeof fn === 'function') { + const list = await fn.call(c.globe?.moments ? c.globe : eng()); + if (Array.isArray(list) && list.length) return list; + } + } catch { + /* ignore */ + } + return [heroFallback()]; + }; + c.moment = async (id) => { + try { + const fn = c.globe?.moment || eng()?.moment; + if (typeof fn === 'function') { + const m = await fn.call(c.globe?.moment ? c.globe : eng(), id); + if (m) return m; + } + } catch { + /* ignore */ + } + return (await c.moments()).find((m) => m.id === id) || heroFallback(); + }; + + /* Where the person has a sealed capture (for the Witness glow). */ + const witnessRooms = new Set(); + c.rooms + .myPosts() + .then((list) => + list.forEach((p) => p.evidence?.sealed && witnessRooms.add(p.room)), + ) + .catch(() => {}); + c.myWitness = (room) => witnessRooms.has(String(room)); + + /* Simulated corroboration of the person's sealed posts (labelled). */ + let simc = {}; + get('kv', 'simcorro') + .then((r) => (simc = r?.map || {})) + .catch(() => {}); + c.simCorro = (postId) => simc[postId] || []; + async function simulateCorroboration(s, post) { + const cast = [ + ...PERSONAS.local, + ...PERSONAS.wx, + ...PERSONAS.pilot, + ...PERSONAS.photo, + ...PERSONAS.skeptic, + ].sort(() => Math.random() - 0.5); + const n = 3 + Math.floor(Math.random() * 3); + for (let i = 0; i < n; i += 1) { + const p = cast[i]; + setTimeout( + async () => { + const t = simTier(p.handle, p.floor); + simc[post.id] = [ + ...(simc[post.id] || []), + { handle: p.handle, w: t.weight, at: Date.now() }, + ]; + await put('kv', { id: 'simcorro', map: simc }).catch(() => {}); + const delta = Math.round(RULE.corroborated.delta * t.weight); + await c.signal.award({ + id: `corro:${post.id}:${p.handle}`, + rule: 'corroborated', + delta, + text: `${p.handle} (${t.name}, ×${t.weight.toFixed(1)}) corroborated your capture in ${s.title} · simulated`, + ref: { room: String(s.id), post: post.id }, + sim: true, + }); + ev.emit('simcorro', { room: String(s.id), post: post.id }); + if (i === 0) + toast(`${p.handle} corroborated your capture · +${delta} Signal`, { + icon: icon('agree'), + }); + }, + 7000 + i * (3500 + Math.random() * 4000), + ); + } + } + + /** + * After a capture is posted (attach.js calls this): receipts, First eyes, + * Need eyes fulfilment, the byline moment, and the Moment timeline. + */ + c.afterPost = async (s, post, item = {}, { needEyes = null } = {}) => { + if (!post?.evidence?.sealed) return null; + witnessRooms.add(String(s.id)); + const before = await c.signal.score(); + const receipts = []; + const add = async (r) => { + const out = await c.signal.award(r); + if (out) receipts.push(out.receipt); + }; + const ref = { room: String(s.id), post: post.id }; + await add({ + id: `sealed:${post.id}`, + rule: 'sealed', + text: `Sealed capture posted to ${s.title}`, + ref, + }); + if (post.evidence.wasThere) + await add({ + id: `there:${post.id}`, + rule: 'there', + text: `Inside the ${needEyes ? 'request' : 'event'} radius${Number.isFinite(post.evidence.distanceKm) ? ` · ${fmtKm(post.evidence.distanceKm)} ${needEyes ? 'from the spot' : 'away'}` : ''}`, + ref, + }); + // First eyes on the room: no earlier sealed capture from inside it. + const r = await c.rooms.room(s).catch(() => null); + const earlier = (r?.posts || []).filter( + (p) => p.id !== post.id && p.evidence?.sealed && p.evidence?.wasThere, + ); + const roomFirst = post.evidence.wasThere && !earlier.length; + let km = NaN; + if (Number.isFinite(post.evidence.distanceKm)) + km = post.evidence.distanceKm; + else if (needEyes && post.anchor && Number.isFinite(post.anchor.lat)) + km = distKm(post.anchor, needEyes); + let needFirst = false; + if (needEyes) { + const live = c.needs.byId(needEyes.id); + needFirst = !live?.fulfilledBy; + await c.needs.fulfil(needEyes, post, { km }); + await add({ + id: `fulfil:${needEyes.id}`, + rule: 'fulfil', + text: `Fulfilled Need eyes: “${needEyes.question}”`, + ref, + }); + } + const first = roomFirst || needFirst; + if (first) + await add({ + id: `first:${needEyes ? `need:${needEyes.id}` : s.id}`, + rule: 'first', + text: needEyes + ? `First eyes on “${needEyes.question}”` + : `First eyes on ${s.title}`, + ref, + }); + // Sensor corroboration: real contacts near the sealed position. + const a = post.anchor; + if (a && Number.isFinite(a.lat) && c.globe?.contactsNear) { + const near = (c.globe.contactsNear(a.lon, a.lat, 8) || []).filter((x) => + /aircraft|helicopter|ship|vessel/.test(x.kind), + ); + if (near.length) { + const air = near.filter((x) => + /aircraft|helicopter/.test(x.kind), + ).length; + await add({ + id: `sensor:${post.id}`, + rule: 'sensor', + text: `${air ? `ADS-B: ${air} aircraft` : `AIS: ${near.length} vessels`} within 8 km at the sealed time`, + ref, + source: air ? 'ADS-B' : 'AIS', + }); + } + } + const after = await c.signal.score(); + const handle = c.rt.me.handle; + const at = post.anchor?.t || post.at; + const byline = `${first ? 'FIRST EYES' : 'SEALED'} · ${handle} · ${utc(new Date(at))}${Number.isFinite(km) ? ` · ${fmtKm(km).toUpperCase()}` : ''}`; + // The Live lane hears about it. + c.hubs.get(String(s.id))?.push({ + id: `sys-cap-${post.id}`, + system: true, + handle: 'Record', + text: `${handle} posted a sealed capture${Number.isFinite(km) ? ` from ${fmtKm(km)}` : ''}${first ? ' · First eyes' : ''}.`, + at: Date.now(), + ref: post.id, + }); + c.hubs.get(String(s.id))?.refreshFirst(); + // A Moment's timeline picks it up. + const mid = s.momentId || activeMomentId(); + if (mid) { + const cap = { + id: `capture:${mid}:${post.id}`, + t: at, + lon: a?.lon, + lat: a?.lat, + headingDeg: post.evidence.headingDeg ?? null, + fovDeg: 62, + title: post.text, + handle, + sealed: true, + firstEyes: first, + post: post.id, + self: true, + }; + const rec = await get('kv', 'momentCaptures').catch(() => null); + const map = rec?.map || {}; + map[mid] = [...(map[mid] || []).filter((x) => x.id !== cap.id), cap]; + await put('kv', { id: 'momentCaptures', map }).catch(() => {}); + const fn = c.globe?.addMomentCapture || eng()?.addMomentCapture; + if (typeof fn === 'function') + try { + fn.call(c.globe?.addMomentCapture ? c.globe : eng(), mid, cap); + } catch { + /* optional */ + } + ev.emit('moment-capture', { momentId: mid, capture: cap }); + } + simulateCorroboration(s, post); + const total = receipts.reduce((x, y) => x + (y.delta || 0), 0); + if (needEyes || first) + celebrate({ + byline, + first, + need: needEyes, + receipts, + before, + after, + onRecord: () => openRoom(s, { lane: 'record', focusPost: post.id }), + }); + else if (total) + toast(`+${total} Signal · ${receipts.length} receipts`, { + icon: icon('sparkles'), + }); + return { receipts, before, after, first }; + }; + function activeMomentId() { + try { + const fn = c.globe?.activeMoment || eng()?.activeMoment; + return typeof fn === 'function' + ? fn.call(c.globe?.activeMoment ? c.globe : eng())?.id || null + : null; + } catch { + return null; + } + } + + /* Cross-tab traffic into the right hub. */ + c.rt.on('chat', ({ room, msg }) => { + const hub = c.hubs.get(String(room)); + if (hub && msg) hub.push({ ...msg, tier: msg.tier || tierOf(0) }); + }); + c.rt.on('react', ({ room, kind, remote }) => { + const hub = c.hubs.get(String(room)); + if (hub && ['heart', 'fire', 'eyes', 'wow'].includes(kind)) + hub.react(kind, { remote }); + }); + c.rt.on('corro', ({ room, id, w }) => { + const hub = c.hubs.get(String(room)); + const m = hub?.log.find((x) => x.id === id); + if (m) hub.addCorro(m, `tab:${id}:${Math.random()}`, w || 1); + }); + + c.needs = createNeeds(c); + c.crews = createCrews(c); + c.openRoom = (t, o) => openRoom(t, o); + c.composer = null; + c.getComposer = () => { + // Modal sheets made earlier must still open above the room sheet. + const r = c.composer?.root; + r?.parentElement?.appendChild(r); + return (c.composer = + c.composer || + createComposer({ + rooms: c.rooms, + getTime: () => c.ctx.getTime?.() || null, + getLocation: c.getLocation, + getView: () => null, + onCapture: (s, o) => c.onCapture(s, o), + onPosted: () => {}, + })); + }; + return c; +} + +/* ---------------- openRoom ---------------- */ + +let roomSheet = null; +let openPanel = null; + +/** + * Open a room sheet in a lane. Accepts a Situation or a Moment. + * @param {object} target + * @param {{ lane?: 'live'|'record', focusPost?: string, crew?: object, detent?: string }} o + */ +export async function openRoom(target, o = {}) { + const c = socialCore(); + if (!target) return null; + const s = isMoment(target) ? momentRoom(target) : target; + const lane = o.lane === 'record' ? 'record' : 'live'; + if (!roomSheet) + roomSheet = createSheet({ + host: + c.ctx.sheetHost || document.getElementById('sheets') || document.body, + label: 'Room', + detents: ['medium', 'large'], + dismissible: true, + closeButton: true, + className: 'room-sheet', + heights: { + medium: (H) => Math.round(H * 0.56), + large: (H) => Math.round(H * 0.94), + }, + }); + const sheet = roomSheet; + openPanel?.destroy(); + openPanel = null; + let dock = sheet.sheet.querySelector('.sheet-dock'); + if (!dock) { + dock = h('
'); + sheet.sheet.appendChild(dock); + } + dock.innerHTML = ''; + sheet.body.innerHTML = ''; + const band = bandOf(s); + const hub = c.hub(s); + sheet.sheet.dataset.band = band; + sheet.head.innerHTML = `
${esc(BAND_NAME[band] || 'Room')}${s.live === false && !s.momentId ? 'Room' : 'Live room'}·${esc(s.place || '')}
+

${esc(s.title)}

+ ${o.crew ? `
${icon('users')}Watching with ${esc(o.crew.name)} · ${12 + (o.crew.members % 40)} crew hereExample
` : ''} + ${s.illustrative ? '
Illustrative reconstruction · tracks are synthetic
' : ''}`; + const panel = createRoomPanel(c, s, { + body: sheet.body, + dock, + switchHost: sheet.head, + lane, + focusPost: o.focusPost, + onCompose: (x, y) => c.getComposer().open(x, y), + onCapture: (x, y) => { + sheet.close(); + c.onCapture(x, y); + }, + flyTo: (a) => { + sheet.setDetent('medium'); + c.globe?.flyTo?.({ + lon: a.lon, + lat: a.lat, + height: 2500, + pitch: -45, + duration: 1.6, + }); + }, + scrub: null, + onLane: (l) => { + sheet.sheet.dataset.lane = l; + }, + }); + openPanel = panel; + const detent = + o.detent || (lane === 'live' && c.ctx.onShowGlobe ? 'medium' : 'large'); + if (c.ctx.onShowGlobe) c.ctx.onShowGlobe(s); + sheet.open(detent); + const offClose = sheet.on('close', () => { + offClose?.(); + if (openPanel === panel) { + panel.destroy(); + openPanel = null; + } + }); + haptic('light'); + void hub; + return { sheet, panel, close: () => sheet.close() }; +} + +/* ---------------- Tabs ---------------- */ + +export function mountRoomsTab(el, ctx = {}) { + const c = socialCore(ctx); + let api = null; + const p = import('./roomsTab.js').then( + (m) => (api = m.createRoomsTab(el, c)), + ); + return { + refresh: () => p.then(() => api?.refresh()), + destroy: () => p.then(() => api?.destroy()), + }; +} + +export function mountYouTab(el, ctx = {}) { + const c = socialCore(ctx); + let api = null; + const p = import('./you.js').then((m) => { + api = m.createYou(el, { + bindScroll: c.ctx.bindScroll || (() => {}), + getSituations: c.situations, + onOpenSituation: (s) => c.ctx.onOpenSituation?.(s), + onVerify: (b) => c.ctx.onVerify?.(b), + onCredits: () => c.ctx.onCredits?.(), + rooms: c.rooms, + rt: c.rt, + onOpenRoom: (s, postId) => + openRoom(s, { lane: 'record', focusPost: postId }), + onLocate: async () => c.ctx.onLocate?.() || c.getLocation(), + core: c, + }); + api.render(); + return api; + }); + return { + render: () => p.then(() => api?.render()), + destroy: () => p.then(() => (el.innerHTML = '')), + }; +} + +/** Twitch-style chat overlay for the Moment player. */ +export function chatOverlay(el, ctx = {}) { + const c = socialCore(ctx); + return mountChatOverlay(c, el, { + globe: ctx.globe || c.globe, + moment: ctx.moment, + }); +} + +/** Open Need eyes requests near a point (sync; for Today and Capture). */ +export function needEyesNear(lonlat, km = 25) { + const c = socialCore(); + return c.needs.near(lonlat, km); +} + +export function onSocial(event, fn) { + return socialCore().on(event, fn); +} + +export async function momentCaptures(momentId) { + const rec = await get('kv', 'momentCaptures').catch(() => null); + return rec?.map?.[momentId] || []; +} + +export function openCrew(id, o) { + return socialCore().crews.open(id, o); +} +export function openNeedEyes(situation) { + return socialCore().needs.openCreate(situation); +} +export function signalNow() { + const c = socialCore(); + return { score: c.signal.cached, tier: c.signal.tier() }; +} +export { celebrate, viewerCount }; diff --git a/mobile/ui/socialBridge.js b/mobile/ui/socialBridge.js new file mode 100644 index 000000000..713c15405 --- /dev/null +++ b/mobile/ui/socialBridge.js @@ -0,0 +1,33 @@ +/** + * The social agent's mount points (rooms, chat, You) live in ./social.js. + * Feature-detected: a checkout without it still builds and the shell shows + * its own fallbacks. A glob (not a bare dynamic import) lets Vite pick the + * file up as soon as it appears. + */ + +let mod; +export function social() { + if (mod !== undefined) return mod; + const loaders = import.meta.glob('./social.js'); + const load = loaders['./social.js']; + mod = load + ? load().catch((err) => { + console.warn('[social] social.js failed to load', err); + return null; + }) + : Promise.resolve(null); + return mod; +} + +/** Call an export if it exists; resolves to { ok, value }. */ +export async function callSocial(name, ...args) { + const m = await social(); + const fn = m?.[name]; + if (typeof fn !== 'function') return { ok: false }; + try { + return { ok: true, value: await fn(...args) }; + } catch (err) { + console.warn(`[social] ${name} failed`, err); + return { ok: false, error: err }; + } +} diff --git a/mobile/ui/you.js b/mobile/ui/you.js index ea60ae68e..ae2d8f3ed 100644 --- a/mobile/ui/you.js +++ b/mobile/ui/you.js @@ -1,6 +1,8 @@ /** - * You: your sealed captures and live bundles, unverified uploads, your room - * posts and holograms, followed situations, home area, and settings. + * You (DESIGN.md §12.2, §12.7): Signal with its receipts, First eyes + * bylines, your sealed captures and uploads, your posts and holograms, + * what you watch, calm-by-default notifications, location precision, the + * privacy line we don't cross, and settings. */ import { icon, FAMILY_ICON } from './icons.js'; @@ -8,7 +10,51 @@ import { $, $$, esc, ago, haptic, store, applyMotionPref } from './dom.js'; import { all, get } from '../capture/db.js'; import { deviceKey } from '../capture/provenance.js'; import { KIND, badgesFor } from './rooms.js'; -import { llShort } from './bands.js'; +import { llShort, utc } from './bands.js'; +import { RULES, tierOf, TIERS } from './signal.js'; + +/** Shown, dimmed and labelled, until the person has receipts of their own. */ +const EXAMPLE_LEDGER = [ + { + delta: 40, + text: 'Fulfilled Need eyes: “Is the 7th St underpass flooded?”', + m: 1440, + }, + { delta: 50, text: 'First eyes on Heavy rain cell over Houston', m: 1440 }, + { + delta: 20, + text: 'ADS-B: 4 aircraft within 8 km at the sealed time', + m: 1441, + }, + { + delta: 5, + text: '@wxdan (Corroborated, ×1.9) corroborated your capture', + m: 1420, + }, + { + delta: -25, + text: 'A bridged correction: your “Cat 5” claim used the wrong scale', + m: 4320, + }, +]; + +const NOTIFY = [ + { + id: 'severe', + label: 'Severe, near me', + sub: 'Only things that can hurt people, inside your alert radius', + }, + { + id: 'notable', + label: 'Notable', + sub: 'Plus wonder: ISS passes, launches, aurora over you', + }, + { + id: 'all', + label: 'Everything', + sub: 'Every situation inside the radius. Not recommended.', + }, +]; export function createYou( view, @@ -22,8 +68,14 @@ export function createYou( rt, onOpenRoom, onLocate, + core: coreIn, }, ) { + let core = coreIn || null; + const getCore = async () => + (core = + core || + (await import('./social.js')).socialCore({ rooms, rt, getSituations })); let urls = []; view.addEventListener( 'scroll', @@ -42,6 +94,28 @@ export function createYou( all('uploads').catch(() => []), rooms.myPosts().catch(() => []), ]); + const c = await getCore(); + const [score, receipts] = await Promise.all([ + c.signal.score(), + c.signal.receipts(), + ]); + const tier = tierOf(score); + const firsts = receipts.filter((r) => r.rule === 'first'); + const exampleLedger = !receipts.length; + const ledger = exampleLedger + ? EXAMPLE_LEDGER.map((x) => ({ + ...x, + at: Date.now() - x.m * 60e3, + example: true, + })) + : receipts; + const loc = c.myLoc(); + const needs = c.needs.near(loc, 60).filter((x) => x.status === 'open'); + const notify = store.get('notifyLevel') || 'severe'; + const asks = store.get('needAsks') || 'daily'; + const quiet = store.get('quietHours') ?? true; + const prec = store.get('precisionDefault') || '100m'; + const watchSummary = c.ctx.watch?.summary?.() || null; uploads.sort((a, b) => b.addedAt - a.addedAt); posts.sort((a, b) => b.at - a.at); const sitOf = (id) => sits.find((x) => String(x.id) === id); @@ -78,6 +152,40 @@ export function createYou( ${key ? `Signing key ${esc(key.keyId)} · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'} ${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${uploads.length} upload${uploads.length === 1 ? '' : 's'} · ${posts.length} post${posts.length === 1 ? '' : 's'} · ${followed.length} followed
+
+
${icon('sparkles')}Signal${esc(tier.name)}
+
${score.toLocaleString()}votes weigh ×${tier.weight.toFixed(1)}
+
${TIERS.map((t) => ``).join('')}
+
${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
+

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. No follower counts, ever.

+
How Signal is calculated
    ${RULES.map((r) => `
  • ${r.delta > 0 ? '+' : ''}${r.delta}${esc(r.text)}
  • `).join('')}

Tiers: ${TIERS.map((t) => `${esc(t.name)} ${t.min}+ (×${t.weight})`).join(' · ')}

+
+ + ${ + firsts.length + ? `
First eyes permanent bylines on the record
` + : '' + } + +
Receipts ${exampleLedger ? 'example until you earn your own' : 'every change, with its reason'}
+
${ledger + .slice(0, 14) + .map( + (r) => + `
${r.delta > 0 ? '+' : ''}${r.delta}${esc(r.text)}${esc(ago(r.at))}${r.source ? ` · ${esc(r.source)}` : ''}${r.sim ? ' · simulated' : ''}${r.example ? ' · Example' : ''}
`, + ) + .join('')}
+ ${ + exampleLedger + ? `
${icon('eyes')}${needs.length ? `${needs.length} Need eyes request${needs.length === 1 ? '' : 's'} near you` : 'Earn your first receipt'}Answer one with a sealed capture: +40 Signal and the First eyes byline.${needs[0] ? `` : ''}
` + : '' + } +
Captures tap to verify
${ captures.length @@ -118,7 +226,7 @@ export function createYou( : '
Nothing posted yet. Every situation has a room: open one and reply with evidence.
' } -
Following
+
Watching ${watchSummary ? esc(watchSummary) : 'places and things you follow'}${c.ctx.watch?.open ? '' : ''}
${ fol.length || contacts.length ? `
${fol @@ -142,6 +250,42 @@ export function createYou(
Home area
+
Notifications calm by default
+
+
${icon('hazards')}Tell me about${esc(NOTIFY.find((x) => x.id === notify).sub)}
+
${NOTIFY.map((v) => ``).join('')}
+
${icon('eyes')}Need eyes asksAt most one quiet ask a day, only when you’re inside a request’s radius
+
${[ + ['daily', 'One a day'], + ['off', 'Off'], + ] + .map( + ([v, l]) => + ``, + ) + .join('')}
+
${icon('clock')}Quiet hours22:00–07:00 · severe and local only
+
${icon('checkmark')}Nothing unusual near youThat’s a valid state. No alarm feed, no upsell.
+
+ +
Location you share default for new captures
+
+
${[ + ['exact', 'Exact'], + ['100m', '100 m'], + ['1km', '1 km'], + ] + .map( + ([v, l]) => + ``, + ) + .join('')}
+
${icon('lock')}The seal still proves the exact position to verifiers without publishing it. You can change it per capture.
+
+ +
The line we don’t cross
+
${icon('eyes')}
We track things that broadcast, never people.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.
+
Settings
${icon('globe')}Globe qualityApplies on next launch
@@ -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 }; }