@weaveprotocol/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -4
- package/dist/calls/calls.d.ts +141 -0
- package/dist/calls/calls.d.ts.map +1 -0
- package/dist/calls/calls.js +763 -0
- package/dist/calls/calls.js.map +1 -0
- package/dist/calls/index.d.ts +7 -0
- package/dist/calls/index.d.ts.map +1 -0
- package/dist/calls/index.js +6 -0
- package/dist/calls/index.js.map +1 -0
- package/dist/network/mesh.d.ts +6 -0
- package/dist/network/mesh.d.ts.map +1 -1
- package/dist/network/mesh.js +33 -2
- package/dist/network/mesh.js.map +1 -1
- package/dist/network/multi-signaling.d.ts.map +1 -1
- package/dist/network/multi-signaling.js +13 -0
- package/dist/network/multi-signaling.js.map +1 -1
- package/dist/network/rtc-transport.d.ts +3 -1
- package/dist/network/rtc-transport.d.ts.map +1 -1
- package/dist/network/rtc-transport.js +4 -3
- package/dist/network/rtc-transport.js.map +1 -1
- package/dist/network/signaling.d.ts +8 -1
- package/dist/network/signaling.d.ts.map +1 -1
- package/dist/network/signaling.js +20 -0
- package/dist/network/signaling.js.map +1 -1
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +38 -4
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts +2 -0
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +83 -2
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +49 -4
- package/dist/node/types.d.ts.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/use-calls.d.ts +21 -0
- package/dist/react/use-calls.d.ts.map +1 -0
- package/dist/react/use-calls.js +38 -0
- package/dist/react/use-calls.js.map +1 -0
- package/dist/react/use-space.d.ts +2 -2
- package/dist/react/use-space.d.ts.map +1 -1
- package/dist/react/use-space.js +9 -4
- package/dist/react/use-space.js.map +1 -1
- package/dist/schemas/index.d.ts +52 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +23 -1
- package/dist/schemas/index.js.map +1 -1
- package/package.json +11 -2
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { call as callSchema } from '../schemas/index.js';
|
|
2
|
+
const CALL_TYPES = new Set(['call.here', 'call.ring', 'call.answered', 'call.declined', 'call.cancel', 'call.signal', 'call.leave']);
|
|
3
|
+
/** Rings one account may make you hear in a minute */
|
|
4
|
+
const RINGS_PER_MINUTE = 3;
|
|
5
|
+
const REJOIN_KEY = 'weave-call';
|
|
6
|
+
/** How long "declined" or "missed" shows before a call with nobody else in it ends */
|
|
7
|
+
const GIVE_UP_MS = 2500;
|
|
8
|
+
/** How long a space stays open after leaving its call, for the goodbye to get out */
|
|
9
|
+
const LEAVE_LINGER_MS = 1000;
|
|
10
|
+
/** How long a member list is trusted before asking the space again */
|
|
11
|
+
const MEMBERS_MS = 10_000;
|
|
12
|
+
function isCallMessage(value) {
|
|
13
|
+
const message = value;
|
|
14
|
+
return (typeof message?.type === 'string' &&
|
|
15
|
+
CALL_TYPES.has(message.type) &&
|
|
16
|
+
typeof message.call === 'string' &&
|
|
17
|
+
message.call.length > 0 &&
|
|
18
|
+
message.call.length <= 64);
|
|
19
|
+
}
|
|
20
|
+
const randomId = () => {
|
|
21
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
22
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
};
|
|
24
|
+
export function createCalls(node, options = {}) {
|
|
25
|
+
const heartbeatMs = options.heartbeatMs ?? 5000;
|
|
26
|
+
const goneMs = options.goneMs ?? 15_000;
|
|
27
|
+
const ringMs = options.ringMs ?? 45_000;
|
|
28
|
+
const createConnection = options.createConnection ?? ((config) => new RTCPeerConnection(config));
|
|
29
|
+
const getUserMedia = options.getUserMedia ?? ((constraints) => navigator.mediaDevices.getUserMedia(constraints));
|
|
30
|
+
const getDisplayMedia = options.getDisplayMedia ?? ((constraints) => navigator.mediaDevices.getDisplayMedia(constraints));
|
|
31
|
+
const createStream = options.createStream ??
|
|
32
|
+
((tracks) => (typeof MediaStream === 'function' ? new MediaStream([...tracks]) : null));
|
|
33
|
+
const storage = options.storage === undefined ? safeSessionStorage() : options.storage;
|
|
34
|
+
const listeners = new Set();
|
|
35
|
+
/** Who is in which call, by space, call and device */
|
|
36
|
+
const around = new Map();
|
|
37
|
+
const ringing = new Map();
|
|
38
|
+
const ringsFrom = new Map();
|
|
39
|
+
const members = new Map();
|
|
40
|
+
let active = null;
|
|
41
|
+
/** A start under way, so a second one waits for it rather than racing it */
|
|
42
|
+
let starting = null;
|
|
43
|
+
let state = null;
|
|
44
|
+
let closed = false;
|
|
45
|
+
let remembered = readRejoin();
|
|
46
|
+
/** The space of the call from before a reload, held while it might still be going on — to hear it, and offer rejoining */
|
|
47
|
+
let rejoinHold = null;
|
|
48
|
+
if (remembered) {
|
|
49
|
+
rejoinHold = node.spaces.hold(remembered.space).catch(() => null);
|
|
50
|
+
const check = () => {
|
|
51
|
+
if (!rejoinHold || !remembered)
|
|
52
|
+
return;
|
|
53
|
+
if (inCall(remembered.space, remembered.call)?.size)
|
|
54
|
+
later(check, goneMs);
|
|
55
|
+
else
|
|
56
|
+
forgetRejoin();
|
|
57
|
+
};
|
|
58
|
+
later(check, goneMs * 2);
|
|
59
|
+
}
|
|
60
|
+
const changed = () => {
|
|
61
|
+
state = null;
|
|
62
|
+
for (const listener of [...listeners]) {
|
|
63
|
+
try {
|
|
64
|
+
listener();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A listener's problem is its own.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
function later(fn, ms) {
|
|
72
|
+
const timer = setTimeout(fn, ms);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
return timer;
|
|
75
|
+
}
|
|
76
|
+
/** Stops offering to rejoin the call from before the reload */
|
|
77
|
+
function forgetRejoin() {
|
|
78
|
+
const was = remembered;
|
|
79
|
+
remembered = null;
|
|
80
|
+
void rejoinHold?.then((release) => release?.());
|
|
81
|
+
rejoinHold = null;
|
|
82
|
+
if (was)
|
|
83
|
+
changed();
|
|
84
|
+
}
|
|
85
|
+
const send = (space, message, to) => node.spaces.send(space, message, to).catch(() => { });
|
|
86
|
+
// ─── Who may take part ─────────────────────────────────────────────
|
|
87
|
+
/** Whether an account holds a role in the space — a view-only reader holds none */
|
|
88
|
+
async function isMember(space, account) {
|
|
89
|
+
if (!account)
|
|
90
|
+
return false;
|
|
91
|
+
let known = members.get(space);
|
|
92
|
+
if (!known || Date.now() - known.at > MEMBERS_MS) {
|
|
93
|
+
known = { at: Date.now(), dids: node.spaces.access(space).then((access) => new Set(access.members.map((m) => m.did))) };
|
|
94
|
+
members.set(space, known);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const dids = await known.dids;
|
|
98
|
+
if (dids.has(account))
|
|
99
|
+
return true;
|
|
100
|
+
// Someone who joined a moment ago: ask again, once.
|
|
101
|
+
if (Date.now() - known.at < 1000)
|
|
102
|
+
return false;
|
|
103
|
+
members.delete(space);
|
|
104
|
+
return (await node.spaces.access(space)).members.some((m) => m.did === account);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function withinRingAllowance(account) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const recent = (ringsFrom.get(account) ?? []).filter((at) => now - at < 60_000);
|
|
113
|
+
if (recent.length >= RINGS_PER_MINUTE)
|
|
114
|
+
return false;
|
|
115
|
+
recent.push(now);
|
|
116
|
+
ringsFrom.set(account, recent);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
// ─── Calls going on ────────────────────────────────────────────────
|
|
120
|
+
const inCall = (space, call) => around.get(space)?.get(call);
|
|
121
|
+
function notePresence(space, call, peer, account, message) {
|
|
122
|
+
const calls = around.get(space) ?? around.set(space, new Map()).get(space);
|
|
123
|
+
// A device is in one call at a time.
|
|
124
|
+
for (const [id, peers] of calls)
|
|
125
|
+
if (id !== call && peers.delete(peer) && peers.size === 0)
|
|
126
|
+
calls.delete(id);
|
|
127
|
+
const peers = calls.get(call) ?? calls.set(call, new Map()).get(call);
|
|
128
|
+
const known = peers.get(peer);
|
|
129
|
+
const since = typeof message.since === 'number' && Number.isFinite(message.since) ? Math.min(message.since, Date.now()) : Date.now();
|
|
130
|
+
peers.set(peer, {
|
|
131
|
+
account,
|
|
132
|
+
since: known?.since ?? since,
|
|
133
|
+
lastSeen: Date.now(),
|
|
134
|
+
camera: message.camera === true,
|
|
135
|
+
muted: message.muted === true,
|
|
136
|
+
});
|
|
137
|
+
return !known;
|
|
138
|
+
}
|
|
139
|
+
function forget(space, call, peer) {
|
|
140
|
+
const calls = around.get(space);
|
|
141
|
+
const peers = calls?.get(call);
|
|
142
|
+
if (!peers?.delete(peer))
|
|
143
|
+
return;
|
|
144
|
+
if (peers.size === 0)
|
|
145
|
+
calls.delete(call);
|
|
146
|
+
if (calls.size === 0)
|
|
147
|
+
around.delete(space);
|
|
148
|
+
}
|
|
149
|
+
/** Drops whoever has gone quiet, and the connections to them */
|
|
150
|
+
function sweep() {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
let dropped = false;
|
|
153
|
+
for (const [space, calls] of around) {
|
|
154
|
+
for (const [call, peers] of calls) {
|
|
155
|
+
for (const [peer, presence] of peers) {
|
|
156
|
+
if (now - presence.lastSeen <= goneMs)
|
|
157
|
+
continue;
|
|
158
|
+
forget(space, call, peer);
|
|
159
|
+
if (active?.space === space && active.id === call)
|
|
160
|
+
closeLink(peer);
|
|
161
|
+
dropped = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (dropped)
|
|
166
|
+
changed();
|
|
167
|
+
}
|
|
168
|
+
const sweeper = setInterval(sweep, Math.max(250, Math.min(heartbeatMs, goneMs / 3)));
|
|
169
|
+
sweeper.unref?.();
|
|
170
|
+
// ─── Connections ───────────────────────────────────────────────────
|
|
171
|
+
const videoTrack = (call) => call.screen ?? call.camera;
|
|
172
|
+
function localStream(call) {
|
|
173
|
+
const tracks = [call.audio, videoTrack(call)].filter((t) => t !== null);
|
|
174
|
+
return tracks.length > 0 ? createStream(tracks) : null;
|
|
175
|
+
}
|
|
176
|
+
function hereMessage(call) {
|
|
177
|
+
return { type: 'call.here', call: call.id, since: call.joinedAt, camera: videoTrack(call) !== null, muted: call.muted };
|
|
178
|
+
}
|
|
179
|
+
function linkFor(call, peer, account, offerer) {
|
|
180
|
+
closeLink(peer);
|
|
181
|
+
const pc = createConnection({ iceServers: [...call.ice] });
|
|
182
|
+
const link = { peer, account, pc, offerer, tracks: [], stream: null, state: 'connecting', pending: [], described: false };
|
|
183
|
+
call.links.set(peer, link);
|
|
184
|
+
call.seen.add(account);
|
|
185
|
+
pc.onicecandidate = (event) => {
|
|
186
|
+
if (event.candidate && active === call)
|
|
187
|
+
void send(call.space, { type: 'call.signal', call: call.id, candidate: event.candidate.toJSON() }, peer);
|
|
188
|
+
};
|
|
189
|
+
pc.ontrack = (event) => {
|
|
190
|
+
if (call.links.get(peer) !== link)
|
|
191
|
+
return;
|
|
192
|
+
if (!link.tracks.includes(event.track))
|
|
193
|
+
link.tracks = [...link.tracks, event.track];
|
|
194
|
+
link.stream = createStream(link.tracks);
|
|
195
|
+
changed();
|
|
196
|
+
};
|
|
197
|
+
pc.onconnectionstatechange = () => {
|
|
198
|
+
if (call.links.get(peer) !== link)
|
|
199
|
+
return;
|
|
200
|
+
const now = pc.connectionState;
|
|
201
|
+
const next = now === 'connected' ? 'connected' : now === 'failed' || now === 'closed' ? 'failed' : link.state;
|
|
202
|
+
if (next === link.state)
|
|
203
|
+
return;
|
|
204
|
+
link.state = next;
|
|
205
|
+
changed();
|
|
206
|
+
// The side that offers tries again, while the other is still in the call.
|
|
207
|
+
if (next === 'failed' && link.offerer) {
|
|
208
|
+
call.timers.add(later(() => {
|
|
209
|
+
if (active === call && call.links.get(peer) === link && inCall(call.space, call.id)?.has(peer))
|
|
210
|
+
void offer(call, peer, account);
|
|
211
|
+
}, 2000));
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
return link;
|
|
215
|
+
}
|
|
216
|
+
function closeLink(peer) {
|
|
217
|
+
const link = active?.links.get(peer);
|
|
218
|
+
if (!link)
|
|
219
|
+
return;
|
|
220
|
+
active.links.delete(peer);
|
|
221
|
+
try {
|
|
222
|
+
link.pc.close();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Already closed.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function offer(call, peer, account) {
|
|
229
|
+
const link = linkFor(call, peer, account, true);
|
|
230
|
+
try {
|
|
231
|
+
link.pc.addTransceiver(call.audio ?? 'audio', { direction: 'sendrecv' });
|
|
232
|
+
link.pc.addTransceiver(videoTrack(call) ?? 'video', { direction: 'sendrecv' });
|
|
233
|
+
const description = await link.pc.createOffer();
|
|
234
|
+
await link.pc.setLocalDescription(description);
|
|
235
|
+
if (active !== call || call.links.get(peer) !== link)
|
|
236
|
+
return;
|
|
237
|
+
await send(call.space, { type: 'call.signal', call: call.id, description: { type: description.type, sdp: description.sdp ?? '' } }, peer);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
link.state = 'failed';
|
|
241
|
+
changed();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function onSignal(call, peer, account, message) {
|
|
245
|
+
const { description, candidate } = message;
|
|
246
|
+
if (description && typeof description === 'object') {
|
|
247
|
+
if (description.type === 'offer') {
|
|
248
|
+
// Only the lower DID offers; an offer the other way round is someone not following that.
|
|
249
|
+
if (!(peer < node.sessionDid))
|
|
250
|
+
return;
|
|
251
|
+
const link = linkFor(call, peer, account, false);
|
|
252
|
+
try {
|
|
253
|
+
await link.pc.setRemoteDescription({ type: 'offer', sdp: String(description.sdp ?? '') });
|
|
254
|
+
link.described = true;
|
|
255
|
+
for (const transceiver of link.pc.getTransceivers()) {
|
|
256
|
+
const kind = transceiver.receiver.track?.kind;
|
|
257
|
+
if (kind !== 'audio' && kind !== 'video')
|
|
258
|
+
continue;
|
|
259
|
+
transceiver.direction = 'sendrecv';
|
|
260
|
+
await transceiver.sender.replaceTrack(kind === 'audio' ? call.audio : videoTrack(call));
|
|
261
|
+
}
|
|
262
|
+
const answer = await link.pc.createAnswer();
|
|
263
|
+
await link.pc.setLocalDescription(answer);
|
|
264
|
+
if (active !== call || call.links.get(peer) !== link)
|
|
265
|
+
return;
|
|
266
|
+
await send(call.space, { type: 'call.signal', call: call.id, description: { type: answer.type, sdp: answer.sdp ?? '' } }, peer);
|
|
267
|
+
await flush(link);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
link.state = 'failed';
|
|
271
|
+
changed();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (description.type === 'answer') {
|
|
275
|
+
const link = call.links.get(peer);
|
|
276
|
+
if (!link?.offerer || link.described)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
await link.pc.setRemoteDescription({ type: 'answer', sdp: String(description.sdp ?? '') });
|
|
280
|
+
link.described = true;
|
|
281
|
+
await flush(link);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
link.state = 'failed';
|
|
285
|
+
changed();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (candidate && typeof candidate === 'object') {
|
|
290
|
+
const link = call.links.get(peer);
|
|
291
|
+
if (!link)
|
|
292
|
+
return;
|
|
293
|
+
if (!link.described) {
|
|
294
|
+
if (link.pending.length < 64)
|
|
295
|
+
link.pending.push(candidate);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
await link.pc.addIceCandidate(candidate).catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function flush(link) {
|
|
302
|
+
const pending = link.pending.splice(0);
|
|
303
|
+
for (const candidate of pending)
|
|
304
|
+
await link.pc.addIceCandidate(candidate).catch(() => { });
|
|
305
|
+
}
|
|
306
|
+
/** Puts a new video track (camera, screen or none) on every connection */
|
|
307
|
+
async function sendVideo(call) {
|
|
308
|
+
const track = videoTrack(call);
|
|
309
|
+
await Promise.all([...call.links.values()].flatMap((link) => link.pc
|
|
310
|
+
.getTransceivers()
|
|
311
|
+
.filter((t) => (t.sender.track?.kind ?? t.receiver.track?.kind) === 'video')
|
|
312
|
+
.map((t) => t.sender.replaceTrack(track).catch(() => { }))));
|
|
313
|
+
call.local = localStream(call);
|
|
314
|
+
void send(call.space, hereMessage(call));
|
|
315
|
+
changed();
|
|
316
|
+
}
|
|
317
|
+
// ─── Messages ──────────────────────────────────────────────────────
|
|
318
|
+
async function onMessage(event) {
|
|
319
|
+
const message = event.message;
|
|
320
|
+
if (!isCallMessage(message) || !event.from)
|
|
321
|
+
return;
|
|
322
|
+
const { space, peer, from } = event;
|
|
323
|
+
// An agent acting for someone can't take part in their calls.
|
|
324
|
+
if (event.agent)
|
|
325
|
+
return;
|
|
326
|
+
switch (message.type) {
|
|
327
|
+
case 'call.here': {
|
|
328
|
+
if (!(await isMember(space, from)))
|
|
329
|
+
return;
|
|
330
|
+
const isNew = notePresence(space, message.call, peer, from, message);
|
|
331
|
+
const call = active;
|
|
332
|
+
if (call && call.space === space) {
|
|
333
|
+
if (call.id === message.call) {
|
|
334
|
+
call.seen.add(from);
|
|
335
|
+
call.startedAt = Math.min(call.startedAt, inCall(space, call.id)?.get(peer)?.since ?? call.startedAt);
|
|
336
|
+
// Someone just arrived: tell them we're here now, not at our next heartbeat.
|
|
337
|
+
if (isNew)
|
|
338
|
+
void send(space, hereMessage(call), peer);
|
|
339
|
+
if (!call.links.has(peer) && node.sessionDid < peer)
|
|
340
|
+
void offer(call, peer, from);
|
|
341
|
+
}
|
|
342
|
+
else if (message.call < call.id && call.links.size === 0) {
|
|
343
|
+
// Two calls started at once in one space: the lower id wins, and everyone moves into it.
|
|
344
|
+
call.id = message.call;
|
|
345
|
+
writeRejoin(call);
|
|
346
|
+
void send(space, hereMessage(call));
|
|
347
|
+
if (node.sessionDid < peer)
|
|
348
|
+
void offer(call, peer, from);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
changed();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
case 'call.leave': {
|
|
355
|
+
forget(space, message.call, peer);
|
|
356
|
+
if (active?.space === space && active.id === message.call)
|
|
357
|
+
closeLink(peer);
|
|
358
|
+
for (const [id, ring] of ringing)
|
|
359
|
+
if (id === message.call && ring.peer === peer)
|
|
360
|
+
stopRinging(id);
|
|
361
|
+
changed();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
case 'call.ring': {
|
|
365
|
+
if (active?.id === message.call || ringing.has(message.call))
|
|
366
|
+
return;
|
|
367
|
+
if (!withinRingAllowance(from) || !(await isMember(space, from)))
|
|
368
|
+
return;
|
|
369
|
+
const timer = later(() => stopRinging(message.call), ringMs);
|
|
370
|
+
ringing.set(message.call, { id: message.call, space, from, peer, since: Date.now(), timer });
|
|
371
|
+
changed();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
case 'call.cancel': {
|
|
375
|
+
if (ringing.get(message.call)?.from === from)
|
|
376
|
+
stopRinging(message.call);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case 'call.answered':
|
|
380
|
+
case 'call.declined': {
|
|
381
|
+
// Your own other device answered or declined: stop ringing here too.
|
|
382
|
+
if (from === node.did) {
|
|
383
|
+
stopRinging(message.call);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const call = active;
|
|
387
|
+
if (!call || call.id !== message.call || call.outgoing?.to !== from || call.outgoing.state !== 'ringing')
|
|
388
|
+
return;
|
|
389
|
+
if (message.type === 'call.answered') {
|
|
390
|
+
call.outgoing = null;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
call.outgoing = { ...call.outgoing, state: 'declined' };
|
|
394
|
+
giveUpIfAlone(call);
|
|
395
|
+
}
|
|
396
|
+
changed();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
case 'call.signal': {
|
|
400
|
+
const call = active;
|
|
401
|
+
if (!call || call.space !== space || call.id !== message.call)
|
|
402
|
+
return;
|
|
403
|
+
if (!(await isMember(space, from)))
|
|
404
|
+
return;
|
|
405
|
+
await onSignal(call, peer, from, message);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const unsubscribe = node.subscribe((event) => {
|
|
411
|
+
if (event.type === 'message')
|
|
412
|
+
void onMessage(event);
|
|
413
|
+
});
|
|
414
|
+
function stopRinging(id) {
|
|
415
|
+
const ring = ringing.get(id);
|
|
416
|
+
if (!ring)
|
|
417
|
+
return;
|
|
418
|
+
clearTimeout(ring.timer);
|
|
419
|
+
ringing.delete(id);
|
|
420
|
+
changed();
|
|
421
|
+
}
|
|
422
|
+
/** Ends a call nobody else joined, a moment after the ringing came to nothing */
|
|
423
|
+
function giveUpIfAlone(call) {
|
|
424
|
+
call.timers.add(later(() => {
|
|
425
|
+
if (active === call && call.links.size === 0 && (inCall(call.space, call.id)?.size ?? 0) === 0)
|
|
426
|
+
void leave();
|
|
427
|
+
}, GIVE_UP_MS));
|
|
428
|
+
}
|
|
429
|
+
// ─── History ───────────────────────────────────────────────────────
|
|
430
|
+
async function remember(space, body) {
|
|
431
|
+
try {
|
|
432
|
+
const defined = (await node.collections.list(space)).some((c) => c.name === callSchema.name && c.version !== null);
|
|
433
|
+
if (defined)
|
|
434
|
+
await node.records.put(space, callSchema.name, body);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// History is a nicety; a space that won't take it still had the call.
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// ─── Rejoining after a reload ──────────────────────────────────────
|
|
441
|
+
function readRejoin() {
|
|
442
|
+
try {
|
|
443
|
+
const saved = JSON.parse(storage?.getItem(REJOIN_KEY) ?? 'null');
|
|
444
|
+
return typeof saved?.space === 'string' && typeof saved.call === 'string' ? { space: saved.space, call: saved.call } : null;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function writeRejoin(call) {
|
|
451
|
+
try {
|
|
452
|
+
if (call)
|
|
453
|
+
storage?.setItem(REJOIN_KEY, JSON.stringify({ space: call.space, call: call.id }));
|
|
454
|
+
else
|
|
455
|
+
storage?.removeItem(REJOIN_KEY);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// Private mode, or storage turned off: no rejoining, nothing else lost.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// ─── Joining and leaving ───────────────────────────────────────────
|
|
462
|
+
async function media(video) {
|
|
463
|
+
try {
|
|
464
|
+
return { stream: await getUserMedia({ audio: true, video }), problem: null };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
if (video) {
|
|
468
|
+
try {
|
|
469
|
+
return { stream: await getUserMedia({ audio: true, video: false }), problem: 'The camera isn’t available, so you joined with sound only.' };
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Neither: fall through.
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { stream: null, problem: 'The microphone isn’t available, so others can’t hear you.' };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function start(space, opts = {}) {
|
|
479
|
+
if (closed)
|
|
480
|
+
throw new Error('Calls have been closed');
|
|
481
|
+
while (starting)
|
|
482
|
+
await starting.catch(() => { });
|
|
483
|
+
if (active && active.space === space && (!opts.call || opts.call === active.id))
|
|
484
|
+
return;
|
|
485
|
+
starting = (async () => {
|
|
486
|
+
if (active)
|
|
487
|
+
await leave();
|
|
488
|
+
const release = await node.spaces.hold(space);
|
|
489
|
+
try {
|
|
490
|
+
const { stream, problem } = await media(opts.video === true);
|
|
491
|
+
const going = [...(around.get(space) ?? new Map())].sort(([a], [b]) => (a < b ? -1 : 1))[0]?.[0];
|
|
492
|
+
const call = {
|
|
493
|
+
id: opts.call ?? going ?? randomId(),
|
|
494
|
+
space,
|
|
495
|
+
joinedAt: Date.now(),
|
|
496
|
+
release,
|
|
497
|
+
startedAt: Date.now(),
|
|
498
|
+
audio: stream?.getAudioTracks()[0] ?? null,
|
|
499
|
+
camera: stream?.getVideoTracks()[0] ?? null,
|
|
500
|
+
screen: null,
|
|
501
|
+
muted: false,
|
|
502
|
+
local: null,
|
|
503
|
+
links: new Map(),
|
|
504
|
+
seen: new Set([node.did]),
|
|
505
|
+
outgoing: null,
|
|
506
|
+
problem,
|
|
507
|
+
ice: await node.iceServers().catch(() => []),
|
|
508
|
+
timers: new Set(),
|
|
509
|
+
heartbeat: null,
|
|
510
|
+
};
|
|
511
|
+
call.local = localStream(call);
|
|
512
|
+
if (closed) {
|
|
513
|
+
for (const track of stream?.getTracks() ?? [])
|
|
514
|
+
track.stop();
|
|
515
|
+
throw new Error('Calls have been closed');
|
|
516
|
+
}
|
|
517
|
+
active = call;
|
|
518
|
+
forgetRejoin();
|
|
519
|
+
writeRejoin(call);
|
|
520
|
+
call.heartbeat = setInterval(() => void send(space, hereMessage(call)), heartbeatMs);
|
|
521
|
+
call.heartbeat.unref?.();
|
|
522
|
+
await send(space, hereMessage(call));
|
|
523
|
+
for (const [peer, presence] of inCall(space, call.id) ?? []) {
|
|
524
|
+
call.seen.add(presence.account);
|
|
525
|
+
call.startedAt = Math.min(call.startedAt, presence.since);
|
|
526
|
+
if (node.sessionDid < peer)
|
|
527
|
+
void offer(call, peer, presence.account);
|
|
528
|
+
}
|
|
529
|
+
changed();
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
await release().catch(() => { });
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
})();
|
|
536
|
+
try {
|
|
537
|
+
await starting;
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
starting = null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function leave() {
|
|
544
|
+
const call = active;
|
|
545
|
+
if (!call)
|
|
546
|
+
return;
|
|
547
|
+
active = null;
|
|
548
|
+
writeRejoin(null);
|
|
549
|
+
if (call.heartbeat)
|
|
550
|
+
clearInterval(call.heartbeat);
|
|
551
|
+
for (const timer of call.timers)
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
for (const link of call.links.values()) {
|
|
554
|
+
try {
|
|
555
|
+
link.pc.close();
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Already closed.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (const track of [call.audio, call.camera, call.screen])
|
|
562
|
+
track?.stop();
|
|
563
|
+
changed();
|
|
564
|
+
await send(call.space, { type: 'call.leave', call: call.id });
|
|
565
|
+
if (call.outgoing?.state === 'ringing') {
|
|
566
|
+
await send(call.space, { type: 'call.cancel', call: call.id }, call.outgoing.to);
|
|
567
|
+
await remember(call.space, { status: 'missed', to: call.outgoing.to, startedAt: new Date(call.outgoing.since).toISOString() });
|
|
568
|
+
}
|
|
569
|
+
// The last one out writes down who was there — if anyone else ever was.
|
|
570
|
+
const stillIn = inCall(call.space, call.id)?.size ?? 0;
|
|
571
|
+
if (stillIn === 0 && call.seen.size > 1) {
|
|
572
|
+
await remember(call.space, {
|
|
573
|
+
status: 'ended',
|
|
574
|
+
startedAt: new Date(call.startedAt).toISOString(),
|
|
575
|
+
endedAt: new Date().toISOString(),
|
|
576
|
+
people: [...call.seen].slice(0, 64),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// A moment late, so the goodbye leaves before the connection it travels on closes.
|
|
580
|
+
later(() => void call.release().catch(() => { }), LEAVE_LINGER_MS);
|
|
581
|
+
}
|
|
582
|
+
function snapshot() {
|
|
583
|
+
const call = active;
|
|
584
|
+
const current = call && {
|
|
585
|
+
id: call.id,
|
|
586
|
+
space: call.space,
|
|
587
|
+
joinedAt: call.joinedAt,
|
|
588
|
+
local: call.local,
|
|
589
|
+
muted: call.muted,
|
|
590
|
+
camera: videoTrack(call) !== null,
|
|
591
|
+
sharing: call.screen !== null,
|
|
592
|
+
outgoing: call.outgoing,
|
|
593
|
+
problem: call.problem,
|
|
594
|
+
people: [...(inCall(call.space, call.id) ?? new Map())]
|
|
595
|
+
.map(([peer, presence]) => {
|
|
596
|
+
const link = call.links.get(peer);
|
|
597
|
+
return {
|
|
598
|
+
peer,
|
|
599
|
+
account: presence.account,
|
|
600
|
+
stream: link?.stream ?? null,
|
|
601
|
+
muted: presence.muted,
|
|
602
|
+
camera: presence.camera,
|
|
603
|
+
connection: link?.state ?? 'connecting',
|
|
604
|
+
};
|
|
605
|
+
})
|
|
606
|
+
.sort((a, b) => (a.peer < b.peer ? -1 : 1)),
|
|
607
|
+
};
|
|
608
|
+
const all = [];
|
|
609
|
+
for (const [space, calls] of around) {
|
|
610
|
+
for (const [id, peers] of calls) {
|
|
611
|
+
if (peers.size === 0)
|
|
612
|
+
continue;
|
|
613
|
+
const present = [...peers.values()];
|
|
614
|
+
all.push({ id, space, people: [...new Set(present.map((p) => p.account))], since: Math.min(...present.map((p) => p.since)) });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const was = remembered;
|
|
618
|
+
return {
|
|
619
|
+
current,
|
|
620
|
+
ringing: [...ringing.values()].map(({ id, space, from, since }) => ({ id, space, from, since })),
|
|
621
|
+
around: all.filter((c) => !(call && c.space === call.space && c.id === call.id)),
|
|
622
|
+
rejoin: !call && was ? (all.find((c) => c.space === was.space && c.id === was.call) ?? null) : null,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return Object.freeze({
|
|
626
|
+
getState: () => (state ??= snapshot()),
|
|
627
|
+
subscribe(listener) {
|
|
628
|
+
listeners.add(listener);
|
|
629
|
+
return () => void listeners.delete(listener);
|
|
630
|
+
},
|
|
631
|
+
start,
|
|
632
|
+
async ring(space, account, opts = {}) {
|
|
633
|
+
if (account === node.did)
|
|
634
|
+
throw new Error('That’s you.');
|
|
635
|
+
await start(space, opts);
|
|
636
|
+
const call = active;
|
|
637
|
+
if (!call)
|
|
638
|
+
return;
|
|
639
|
+
call.outgoing = { to: account, since: Date.now(), state: 'ringing' };
|
|
640
|
+
await send(space, { type: 'call.ring', call: call.id }, account);
|
|
641
|
+
call.timers.add(later(() => {
|
|
642
|
+
if (active !== call || call.outgoing?.state !== 'ringing')
|
|
643
|
+
return;
|
|
644
|
+
const { to, since } = call.outgoing;
|
|
645
|
+
call.outgoing = { to, since, state: 'missed' };
|
|
646
|
+
void send(space, { type: 'call.cancel', call: call.id }, to);
|
|
647
|
+
void remember(space, { status: 'missed', to, startedAt: new Date(since).toISOString() });
|
|
648
|
+
giveUpIfAlone(call);
|
|
649
|
+
changed();
|
|
650
|
+
}, ringMs));
|
|
651
|
+
changed();
|
|
652
|
+
},
|
|
653
|
+
async answer(id, opts = {}) {
|
|
654
|
+
const ring = ringing.get(id);
|
|
655
|
+
if (!ring)
|
|
656
|
+
throw new Error('That call isn’t ringing any more.');
|
|
657
|
+
stopRinging(id);
|
|
658
|
+
await start(ring.space, { ...opts, call: id });
|
|
659
|
+
await send(ring.space, { type: 'call.answered', call: id }, ring.from);
|
|
660
|
+
await send(ring.space, { type: 'call.answered', call: id }, node.did);
|
|
661
|
+
},
|
|
662
|
+
async decline(id) {
|
|
663
|
+
const ring = ringing.get(id);
|
|
664
|
+
if (!ring)
|
|
665
|
+
return;
|
|
666
|
+
stopRinging(id);
|
|
667
|
+
await send(ring.space, { type: 'call.declined', call: id }, ring.from);
|
|
668
|
+
await send(ring.space, { type: 'call.declined', call: id }, node.did);
|
|
669
|
+
},
|
|
670
|
+
leave,
|
|
671
|
+
forgetRejoin,
|
|
672
|
+
setMuted(muted) {
|
|
673
|
+
const call = active;
|
|
674
|
+
if (!call)
|
|
675
|
+
return;
|
|
676
|
+
call.muted = muted;
|
|
677
|
+
if (call.audio)
|
|
678
|
+
call.audio.enabled = !muted;
|
|
679
|
+
void send(call.space, hereMessage(call));
|
|
680
|
+
changed();
|
|
681
|
+
},
|
|
682
|
+
async setCamera(on) {
|
|
683
|
+
const call = active;
|
|
684
|
+
if (!call || on === (call.camera !== null))
|
|
685
|
+
return;
|
|
686
|
+
if (on) {
|
|
687
|
+
let track;
|
|
688
|
+
try {
|
|
689
|
+
track = (await getUserMedia({ video: true })).getVideoTracks()[0];
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
track = undefined;
|
|
693
|
+
}
|
|
694
|
+
if (active !== call) {
|
|
695
|
+
track?.stop();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (!track) {
|
|
699
|
+
call.problem = 'The camera isn’t available.';
|
|
700
|
+
changed();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
call.camera = track;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
call.camera?.stop();
|
|
707
|
+
call.camera = null;
|
|
708
|
+
}
|
|
709
|
+
await sendVideo(call);
|
|
710
|
+
},
|
|
711
|
+
async shareScreen() {
|
|
712
|
+
const call = active;
|
|
713
|
+
if (!call || call.screen)
|
|
714
|
+
return;
|
|
715
|
+
const track = (await getDisplayMedia({ video: true, audio: false })).getVideoTracks()[0];
|
|
716
|
+
if (!track)
|
|
717
|
+
return;
|
|
718
|
+
if (active !== call) {
|
|
719
|
+
track.stop();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
call.screen = track;
|
|
723
|
+
// The browser's own "Stop sharing" button ends the track.
|
|
724
|
+
track.addEventListener?.('ended', () => {
|
|
725
|
+
if (active === call && call.screen === track) {
|
|
726
|
+
call.screen = null;
|
|
727
|
+
void sendVideo(call);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
await sendVideo(call);
|
|
731
|
+
},
|
|
732
|
+
async stopSharing() {
|
|
733
|
+
const call = active;
|
|
734
|
+
if (!call?.screen)
|
|
735
|
+
return;
|
|
736
|
+
call.screen.stop();
|
|
737
|
+
call.screen = null;
|
|
738
|
+
await sendVideo(call);
|
|
739
|
+
},
|
|
740
|
+
async close() {
|
|
741
|
+
if (closed)
|
|
742
|
+
return;
|
|
743
|
+
closed = true;
|
|
744
|
+
await leave();
|
|
745
|
+
clearInterval(sweeper);
|
|
746
|
+
for (const ring of ringing.values())
|
|
747
|
+
clearTimeout(ring.timer);
|
|
748
|
+
ringing.clear();
|
|
749
|
+
forgetRejoin();
|
|
750
|
+
unsubscribe();
|
|
751
|
+
listeners.clear();
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function safeSessionStorage() {
|
|
756
|
+
try {
|
|
757
|
+
return globalThis.sessionStorage ?? null;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
//# sourceMappingURL=calls.js.map
|