${richText(p.t, { me: core.rt.me.handle }).html}
${p.sealed ? `/**
* 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 ` ${richText(p.t, { me: core.rt.me.handle }).html}
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('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 }) => `Fictional members (example). ${fmtM(c.members)} members in the seeded crew.
`; } else { sheet.body.innerHTML = `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 = `${esc(c.tagline)}