@poolse/react 2.18.0 → 2.19.0

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,40 @@ 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.19.0] — 2026-08-25
8
+
9
+ ### Fixed
10
+
11
+ - **A call cancelled by the caller left the callee's phone ringing.**
12
+ A ring delivered by VoIP push was shown by CallKit without the state
13
+ machine ever hearing about it, so `call:cancelled` was matched against
14
+ an `incoming` that had never been set, failed the guard, and did
15
+ nothing. The phone rang on until its own 45-second timeout. New
16
+ `ring()` registers a ring that arrived off-socket; `onIncoming` now
17
+ recognises the same call arriving by both routes instead of answering
18
+ your own ring with `call:busy`.
19
+
20
+ - **Declining a call before the socket was ready told nobody.** The
21
+ hook builds nothing until it has a user id, and host apps get that
22
+ from `useMe()` — a network round trip. A VoIP push launches the app
23
+ cold and CallKit lets you answer or refuse from the lock screen well
24
+ before that returns, so both actions hit `if (!calls) return` and were
25
+ dropped. They are now queued and sent the moment the resource exists.
26
+ `accept()` had the identical hole.
27
+
28
+ ### Changed
29
+
30
+ - **A group ring no longer ends on the first refusal.** Declines and
31
+ busies accumulate per callee, and the call ends only once nobody is
32
+ left who might still pick up. All busy reports busy; any outright
33
+ refusal reports declined. A decline arriving after somebody answered
34
+ no longer ends the active call.
35
+
36
+ ### Added
37
+
38
+ - `dropped(callId)` — a call torn down from outside the state machine,
39
+ which is how the end of a call reaches a device whose socket isn't up.
40
+
7
41
  ## [2.18.0] — 2026-08-24
8
42
 
9
43
  Republished in lockstep.
package/dist/index.cjs CHANGED
@@ -1342,6 +1342,8 @@ function useCalls(userId, opts = {}) {
1342
1342
  const isCurrent = react.useCallback((callId) => {
1343
1343
  return callId === activeCallIdRef.current || callId === incomingRef.current?.callId || callId === outgoingRef.current?.callId;
1344
1344
  }, []);
1345
+ const rejectedRef = react.useRef(/* @__PURE__ */ new Map());
1346
+ const pendingRef = react.useRef(null);
1345
1347
  const actionsRef = react.useRef({
1346
1348
  cancel: async () => {
1347
1349
  },
@@ -1349,9 +1351,24 @@ function useCalls(userId, opts = {}) {
1349
1351
  }
1350
1352
  });
1351
1353
  const calls = react.useMemo(() => userId ? poolse.realtime.calls(userId) : null, [poolse, userId]);
1354
+ const reject = react.useCallback((callId, userId2, kind) => {
1355
+ if (phaseRef.current !== "ringing-out") return;
1356
+ const out = outgoingRef.current;
1357
+ if (!out || out.callId !== callId) return;
1358
+ rejectedRef.current.set(userId2, kind);
1359
+ const outstanding = out.calleeUserIds.filter((id) => !rejectedRef.current.has(id));
1360
+ if (out.calleeUserIds.length > 0 && outstanding.length > 0) return;
1361
+ const allBusy = [...rejectedRef.current.values()].every((k) => k === "busy");
1362
+ setPhase(allBusy ? "busy" : "declined");
1363
+ setOutgoing(null);
1364
+ setActiveCallId(null);
1365
+ setActiveMedia(null);
1366
+ }, []);
1352
1367
  react.useEffect(() => {
1353
1368
  if (!calls) return;
1354
1369
  const offIncoming = calls.onIncoming((call2) => {
1370
+ if (call2.callId === incomingRef.current?.callId) return;
1371
+ if (call2.callId === activeCallIdRef.current) return;
1355
1372
  if (phaseRef.current !== "idle") {
1356
1373
  void calls.markBusy(call2).catch(() => {
1357
1374
  });
@@ -1368,17 +1385,11 @@ function useCalls(userId, opts = {}) {
1368
1385
  });
1369
1386
  const offDeclined = calls.onDeclined((call2) => {
1370
1387
  if (!isCurrent(call2.callId)) return;
1371
- setPhase("declined");
1372
- setOutgoing(null);
1373
- setActiveCallId(null);
1374
- setActiveMedia(null);
1388
+ reject(call2.callId, call2.userId, "declined");
1375
1389
  });
1376
1390
  const offBusy = calls.onBusy((call2) => {
1377
1391
  if (!isCurrent(call2.callId)) return;
1378
- setPhase("busy");
1379
- setOutgoing(null);
1380
- setActiveCallId(null);
1381
- setActiveMedia(null);
1392
+ reject(call2.callId, call2.userId, "busy");
1382
1393
  });
1383
1394
  const offEnded = calls.onEnded((call2) => {
1384
1395
  if (!isCurrent(call2.callId)) return;
@@ -1404,11 +1415,22 @@ function useCalls(userId, opts = {}) {
1404
1415
  offEnded();
1405
1416
  offCancelled();
1406
1417
  };
1407
- }, [calls, isCurrent]);
1418
+ }, [calls, isCurrent, reject]);
1419
+ react.useEffect(() => {
1420
+ if (!calls) return;
1421
+ const queued = pendingRef.current;
1422
+ if (!queued) return;
1423
+ pendingRef.current = null;
1424
+ const sent = queued.kind === "accept" ? calls.accept(queued.call) : calls.decline(queued.call);
1425
+ void sent.catch((err) => {
1426
+ setError(err instanceof Error ? err : new Error(String(err)));
1427
+ });
1428
+ }, [calls]);
1408
1429
  const call = react.useCallback(
1409
1430
  async (conversationId, media2 = "audio") => {
1410
1431
  if (!calls) return;
1411
1432
  setError(null);
1433
+ rejectedRef.current = /* @__PURE__ */ new Map();
1412
1434
  try {
1413
1435
  const placed = await calls.invite(conversationId, media2);
1414
1436
  setOutgoing(placed);
@@ -1422,10 +1444,40 @@ function useCalls(userId, opts = {}) {
1422
1444
  },
1423
1445
  [calls]
1424
1446
  );
1447
+ const ring = react.useCallback((call2) => {
1448
+ if (incomingRef.current?.callId === call2.callId) return;
1449
+ if (activeCallIdRef.current === call2.callId) return;
1450
+ if (phaseRef.current !== "idle") return;
1451
+ setIncoming(call2);
1452
+ setPhase("ringing-in");
1453
+ }, []);
1454
+ const dropped = react.useCallback(
1455
+ (callId) => {
1456
+ if (pendingRef.current?.call.callId === callId) pendingRef.current = null;
1457
+ if (!isCurrent(callId)) return;
1458
+ rejectedRef.current = /* @__PURE__ */ new Map();
1459
+ setPhase("idle");
1460
+ setIncoming(null);
1461
+ setOutgoing(null);
1462
+ setActiveConversationId(null);
1463
+ setActiveCallId(null);
1464
+ setActiveMedia(null);
1465
+ },
1466
+ [isCurrent]
1467
+ );
1425
1468
  const accept = react.useCallback(
1426
1469
  async (call2) => {
1427
1470
  const target = call2 ?? incoming;
1428
- if (!calls || !target) return;
1471
+ if (!target) return;
1472
+ if (!calls) {
1473
+ pendingRef.current = { kind: "accept", call: target };
1474
+ setActiveConversationId(target.conversationId);
1475
+ setActiveCallId(target.callId);
1476
+ setActiveMedia(target.media);
1477
+ setPhase("active");
1478
+ setIncoming(null);
1479
+ return;
1480
+ }
1429
1481
  try {
1430
1482
  await calls.accept(target);
1431
1483
  setActiveConversationId(target.conversationId);
@@ -1442,7 +1494,15 @@ function useCalls(userId, opts = {}) {
1442
1494
  const decline = react.useCallback(
1443
1495
  async (call2) => {
1444
1496
  const target = call2 ?? incoming;
1445
- if (!calls || !target) return;
1497
+ if (!target) return;
1498
+ if (!calls) {
1499
+ pendingRef.current = { kind: "decline", call: target };
1500
+ setIncoming(null);
1501
+ setActiveCallId(null);
1502
+ setActiveMedia(null);
1503
+ setPhase("idle");
1504
+ return;
1505
+ }
1446
1506
  try {
1447
1507
  await calls.decline(target);
1448
1508
  } catch (err) {
@@ -1505,6 +1565,8 @@ function useCalls(userId, opts = {}) {
1505
1565
  }
1506
1566
  }, [calls, phase, incoming, outgoing, activeConversationId, activeCallId]);
1507
1567
  const reset = react.useCallback(() => {
1568
+ rejectedRef.current = /* @__PURE__ */ new Map();
1569
+ pendingRef.current = null;
1508
1570
  setPhase("idle");
1509
1571
  setIncoming(null);
1510
1572
  setOutgoing(null);
@@ -1522,6 +1584,8 @@ function useCalls(userId, opts = {}) {
1522
1584
  media,
1523
1585
  error,
1524
1586
  call,
1587
+ ring,
1588
+ dropped,
1525
1589
  accept,
1526
1590
  decline,
1527
1591
  cancel,