/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / p2p / network.ts
481 lines · 13.9 KBBlameHistoryRaw
1import {selfId} from './identity'
2import {Nostr, peerTopic, rootTopic} from './nostr'
3import {Peer, type Signal} from './peer'
5// ---------------------------------------------------------------------------
6// CommonCall network layer.
7//
8// Presence: everyone who has entered an ID announces {peerId, name, busy} on
9// the root topic every few seconds; entries expire when announcements stop.
10//
11// Calls: clicking a peer publishes a call-request on that peer's personal
12// topic. The callee must explicitly accept (call-accept) before either side
13// touches getUserMedia or WebRTC — both users must agree. After acceptance the
14// two sides exchange offer/answer/ICE via {t:'signal'} messages on the same
15// per-peer topics, exactly the technique used by commonview, and the media
16// flows peer-to-peer.
17//
18// Messages are authenticated by the nostr layer: every event is schnorr-signed
19// and the sender's pubkey IS the peer ID, so `from` cannot be spoofed.
20// ---------------------------------------------------------------------------
22interface Announcement {
23 peerId: string
24 name: string
25 busy: boolean
28type PeerMsg =
29 | {t: 'call-request'; name: string}
30 | {t: 'call-accept'; name: string}
31 | {t: 'call-decline'; busy?: boolean}
32 | {t: 'call-cancel'}
33 | {t: 'hang-up'}
34 | {t: 'signal'; signal: Signal}
36const ROOM_ID = 'default'
37const ANNOUNCE_INTERVAL_MS = 5000
38const PRESENCE_TTL_MS = 15000
39// Nostr events are ephemeral and relays are flaky, so re-publish the ring
40// while it's pending; the event-id dedup on the far side absorbs repeats.
41const RING_RESEND_MS = 4000
42const RING_TIMEOUT_MS = 45000
43const CONNECT_TIMEOUT_MS = 45000
45const NAME_KEY = 'commoncall:name'
47export type CallPhase = 'outgoing' | 'incoming' | 'connecting' | 'connected'
49interface Call {
50 phase: CallPhase
51 peerId: string
52 peerName: string
53 peer: Peer | null
54 localStream: MediaStream | null
55 remoteStream: MediaStream | null
56 /** Signals that arrived before our getUserMedia resolved. */
57 pendingSignals: Signal[]
58 ringInterval: number | null
59 ringTimeout: number | null
60 connectTimeout: number | null
63export interface RosterEntry {
64 peerId: string
65 name: string
66 busy: boolean
69export interface CallInfo {
70 phase: CallPhase
71 peerId: string
72 peerName: string
73 localStream: MediaStream | null
74 remoteStream: MediaStream | null
77export interface Snapshot {
78 selfId: string
79 name: string | null
80 roster: RosterEntry[]
81 call: CallInfo | null
82 notice: string | null
85export class Network {
86 private nostr = new Nostr()
87 private rootReady: Promise<string>
88 private presence = new Map<
89 string,
90 {name: string; busy: boolean; lastSeen: number}
91 >()
92 private name: string | null = null
93 private call: Call | null = null
94 private notice: string | null = null
96 private snapshot!: Snapshot
97 private listeners = new Set<() => void>()
99 /** Last name used on this browser, for prefilling the join form. */
100 readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
102 constructor() {
103 this.rebuildSnapshot()
104 this.rootReady = rootTopic(ROOM_ID)
105 void this.start()
106 if (this.savedName) this.join(this.savedName)
107 }
109 private async start() {
110 const root = await this.rootReady
112 // Call requests + WebRTC signaling addressed to us.
113 const selfTopic = await peerTopic(root, selfId)
114 this.nostr.subscribe(selfTopic, (content, from) => {
115 if (from === selfId) return
116 let msg: PeerMsg
117 try {
118 msg = JSON.parse(content)
119 } catch {
120 return
121 }
122 this.handlePeerMsg(from, msg)
123 })
125 // Presence announcements.
126 this.nostr.subscribe(root, (content, from) => {
127 if (from === selfId) return
128 let ann: Partial<Announcement>
129 try {
130 ann = JSON.parse(content)
131 } catch {
132 return
133 }
134 if (ann.peerId !== from || typeof ann.name !== 'string') return
135 const prev = this.presence.get(from)
136 const busy = ann.busy === true
137 this.presence.set(from, {name: ann.name, busy, lastSeen: Date.now()})
138 if (!prev || prev.name !== ann.name || prev.busy !== busy) {
139 this.rebuildSnapshot()
140 }
141 })
143 setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)
144 setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)
146 window.addEventListener('online', () => void this.announce())
147 }
149 private async announce() {
150 if (!this.name) return
151 const root = await this.rootReady
152 const ann: Announcement = {
153 peerId: selfId,
154 name: this.name,
155 busy: this.call !== null
156 }
157 void this.nostr.publish(root, JSON.stringify(ann))
158 }
160 private sweepPresence() {
161 const cutoff = Date.now() - PRESENCE_TTL_MS
162 let changed = false
163 for (const [peerId, p] of this.presence) {
164 if (p.lastSeen < cutoff) {
165 this.presence.delete(peerId)
166 changed = true
167 }
168 }
169 if (changed) this.rebuildSnapshot()
170 }
172 private async sendToPeer(peerId: string, msg: PeerMsg) {
173 const root = await this.rootReady
174 const topic = await peerTopic(root, peerId)
175 void this.nostr.publish(topic, JSON.stringify(msg))
176 }
178 // ---- incoming messages ------------------------------------------------
180 private handlePeerMsg(from: string, msg: PeerMsg) {
181 switch (msg.t) {
182 case 'call-request': {
183 if (this.call) {
184 if (this.call.peerId !== from) {
185 // Busy with someone else.
186 void this.sendToPeer(from, {t: 'call-decline', busy: true})
187 } else if (this.call.phase === 'outgoing') {
188 // Glare: we each called the other — that's mutual agreement.
189 this.beginConnecting()
190 } else if (
191 this.call.phase === 'connecting' ||
192 this.call.phase === 'connected'
193 ) {
194 // Their resent ring means our accept was lost; send it again.
195 void this.sendToPeer(from, {
196 t: 'call-accept',
197 name: this.name ?? ''
198 })
199 }
200 // phase 'incoming': duplicate ring, ignore.
201 return
202 }
203 if (!this.name) {
204 // Not joined; we shouldn't be getting calls — turn them away.
205 void this.sendToPeer(from, {t: 'call-decline', busy: true})
206 return
207 }
208 this.notice = null
209 this.call = this.newCall('incoming', from, msg.name)
210 this.rebuildSnapshot()
211 void this.announce()
212 return
213 }
215 case 'call-accept': {
216 if (this.call?.phase === 'outgoing' && this.call.peerId === from) {
217 if (msg.name) this.call.peerName = msg.name
218 this.beginConnecting()
219 }
220 return
221 }
223 case 'call-decline': {
224 if (this.call?.peerId === from && this.call.phase === 'outgoing') {
225 const who = this.call.peerName
226 this.teardown(msg.busy ? `${who} is busy.` : `${who} declined.`)
227 }
228 return
229 }
231 case 'call-cancel': {
232 if (this.call?.peerId === from) {
233 this.teardown(`${this.call.peerName} canceled the call.`)
234 }
235 return
236 }
238 case 'hang-up': {
239 if (this.call?.peerId === from) {
240 this.teardown(`${this.call.peerName} hung up.`)
241 }
242 return
243 }
245 case 'signal': {
246 const call = this.call
247 if (!call || call.peerId !== from) return
248 if (call.phase !== 'connecting' && call.phase !== 'connected') return
249 if (call.peer) void call.peer.signal(msg.signal)
250 else call.pendingSignals.push(msg.signal)
251 return
252 }
253 }
254 }
256 // ---- call lifecycle ---------------------------------------------------
258 private newCall(phase: CallPhase, peerId: string, peerName: string): Call {
259 return {
260 phase,
261 peerId,
262 peerName,
263 peer: null,
264 localStream: null,
265 remoteStream: null,
266 pendingSignals: [],
267 ringInterval: null,
268 ringTimeout: null,
269 connectTimeout: null
270 }
271 }
273 private clearCallTimers(call: Call) {
274 if (call.ringInterval !== null) clearInterval(call.ringInterval)
275 if (call.ringTimeout !== null) clearTimeout(call.ringTimeout)
276 if (call.connectTimeout !== null) clearTimeout(call.connectTimeout)
277 call.ringInterval = null
278 call.ringTimeout = null
279 call.connectTimeout = null
280 }
282 /** Both sides agreed: get the camera/mic and bring up the WebRTC call. */
283 private beginConnecting() {
284 const call = this.call
285 if (!call || call.phase === 'connecting' || call.phase === 'connected') {
286 return
287 }
288 this.clearCallTimers(call)
289 call.phase = 'connecting'
290 call.connectTimeout = window.setTimeout(() => {
291 if (this.call === call && call.phase === 'connecting') {
292 void this.sendToPeer(call.peerId, {t: 'hang-up'})
293 this.teardown('Could not establish a connection.')
294 }
295 }, CONNECT_TIMEOUT_MS)
296 this.rebuildSnapshot()
297 void this.startMedia(call)
298 }
300 private async startMedia(call: Call) {
301 let stream: MediaStream
302 try {
303 stream = await navigator.mediaDevices.getUserMedia({
304 video: true,
305 audio: true
306 })
307 } catch {
308 if (this.call === call) {
309 void this.sendToPeer(call.peerId, {t: 'hang-up'})
310 this.teardown('Could not access your camera/microphone.')
311 }
312 return
313 }
314 if (this.call !== call || call.phase !== 'connecting') {
315 // The call went away while we were waiting for permission.
316 for (const track of stream.getTracks()) track.stop()
317 return
318 }
320 call.localStream = stream
321 // Deterministic initiator (no glare): the smaller peer ID makes the offer.
322 const peer = new Peer(selfId < call.peerId, stream)
323 call.peer = peer
324 peer.setHandlers({
325 signal: signal => {
326 void this.sendToPeer(call.peerId, {t: 'signal', signal})
327 },
328 track: remote => {
329 if (this.call !== call) return
330 call.remoteStream = remote
331 this.rebuildSnapshot()
332 },
333 connect: () => {
334 if (this.call !== call) return
335 call.phase = 'connected'
336 this.clearCallTimers(call)
337 this.rebuildSnapshot()
338 },
339 data: raw => {
340 let msg: {t?: string}
341 try {
342 msg = JSON.parse(raw)
343 } catch {
344 return
345 }
346 if (msg.t === 'hang-up' && this.call === call) {
347 this.teardown(`${call.peerName} hung up.`)
348 }
349 },
350 close: () => {
351 if (this.call === call) this.teardown('Call ended.')
352 }
353 })
354 for (const signal of call.pendingSignals.splice(0)) {
355 void peer.signal(signal)
356 }
357 this.rebuildSnapshot()
358 }
360 private teardown(notice: string | null) {
361 const call = this.call
362 if (!call) return
363 this.call = null // cleared first so the peer's close handler no-ops
364 this.clearCallTimers(call)
365 call.peer?.destroy()
366 if (call.localStream) {
367 for (const track of call.localStream.getTracks()) track.stop()
368 }
369 this.notice = notice
370 this.rebuildSnapshot()
371 void this.announce()
372 }
374 // ---- public API -------------------------------------------------------
376 join(name: string) {
377 const trimmed = name.trim().slice(0, 40)
378 if (!trimmed) return
379 this.name = trimmed
380 localStorage.setItem(NAME_KEY, trimmed)
381 this.notice = null
382 this.rebuildSnapshot()
383 void this.announce()
384 }
386 leave() {
387 if (this.call) this.endCall()
388 this.name = null
389 this.rebuildSnapshot()
390 // Others will drop us from their rosters when announcements stop.
391 }
393 callPeer(peerId: string) {
394 if (!this.name || this.call || peerId === selfId) return
395 const peerName = this.presence.get(peerId)?.name ?? peerId.slice(0, 8)
396 this.notice = null
397 const call = this.newCall('outgoing', peerId, peerName)
398 this.call = call
399 const ring = () => void this.sendToPeer(peerId, {
400 t: 'call-request',
401 name: this.name ?? ''
402 })
403 ring()
404 call.ringInterval = window.setInterval(ring, RING_RESEND_MS)
405 call.ringTimeout = window.setTimeout(() => {
406 if (this.call === call && call.phase === 'outgoing') {
407 void this.sendToPeer(peerId, {t: 'call-cancel'})
408 this.teardown(`${call.peerName} did not answer.`)
409 }
410 }, RING_TIMEOUT_MS)
411 this.rebuildSnapshot()
412 void this.announce()
413 }
415 accept() {
416 const call = this.call
417 if (!call || call.phase !== 'incoming') return
418 void this.sendToPeer(call.peerId, {t: 'call-accept', name: this.name ?? ''})
419 this.beginConnecting()
420 }
422 decline() {
423 const call = this.call
424 if (!call || call.phase !== 'incoming') return
425 void this.sendToPeer(call.peerId, {t: 'call-decline'})
426 this.teardown(null)
427 }
429 endCall() {
430 const call = this.call
431 if (!call) return
432 if (call.phase === 'outgoing') {
433 void this.sendToPeer(call.peerId, {t: 'call-cancel'})
434 } else if (call.phase === 'incoming') {
435 void this.sendToPeer(call.peerId, {t: 'call-decline'})
436 } else {
437 // Belt and braces: the control channel may not be open yet.
438 call.peer?.send(JSON.stringify({t: 'hang-up'}))
439 void this.sendToPeer(call.peerId, {t: 'hang-up'})
440 }
441 this.teardown(null)
442 }
444 dismissNotice() {
445 this.notice = null
446 this.rebuildSnapshot()
447 }
449 getSnapshot = (): Snapshot => this.snapshot
451 subscribe = (listener: () => void): (() => void) => {
452 this.listeners.add(listener)
453 return () => this.listeners.delete(listener)
454 }
456 private rebuildSnapshot() {
457 const roster: RosterEntry[] = [...this.presence.entries()]
458 .map(([peerId, p]) => ({peerId, name: p.name, busy: p.busy}))
459 .sort(
460 (a, b) =>
461 a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
462 )
463 const call: CallInfo | null = this.call
464 ? {
465 phase: this.call.phase,
466 peerId: this.call.peerId,
467 peerName: this.call.peerName,
468 localStream: this.call.localStream,
469 remoteStream: this.call.remoteStream
470 }
471 : null
472 this.snapshot = {
473 selfId,
474 name: this.name,
475 roster,
476 call,
477 notice: this.notice
478 }
479 for (const l of this.listeners) l()
480 }
moveopenescclose