@poolse/react 2.14.1 → 2.15.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to `@poolse/react` are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [semver](https://semver.org).
6
6
 
7
+ ## [2.15.1] — 2026-08-23
8
+
9
+ ### Fixed
10
+
11
+ - **A video call opened with the camera off for whoever answered it.**
12
+ `media` was derived from `incoming ?? outgoing`, and `accept()` clears
13
+ `incoming` in the same batch that flips the phase — so the callee fell
14
+ back to `'audio'` and its room never opened the camera, while the
15
+ caller (which keeps `outgoing`) did. The default looked random; it was
16
+ really "whichever end of the call you were on". The media type of the
17
+ call in progress is now tracked in its own right.
18
+
19
+ ## [2.15.0] — 2026-08-23
20
+
21
+ ### Fixed
22
+
23
+ - **Answering a call then hanging up left the other side in it.**
24
+ `accept()` clears `incoming`, and a callee never had an `outgoing`, so
25
+ `hangUp()` resolved no call id and returned before signalling. The
26
+ call id of the call in progress is now tracked in its own right.
27
+ - **A late `call:cancelled` tore down a live call.** The guard fell
28
+ through whenever `incoming` was null — exactly the state `accept()`
29
+ leaves behind — so a caller's ring timeout firing as the callee
30
+ answered dropped the callee straight out of the call.
31
+ - **Every call signal is now matched to the call it belongs to.**
32
+ `accepted`, `declined`, `busy`, `ended` and `cancelled` are ignored
33
+ unless they name the call this client is actually in. A signal about a
34
+ previous call could previously end the current one.
35
+ - **A room could be left connected with nothing holding it.** If a call
36
+ ended while its media was still connecting, the cleanup ran before
37
+ `join()` resolved, found nothing to release, and the connection
38
+ completed into a room no component held — audio still flowing, no UI,
39
+ and no way to hang up. Both `useCallRoom` and `useVoiceRoom` now
40
+ release a room that was superseded mid-connect.
41
+ - **`markBusy` could reject a call the user was being shown.** The
42
+ decision ran inside a `setPhase` updater; updaters must be pure, and
43
+ React may run them more than once.
44
+
7
45
  ## [2.14.1] — 2026-08-23
8
46
 
9
47
  ### Added
package/dist/index.cjs CHANGED
@@ -1082,6 +1082,7 @@ function useCallRoom(conversationId, opts) {
1082
1082
  const optsRef = react.useRef(opts);
1083
1083
  optsRef.current = opts;
1084
1084
  const joined = react.useRef(false);
1085
+ const held = react.useRef(null);
1085
1086
  const room = react.useMemo(() => {
1086
1087
  if (!conversationId) return null;
1087
1088
  return poolse.call(conversationId, {
@@ -1121,6 +1122,7 @@ function useCallRoom(conversationId, opts) {
1121
1122
  }
1122
1123
  setStatus(room.getStatus());
1123
1124
  setParticipants(room.getParticipants());
1125
+ held.current = room;
1124
1126
  setAlone(false);
1125
1127
  const offStatus = room.onStatus(setStatus);
1126
1128
  const offRoster = room.onParticipants((next) => {
@@ -1136,6 +1138,7 @@ function useCallRoom(conversationId, opts) {
1136
1138
  offAlone();
1137
1139
  offDevices();
1138
1140
  offError();
1141
+ held.current = null;
1139
1142
  if (joined.current) {
1140
1143
  joined.current = false;
1141
1144
  room.leave();
@@ -1143,14 +1146,19 @@ function useCallRoom(conversationId, opts) {
1143
1146
  };
1144
1147
  }, [room, sync, refreshDevices]);
1145
1148
  const join = react.useCallback(async () => {
1146
- if (!room) return false;
1149
+ const target = room;
1150
+ if (!target) return false;
1147
1151
  setError(null);
1148
1152
  try {
1149
- const connected = await room.join();
1153
+ const connected = await target.join();
1154
+ if (held.current !== target) {
1155
+ if (connected) target.leave();
1156
+ return false;
1157
+ }
1150
1158
  joined.current = connected;
1151
1159
  setSfuAvailable(connected);
1152
- sync(room);
1153
- if (connected) refreshDevices(room);
1160
+ sync(target);
1161
+ if (connected) refreshDevices(target);
1154
1162
  return connected;
1155
1163
  } catch (err) {
1156
1164
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1257,12 +1265,14 @@ function useVoiceRoom(conversationId, opts = {}) {
1257
1265
  const [error, setError] = react.useState(null);
1258
1266
  const optsRef = react.useRef(opts);
1259
1267
  const joined = react.useRef(false);
1268
+ const held = react.useRef(null);
1260
1269
  const room = react.useMemo(
1261
1270
  () => conversationId ? poolse.realtime.voice(conversationId, optsRef.current) : null,
1262
1271
  [poolse, conversationId]
1263
1272
  );
1264
1273
  react.useEffect(() => {
1265
1274
  if (!room) return;
1275
+ held.current = room;
1266
1276
  setStatus(room.getStatus());
1267
1277
  setParticipants(room.getParticipants());
1268
1278
  setMuted(room.isMuted());
@@ -1273,6 +1283,7 @@ function useVoiceRoom(conversationId, opts = {}) {
1273
1283
  offStatus();
1274
1284
  offRoster();
1275
1285
  offError();
1286
+ held.current = null;
1276
1287
  if (joined.current) {
1277
1288
  joined.current = false;
1278
1289
  room.leave();
@@ -1280,10 +1291,15 @@ function useVoiceRoom(conversationId, opts = {}) {
1280
1291
  };
1281
1292
  }, [room]);
1282
1293
  const join = react.useCallback(async () => {
1283
- if (!room) return;
1294
+ const target = room;
1295
+ if (!target) return;
1284
1296
  setError(null);
1285
1297
  try {
1286
- await room.join();
1298
+ await target.join();
1299
+ if (held.current !== target) {
1300
+ target.leave();
1301
+ return;
1302
+ }
1287
1303
  joined.current = true;
1288
1304
  } catch (err) {
1289
1305
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1309,10 +1325,23 @@ function useCalls(userId, opts = {}) {
1309
1325
  const ringTimeoutMs = opts.ringTimeoutMs ?? 45e3;
1310
1326
  const poolse = usePoolse();
1311
1327
  const [phase, setPhase] = react.useState("idle");
1328
+ const phaseRef = react.useRef("idle");
1329
+ phaseRef.current = phase;
1312
1330
  const [incoming, setIncoming] = react.useState(null);
1313
1331
  const [outgoing, setOutgoing] = react.useState(null);
1314
1332
  const [activeConversationId, setActiveConversationId] = react.useState(null);
1333
+ const [activeCallId, setActiveCallId] = react.useState(null);
1334
+ const [activeMedia, setActiveMedia] = react.useState(null);
1315
1335
  const [error, setError] = react.useState(null);
1336
+ const incomingRef = react.useRef(null);
1337
+ incomingRef.current = incoming;
1338
+ const outgoingRef = react.useRef(null);
1339
+ outgoingRef.current = outgoing;
1340
+ const activeCallIdRef = react.useRef(null);
1341
+ activeCallIdRef.current = activeCallId;
1342
+ const isCurrent = react.useCallback((callId) => {
1343
+ return callId === activeCallIdRef.current || callId === incomingRef.current?.callId || callId === outgoingRef.current?.callId;
1344
+ }, []);
1316
1345
  const actionsRef = react.useRef({
1317
1346
  cancel: async () => {
1318
1347
  },
@@ -1323,40 +1352,49 @@ function useCalls(userId, opts = {}) {
1323
1352
  react.useEffect(() => {
1324
1353
  if (!calls) return;
1325
1354
  const offIncoming = calls.onIncoming((call2) => {
1326
- setPhase((current) => {
1327
- if (current !== "idle") {
1328
- void calls.markBusy(call2).catch(() => {
1329
- });
1330
- return current;
1331
- }
1332
- setIncoming(call2);
1333
- return "ringing-in";
1334
- });
1355
+ if (phaseRef.current !== "idle") {
1356
+ void calls.markBusy(call2).catch(() => {
1357
+ });
1358
+ return;
1359
+ }
1360
+ setIncoming(call2);
1361
+ setPhase("ringing-in");
1335
1362
  });
1336
1363
  const offAccepted = calls.onAccepted((call2) => {
1364
+ if (outgoingRef.current && call2.callId !== outgoingRef.current.callId) return;
1337
1365
  setPhase("active");
1338
1366
  setActiveConversationId(call2.conversationId);
1367
+ setActiveCallId(call2.callId);
1339
1368
  });
1340
- const offDeclined = calls.onDeclined(() => {
1369
+ const offDeclined = calls.onDeclined((call2) => {
1370
+ if (!isCurrent(call2.callId)) return;
1341
1371
  setPhase("declined");
1342
1372
  setOutgoing(null);
1373
+ setActiveCallId(null);
1374
+ setActiveMedia(null);
1343
1375
  });
1344
- const offBusy = calls.onBusy(() => {
1376
+ const offBusy = calls.onBusy((call2) => {
1377
+ if (!isCurrent(call2.callId)) return;
1345
1378
  setPhase("busy");
1346
1379
  setOutgoing(null);
1380
+ setActiveCallId(null);
1381
+ setActiveMedia(null);
1347
1382
  });
1348
- const offEnded = calls.onEnded(() => {
1383
+ const offEnded = calls.onEnded((call2) => {
1384
+ if (!isCurrent(call2.callId)) return;
1349
1385
  setPhase("idle");
1350
1386
  setIncoming(null);
1351
1387
  setOutgoing(null);
1352
1388
  setActiveConversationId(null);
1389
+ setActiveCallId(null);
1390
+ setActiveMedia(null);
1353
1391
  });
1354
1392
  const offCancelled = calls.onCancelled((call2) => {
1355
- setIncoming((current) => {
1356
- if (current && current.callId !== call2.callId) return current;
1357
- setPhase("idle");
1358
- return null;
1359
- });
1393
+ if (call2.callId !== incomingRef.current?.callId) return;
1394
+ setIncoming(null);
1395
+ setPhase("idle");
1396
+ setActiveCallId(null);
1397
+ setActiveMedia(null);
1360
1398
  });
1361
1399
  return () => {
1362
1400
  offIncoming();
@@ -1366,7 +1404,7 @@ function useCalls(userId, opts = {}) {
1366
1404
  offEnded();
1367
1405
  offCancelled();
1368
1406
  };
1369
- }, [calls]);
1407
+ }, [calls, isCurrent]);
1370
1408
  const call = react.useCallback(
1371
1409
  async (conversationId, media2 = "audio") => {
1372
1410
  if (!calls) return;
@@ -1374,6 +1412,8 @@ function useCalls(userId, opts = {}) {
1374
1412
  try {
1375
1413
  const placed = await calls.invite(conversationId, media2);
1376
1414
  setOutgoing(placed);
1415
+ setActiveCallId(placed.callId);
1416
+ setActiveMedia(placed.media);
1377
1417
  setPhase("ringing-out");
1378
1418
  } catch (err) {
1379
1419
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1389,6 +1429,8 @@ function useCalls(userId, opts = {}) {
1389
1429
  try {
1390
1430
  await calls.accept(target);
1391
1431
  setActiveConversationId(target.conversationId);
1432
+ setActiveCallId(target.callId);
1433
+ setActiveMedia(target.media);
1392
1434
  setPhase("active");
1393
1435
  setIncoming(null);
1394
1436
  } catch (err) {
@@ -1407,6 +1449,8 @@ function useCalls(userId, opts = {}) {
1407
1449
  setError(err instanceof Error ? err : new Error(String(err)));
1408
1450
  } finally {
1409
1451
  setIncoming(null);
1452
+ setActiveCallId(null);
1453
+ setActiveMedia(null);
1410
1454
  setPhase("idle");
1411
1455
  }
1412
1456
  },
@@ -1420,6 +1464,8 @@ function useCalls(userId, opts = {}) {
1420
1464
  setError(err instanceof Error ? err : new Error(String(err)));
1421
1465
  } finally {
1422
1466
  setOutgoing(null);
1467
+ setActiveCallId(null);
1468
+ setActiveMedia(null);
1423
1469
  setPhase("idle");
1424
1470
  }
1425
1471
  }, [calls, outgoing]);
@@ -1437,7 +1483,7 @@ function useCalls(userId, opts = {}) {
1437
1483
  }, [phase, ringTimeoutMs]);
1438
1484
  const hangUp = react.useCallback(async () => {
1439
1485
  const conversationId = activeConversationId ?? incoming?.conversationId ?? outgoing?.conversationId;
1440
- const callId = incoming?.callId ?? outgoing?.callId;
1486
+ const callId = activeCallId ?? incoming?.callId ?? outgoing?.callId;
1441
1487
  try {
1442
1488
  if (!calls || !callId || !conversationId) return;
1443
1489
  if (phase === "ringing-out" && outgoing) {
@@ -1454,16 +1500,20 @@ function useCalls(userId, opts = {}) {
1454
1500
  setIncoming(null);
1455
1501
  setOutgoing(null);
1456
1502
  setActiveConversationId(null);
1503
+ setActiveCallId(null);
1504
+ setActiveMedia(null);
1457
1505
  }
1458
- }, [calls, phase, incoming, outgoing, activeConversationId]);
1506
+ }, [calls, phase, incoming, outgoing, activeConversationId, activeCallId]);
1459
1507
  const reset = react.useCallback(() => {
1460
1508
  setPhase("idle");
1461
1509
  setIncoming(null);
1462
1510
  setOutgoing(null);
1463
1511
  setActiveConversationId(null);
1512
+ setActiveCallId(null);
1513
+ setActiveMedia(null);
1464
1514
  setError(null);
1465
1515
  }, []);
1466
- const media = incoming?.media ?? outgoing?.media ?? "audio";
1516
+ const media = incoming?.media ?? outgoing?.media ?? activeMedia ?? "audio";
1467
1517
  return {
1468
1518
  phase,
1469
1519
  incoming,