3Tips for future agents working in this repo. It combines the p2p techniques of
4the sibling projects `commonview` (auto-connecting mesh) and `commoncall`
5(WebRTC media, settings, screen share) — read those first; this file only
6covers what is different here.
8## Architecture
10```
11src/p2p/
12 identity.ts schnorr keypair; pubkey hex = peer ID — ported from commoncall
13 nostr.ts minimal relay client + topic scheme — ported (roomTopic takes a room ID)
14 peer.ts WebRTC wrapper: media + control channel — ported (replaceTrack generalized to audio|video)
15 settings.ts shared ROOM settings, quality presets — default quality is 'medium', not 'auto'
16 network.ts the heart: rooms, presence, mesh, media, settings sync
17src/App.tsx landing form (light) + in-room view (dark), video grid, control bar
18```
20## Key design decisions
22- **Rooms, no registry.** The room ID is any string (whitespace stripped,
23 exact otherwise, case-sensitive); `roomTopic` hashes it into the nostr
24 presence topic. The URL hash holds the room (`#<encoded-room>`) so the
25 address bar is the invite link.
26- **Auto-mesh, no consent handshake.** Unlike commoncall, entering the room IS
27 the consent: on every presence announcement, `maybeConnect` brings up a
28 `Peer` (initiator = smaller peer ID, commonview's stalled-connection retry
29 at 15 s). All of commoncall's call-request/accept machinery is gone.
30- **Muted by default; placeholder tracks.** getUserMedia runs at entry but
31 tracks start `enabled = false`. Every participant ALWAYS carries exactly one
32 audio + one video track (denied/missing devices get a silent
33 AudioContext-destination track / black canvas-capture track), so
34 offer/answer stays symmetric and the one-offer, no-renegotiation design
35 holds. Unmuting without a real device retries getUserMedia and upgrades the
36 placeholder via `replaceTrack` on every connection.
37- **Soft cap of 8** (`MAX_PARTICIPANTS`). A peer already holding 7 connections
38 answers an unknown peer's announcement/offer with `{t:'room-full'}` on the
39 newcomer's topic instead of connecting; a newcomer with zero connections
40 that receives room-full tears down and shows a notice. Two simultaneous
41 joiners racing for the last slot can briefly exceed the cap — accepted.
42- **Settings are room-wide, multi-party LWW.** One entry per key in
43 `settingsMeta` (`{rev, by}`); changes broadcast `{t:'set', key, value, rev,
44 by}` to all peers (complete graph — no relaying), late joiners get every
45 entry inside each peer's `hello`, and a same-rev tie is won by the SMALLER
46 setter ID. Default quality is `medium` — so quality caps are applied to each
47 sender on connect (`applyVideoParamsTo`, with one delayed retry because
48 encodings may not exist right at 'connected'), not only on change.
49- **Mute is per-participant, NOT a shared setting** — same as commoncall: own
50 flags, `{t:'mute'}` notices, `track.enabled` toggling, and the notice
51 carries the EFFECTIVE outgoing video state (screen share overrides camera
52 mute). Remote participants are assumed muted until told otherwise.
53- **Screen share = track swap on every connection.** `getDisplayMedia` +
54 `replaceTrack` per peer; a peer that joins mid-share gets the screen track
55 from `outgoingStream()`. Same-kind replacement avoids renegotiation — never
56 addTrack mid-connection.
57- **Cleanup is join-generation-guarded.** `joinSeq` is bumped on every
58 join/leave; async work (getUserMedia, topic hashing, display capture)
59 re-checks it after each await. `leave()` unsubscribes topics, stops all
60 tracks, closes the AudioContext, and resets settings to defaults.
62## Testing
64`npm run dev`, then open the room in two browsers (identity is
65per-browser-profile via localStorage, so two tabs in one profile are the SAME
66peer — use a private window or second browser). `npm run build` type-checks
67(`tsc -b`) and bundles. Let the user test multi-party media in real browsers;
68don't try to automate camera/mic flows.