@ethora/chat-component-rn 26.10.7 → 26.10.9

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js +3 -3
  3. package/lib/commonjs/components/Menu/HeaderRoomListMenu.js.map +1 -1
  4. package/lib/commonjs/components/MessageBubble/Message.js +16 -6
  5. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  6. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
  7. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  8. package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
  9. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
  10. package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
  11. package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
  12. package/lib/commonjs/components/VideoCalls/useCallKeep.js +84 -69
  13. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  14. package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
  15. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  16. package/lib/commonjs/main.js +33 -0
  17. package/lib/commonjs/main.js.map +1 -1
  18. package/lib/commonjs/networking/callTokenStanza.js +19 -1
  19. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  20. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
  21. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  22. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  23. package/lib/commonjs/push/sealedPush.js +13 -2
  24. package/lib/commonjs/push/sealedPush.js.map +1 -1
  25. package/lib/commonjs/push/sealedPushTask.js +63 -18
  26. package/lib/commonjs/push/sealedPushTask.js.map +1 -1
  27. package/lib/commonjs/version.js +1 -1
  28. package/lib/module/components/Menu/HeaderRoomListMenu.js +3 -3
  29. package/lib/module/components/Menu/HeaderRoomListMenu.js.map +1 -1
  30. package/lib/module/components/MessageBubble/Message.js +16 -6
  31. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  32. package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
  33. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  34. package/lib/module/components/VideoCalls/callActions.js +29 -0
  35. package/lib/module/components/VideoCalls/callActions.js.map +1 -1
  36. package/lib/module/components/VideoCalls/systemCall.js +73 -0
  37. package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
  38. package/lib/module/components/VideoCalls/useCallKeep.js +79 -68
  39. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  40. package/lib/module/helpers/incomingCallFromPush.js +92 -22
  41. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  42. package/lib/module/main.js +3 -0
  43. package/lib/module/main.js.map +1 -1
  44. package/lib/module/networking/callTokenStanza.js +19 -1
  45. package/lib/module/networking/callTokenStanza.js.map +1 -1
  46. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
  47. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  48. package/lib/module/networking/xmppClient.js.map +1 -1
  49. package/lib/module/push/sealedPush.js +11 -1
  50. package/lib/module/push/sealedPush.js.map +1 -1
  51. package/lib/module/push/sealedPushTask.js +62 -18
  52. package/lib/module/push/sealedPushTask.js.map +1 -1
  53. package/lib/module/version.js +1 -1
  54. package/lib/typescript/components/Menu/HeaderRoomListMenu.d.ts.map +1 -1
  55. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  56. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  57. package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
  58. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
  59. package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
  60. package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
  61. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
  62. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  63. package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
  64. package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
  65. package/lib/typescript/main.d.ts +6 -1
  66. package/lib/typescript/main.d.ts.map +1 -1
  67. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  68. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
  69. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
  70. package/lib/typescript/networking/xmppClient.d.ts +2 -4
  71. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  72. package/lib/typescript/push/sealedPush.d.ts +6 -0
  73. package/lib/typescript/push/sealedPush.d.ts.map +1 -1
  74. package/lib/typescript/push/sealedPushTask.d.ts +31 -7
  75. package/lib/typescript/push/sealedPushTask.d.ts.map +1 -1
  76. package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
  77. package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
  78. package/lib/typescript/version.d.ts +1 -1
  79. package/package.json +1 -1
  80. package/plugins/notification-service/NotificationService.swift +105 -19
  81. package/plugins/withNotificationServiceExtension.js +44 -4
  82. package/src/components/Menu/HeaderRoomListMenu.tsx +13 -8
  83. package/src/components/MessageBubble/Message.tsx +16 -5
  84. package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
  85. package/src/components/VideoCalls/callActions.ts +28 -0
  86. package/src/components/VideoCalls/systemCall.ts +109 -0
  87. package/src/components/VideoCalls/useCallKeep.ts +91 -101
  88. package/src/helpers/incomingCallFromPush.ts +97 -26
  89. package/src/main.ts +9 -1
  90. package/src/networking/callTokenStanza.ts +20 -1
  91. package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
  92. package/src/networking/xmppClient.ts +2 -2
  93. package/src/push/sealedPush.ts +11 -0
  94. package/src/push/sealedPushTask.ts +95 -16
  95. package/src/types/models/xmpp.model.ts +2 -1
  96. package/src/version.ts +1 -1
@@ -26,6 +26,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
26
26
  import { RootState } from '../../roomStore';
27
27
  import {
28
28
  acceptIncomingCall,
29
+ declineIncomingCall,
29
30
  resetCall,
30
31
  setCallError,
31
32
  } from '../../roomStore/callSlice';
@@ -64,6 +65,12 @@ const ACCEPT = '#10B981';
64
65
  // PSTN ring window.
65
66
  const OUTGOING_CALL_TIMEOUT_MS = 30000;
66
67
 
68
+ // Stop ringing an incoming call nobody cancelled. The caller gives up after
69
+ // OUTGOING_CALL_TIMEOUT_MS and says so, but that signal is lost when the
70
+ // call came in by push and the socket never came up (the system call
71
+ // screen of a VoIP push would otherwise ring on).
72
+ const INCOMING_CALL_TIMEOUT_MS = 45000;
73
+
67
74
  // Ring pattern for an incoming call: wait, buzz, pause, buzz... Android
68
75
  // loops this from index 0, iOS ignores the pattern and just vibrates.
69
76
  const RING_VIBRATION_PATTERN = [0, 700, 900];
@@ -507,8 +514,6 @@ const VideoCallOverlayInner: FC = () => {
507
514
  }, [enabled, canRenderSession, call.token, livekitUrl, call.kind]);
508
515
 
509
516
  // Give up on an unanswered outgoing dial. Unlike web (which only shows
510
- // the error), also tell the callee, who would otherwise keep ringing:
511
- // there is no incoming ring timeout on either platform.
512
517
  useEffect(() => {
513
518
  if (call.phase !== 'requesting' || call.direction !== 'outgoing') {
514
519
  return;
@@ -520,6 +525,17 @@ const VideoCallOverlayInner: FC = () => {
520
525
  return () => clearTimeout(timer);
521
526
  }, [call.phase, call.direction, call.startedAt, dispatch, t]);
522
527
 
528
+ useEffect(() => {
529
+ if (call.phase !== 'ringing-incoming' || call.direction !== 'incoming') {
530
+ return;
531
+ }
532
+ const elapsed = Date.now() - (call.startedAt || Date.now());
533
+ const timer = setTimeout(() => {
534
+ dispatch(declineIncomingCall());
535
+ }, Math.max(0, INCOMING_CALL_TIMEOUT_MS - elapsed));
536
+ return () => clearTimeout(timer);
537
+ }, [call.phase, call.direction, call.startedAt, dispatch]);
538
+
523
539
  // Buzz while an incoming call is ringing. The ring screen is already on
524
540
  // top, so this is the only alert needed when the app is in the
525
541
  // foreground; a backgrounded/killed app is handled by the call push.
@@ -7,6 +7,7 @@ import {
7
7
  sendCallStateSignal,
8
8
  } from '../../networking/callTokenStanza';
9
9
  import { translateKey } from '../../i18n/strings';
10
+ import { getGlobalXmppClient } from '../../utils/clientRegistry';
10
11
 
11
12
  /**
12
13
  * Call teardown that doesn't need a mounted component.
@@ -51,6 +52,33 @@ export const declineActiveCall = (): void => {
51
52
  store.dispatch(declineIncomingCall());
52
53
  };
53
54
 
55
+ const DECLINE_SIGNAL_WAIT_MS = 30_000;
56
+ const DECLINE_SIGNAL_POLL_MS = 500;
57
+
58
+ /**
59
+ * Decline the ringing call, telling the caller as soon as the socket is
60
+ * up. A call declined on the lock screen right after a push woke the app
61
+ * gets here while XMPP is still connecting, and the signal would be lost.
62
+ */
63
+ export const declineActiveCallWhenOnline = (): void => {
64
+ const call = store.getState().call;
65
+ const signal = {
66
+ peerXmppUsername: call.peerXmppUsername,
67
+ callId: call.callId,
68
+ roomBareName: call.roomBareName,
69
+ };
70
+ store.dispatch(declineIncomingCall());
71
+ const deadline = Date.now() + DECLINE_SIGNAL_WAIT_MS;
72
+ const attempt = () => {
73
+ if (getGlobalXmppClient()?.status === 'online') {
74
+ sendCallStateSignal('declined', signal);
75
+ return;
76
+ }
77
+ if (Date.now() < deadline) {setTimeout(attempt, DECLINE_SIGNAL_POLL_MS);}
78
+ };
79
+ attempt();
80
+ };
81
+
54
82
  /** `t` for code outside React, resolved by the same rule as useT. */
55
83
  export const translateCallString = (
56
84
  key: string,
@@ -0,0 +1,109 @@
1
+ import { Platform } from 'react-native';
2
+
3
+
4
+ export interface CallKeepModule {
5
+ setup: (options: any) => Promise<void>;
6
+ displayIncomingCall: (
7
+ uuid: string,
8
+ handle: string,
9
+ localizedCallerName?: string,
10
+ handleType?: string,
11
+ hasVideo?: boolean
12
+ ) => void;
13
+ endCall: (uuid: string) => void;
14
+ endAllCalls: () => void;
15
+ startCall?: (
16
+ uuid: string,
17
+ handle: string,
18
+ contactIdentifier?: string,
19
+ handleType?: string,
20
+ hasVideo?: boolean
21
+ ) => void;
22
+ answerIncomingCall?: (uuid: string) => void;
23
+ reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
24
+ reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
25
+ reportEndCallWithUUID?: (uuid: string, reason: number) => void;
26
+ setMutedCall?: (uuid: string, muted: boolean) => void;
27
+ addEventListener: (event: string, handler: (payload: any) => void) => void;
28
+ removeEventListener: (event: string) => void;
29
+ setAvailable?: (available: boolean) => void;
30
+ backToForeground?: () => void;
31
+ clearInitialEvents?: () => void;
32
+ }
33
+
34
+ let cachedCallKeep: CallKeepModule | null | undefined;
35
+
36
+ export const loadCallKeep = (): CallKeepModule | null => {
37
+ if (cachedCallKeep !== undefined) {
38
+ return cachedCallKeep;
39
+ }
40
+ try {
41
+ const { NativeModules } = require('react-native');
42
+ if (!NativeModules?.RNCallKeep) {
43
+ cachedCallKeep = null;
44
+ return cachedCallKeep;
45
+ }
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const mod = require('react-native-callkeep');
48
+ cachedCallKeep = (mod?.default || mod) as CallKeepModule;
49
+ } catch {
50
+ cachedCallKeep = null;
51
+ }
52
+ return cachedCallKeep;
53
+ };
54
+
55
+ export const callKitUuid = (seed: string): string => {
56
+ let hash = 0x811c9dc5;
57
+ const bytes: number[] = [];
58
+ for (let i = 0; i < 32; i++) {
59
+ const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
60
+ hash ^= ch;
61
+ hash = Math.imul(hash, 0x01000193) >>> 0;
62
+ bytes.push(hash & 0xff);
63
+ }
64
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
65
+ return [
66
+ hex.slice(0, 8),
67
+ hex.slice(8, 12),
68
+ '4' + hex.slice(13, 16),
69
+ ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
70
+ hex.slice(20, 32),
71
+ ].join('-');
72
+ };
73
+
74
+ export type SystemCallAction = 'answer' | 'end';
75
+
76
+ const SYSTEM_ACTION_TTL_MS = 2 * 60 * 1000;
77
+ const pendingActions = new Map<string, { action: SystemCallAction; at: number }>();
78
+
79
+ const key = (uuid: string) => uuid.trim().toLowerCase();
80
+
81
+ export const rememberSystemCallAction = (
82
+ uuid: string,
83
+ action: SystemCallAction
84
+ ): void => {
85
+ if (!uuid) {return;}
86
+ pendingActions.set(key(uuid), { action, at: Date.now() });
87
+ };
88
+
89
+ export const takeSystemCallAction = (uuid: string): SystemCallAction | null => {
90
+ const entry = pendingActions.get(key(uuid));
91
+ pendingActions.delete(key(uuid));
92
+ if (!entry || Date.now() - entry.at > SYSTEM_ACTION_TTL_MS) {return null;}
93
+ return entry.action;
94
+ };
95
+
96
+ export const endSystemIncomingCall = (callId: string | null | undefined): void => {
97
+ if (Platform.OS !== 'ios' || !callId) {return;}
98
+ const callKeep = loadCallKeep();
99
+ if (!callKeep?.reportEndCallWithUUID) {return;}
100
+ try {
101
+ callKeep.reportEndCallWithUUID(callKitUuid(callId), 3);
102
+ } catch {
103
+ }
104
+ };
105
+
106
+ export const __resetSystemCallForTests = (): void => {
107
+ pendingActions.clear();
108
+ cachedCallKeep = undefined;
109
+ };
@@ -2,12 +2,17 @@ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Platform } from 'react-native';
3
3
  import { useDispatch, useSelector } from 'react-redux';
4
4
  import { RootState, store } from '../../roomStore';
5
- import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
6
- import { sendCallStateSignal } from '../../networking/callTokenStanza';
7
- import { terminateActiveCall } from './callActions';
5
+ import { acceptIncomingCall } from '../../roomStore/callSlice';
6
+ import { declineActiveCallWhenOnline, terminateActiveCall } from './callActions';
8
7
  import { setCallMicEnabled, useCallSession } from './callSessionController';
9
8
  import { loadLiveKit } from './livekitRuntime';
10
9
  import { setCallSystemRinging } from './callUiStore';
10
+ import {
11
+ callKitUuid,
12
+ loadCallKeep,
13
+ rememberSystemCallAction,
14
+ takeSystemCallAction,
15
+ } from './systemCall';
11
16
 
12
17
  /**
13
18
  * Native incoming-call UI (Android ConnectionService / iOS CallKit) via
@@ -27,95 +32,15 @@ import { setCallSystemRinging } from './callUiStore';
27
32
  * Platform reality, stated plainly:
28
33
  * - Android: full value. A ConnectionService call shows the system
29
34
  * incoming-call screen over the lock screen.
30
- * - iOS: this only fires while the app is running. A CallKit screen from
31
- * a killed app requires a VoIP push over PushKit, which needs a
32
- * separate Apple VoIP certificate and a separate send path on the
33
- * backend (not FCM). Until that exists, iOS falls back to the regular
34
- * data push and the in-app ring screen.
35
+ * - iOS: this hook only runs while `<Chat>` is mounted. A CallKit screen
36
+ * from a killed app comes from the host: its PushKit handler reports
37
+ * the VoIP push to CallKit natively under `callKitUuid(callId)` and
38
+ * hands the payload to `handlePushPayload`. This hook then adopts that
39
+ * call (same UUID, CallKit answers "CallUUIDAlreadyExists" to our own
40
+ * report) and replays what the user did on it before JS was up.
35
41
  */
36
42
 
37
- interface CallKeepModule {
38
- setup: (options: any) => Promise<void>;
39
- displayIncomingCall: (
40
- uuid: string,
41
- handle: string,
42
- localizedCallerName?: string,
43
- handleType?: string,
44
- hasVideo?: boolean
45
- ) => void;
46
- endCall: (uuid: string) => void;
47
- endAllCalls: () => void;
48
- startCall?: (
49
- uuid: string,
50
- handle: string,
51
- contactIdentifier?: string,
52
- handleType?: string,
53
- hasVideo?: boolean
54
- ) => void;
55
- answerIncomingCall?: (uuid: string) => void;
56
- reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
57
- reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
58
- reportEndCallWithUUID?: (uuid: string, reason: number) => void;
59
- setMutedCall?: (uuid: string, muted: boolean) => void;
60
- addEventListener: (event: string, handler: (payload: any) => void) => void;
61
- removeEventListener: (event: string) => void;
62
- setAvailable?: (available: boolean) => void;
63
- backToForeground?: () => void;
64
- }
65
-
66
- let cachedCallKeep: CallKeepModule | null | undefined;
67
-
68
- export const loadCallKeep = (): CallKeepModule | null => {
69
- if (cachedCallKeep !== undefined) {
70
- return cachedCallKeep;
71
- }
72
- try {
73
- // The JS package being installed does NOT mean the native side is
74
- // linked: a host can `npm i react-native-callkeep` and skip
75
- // `pod install`, or (very common in this repo) node_modules can carry
76
- // it over from another branch whose build did link it. In that state
77
- // requiring the package builds a `new NativeEventEmitter(null)` at
78
- // module scope, which throws an invariant that RN surfaces as an
79
- // uncaught redbox on every render of the call overlay — the whole app
80
- // becomes unusable over a feature the host never switched on.
81
- // Probing NativeModules first keeps the optional dependency genuinely
82
- // optional: no native module => behave exactly as if it isn't installed.
83
- // eslint-disable-next-line @typescript-eslint/no-require-imports
84
- const { NativeModules } = require('react-native');
85
- if (!NativeModules?.RNCallKeep) {
86
- cachedCallKeep = null;
87
- return cachedCallKeep;
88
- }
89
- // eslint-disable-next-line @typescript-eslint/no-require-imports
90
- const mod = require('react-native-callkeep');
91
- cachedCallKeep = (mod?.default || mod) as CallKeepModule;
92
- } catch {
93
- cachedCallKeep = null;
94
- }
95
- return cachedCallKeep;
96
- };
97
-
98
- // CallKeep identifies calls by UUID. Our callIds aren't UUIDs, so derive a
99
- // stable v4-shaped string from the callId: same call always maps to the
100
- // same UUID, which is what endCall() needs to dismiss the right screen.
101
- const toUuid = (seed: string): string => {
102
- let hash = 0x811c9dc5;
103
- const bytes: number[] = [];
104
- for (let i = 0; i < 32; i++) {
105
- const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
106
- hash ^= ch;
107
- hash = Math.imul(hash, 0x01000193) >>> 0;
108
- bytes.push(hash & 0xff);
109
- }
110
- const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
111
- return [
112
- hex.slice(0, 8),
113
- hex.slice(8, 12),
114
- '4' + hex.slice(13, 16),
115
- ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
116
- hex.slice(20, 32),
117
- ].join('-');
118
- };
43
+ export { loadCallKeep };
119
44
 
120
45
  const trace = (...args: unknown[]) => {
121
46
  if (__DEV__) {console.log('[calls] CallKit:', ...args);}
@@ -192,7 +117,25 @@ export const useCallKeep = (): void => {
192
117
  useEffect(() => {
193
118
  if (!callKeep) {return;}
194
119
 
195
- const onAnswer = () => {
120
+ const isOtherCall = (payload: any): boolean => {
121
+ const uuid = String(payload?.callUUID || '').toLowerCase();
122
+ if (!uuid) {return false;}
123
+ const active = activeUuidRef.current?.toLowerCase() || null;
124
+ if (active === uuid) {return false;}
125
+ const current = store.getState().call;
126
+ const ringingUuid =
127
+ current.direction === 'incoming' && current.callId
128
+ ? callKitUuid(current.callId)
129
+ : null;
130
+ return ringingUuid !== uuid;
131
+ };
132
+
133
+ const onAnswer = (payload?: any) => {
134
+ if (isOtherCall(payload)) {
135
+ trace('answer before the call arrived', payload?.callUUID);
136
+ rememberSystemCallAction(String(payload.callUUID), 'answer');
137
+ return;
138
+ }
196
139
  answeredRef.current = true;
197
140
  // The user answered from the system screen, which on Android may
198
141
  // mean the app was not even in the foreground.
@@ -206,15 +149,19 @@ export const useCallKeep = (): void => {
206
149
  }
207
150
  };
208
151
 
209
- const onEnd = () => {
152
+ const onEnd = (payload?: any) => {
153
+ if (isOtherCall(payload)) {
154
+ trace('declined before the call arrived', payload?.callUUID);
155
+ rememberSystemCallAction(String(payload.callUUID), 'end');
156
+ return;
157
+ }
210
158
  const snapshot = store.getState().call;
211
159
  if (snapshot.phase === 'idle') {return;}
212
160
  if (
213
161
  snapshot.direction === 'incoming' &&
214
162
  snapshot.phase === 'ringing-incoming'
215
163
  ) {
216
- sendCallStateSignal('declined');
217
- dispatch(declineIncomingCall());
164
+ declineActiveCallWhenOnline();
218
165
  return;
219
166
  }
220
167
  // Ended from the Dynamic Island, the lock screen or a headset.
@@ -262,16 +209,46 @@ export const useCallKeep = (): void => {
262
209
  callKeep.addEventListener('didResetProvider', onReset);
263
210
  // The system's verdict on displayIncomingCall ("Unentitled" means the
264
211
  // app lacks the voip background mode).
265
- const onDisplayed = ({ error, errorCode }: { error?: string; errorCode?: string }) => {
266
- trace('incoming call shown', error ? `failed: ${errorCode || ''} ${error}` : 'ok');
212
+ const onDisplayed = ({
213
+ error,
214
+ errorCode,
215
+ fromPushKit,
216
+ }: {
217
+ error?: string;
218
+ errorCode?: string;
219
+ fromPushKit?: string;
220
+ }) => {
221
+ trace(
222
+ `incoming call shown${fromPushKit === '1' ? ' (PushKit)' : ''}`,
223
+ error ? `failed: ${errorCode || ''} ${error}` : 'ok'
224
+ );
225
+ if (errorCode === 'CallUUIDAlreadyExists') {return;}
267
226
  // CallKit refused it (Do Not Disturb, unentitled...): bring the
268
- // in-app ring card back so the call can still be answered.
269
- if (error) {setCallSystemRinging(false);}
227
+ if (error && fromPushKit !== '1') {setCallSystemRinging(false);}
270
228
  };
271
229
  callKeep.addEventListener('didDisplayIncomingCall', onDisplayed);
272
230
  callKeep.addEventListener('didDeactivateAudioSession', onAudioDeactivated);
273
231
 
232
+ const replay: Record<string, (data: any) => void> = {
233
+ RNCallKeepPerformAnswerCallAction: onAnswer,
234
+ RNCallKeepPerformEndCallAction: onEnd,
235
+ RNCallKeepDidDisplayIncomingCall: onDisplayed,
236
+ RNCallKeepDidActivateAudioSession: onAudioActivated,
237
+ RNCallKeepDidDeactivateAudioSession: onAudioDeactivated,
238
+ RNCallKeepDidPerformSetMutedCallAction: onMuted,
239
+ };
240
+ const onLoadWithEvents = (events: { name: string; data: any }[]) => {
241
+ if (!Array.isArray(events) || events.length === 0) {return;}
242
+ trace('replaying', events.map((e) => e.name).join(', '));
243
+ callKeep.clearInitialEvents?.();
244
+ for (const event of events) {
245
+ replay[event.name]?.(event.data);
246
+ }
247
+ };
248
+ callKeep.addEventListener('didLoadWithEvents', onLoadWithEvents);
249
+
274
250
  return () => {
251
+ callKeep.removeEventListener('didLoadWithEvents');
275
252
  callKeep.removeEventListener('answerCall');
276
253
  callKeep.removeEventListener('endCall');
277
254
  callKeep.removeEventListener('didPerformSetMutedCallAction');
@@ -313,11 +290,23 @@ export const useCallKeep = (): void => {
313
290
 
314
291
  if (isRinging) {
315
292
  if (activeUuidRef.current) {return;}
316
- const uuid = toUuid(call.callId || call.roomJid || String(Date.now()));
293
+ const uuid = callKitUuid(call.callId || call.roomJid || String(Date.now()));
317
294
  activeUuidRef.current = uuid;
318
295
  answeredRef.current = false;
296
+ const earlier = takeSystemCallAction(uuid);
297
+ if (earlier === 'answer') {
298
+ trace('answered earlier on the system screen', uuid);
299
+ answeredRef.current = true;
300
+ dispatch(acceptIncomingCall());
301
+ return;
302
+ }
303
+ if (earlier === 'end') {
304
+ trace('declined earlier on the system screen', uuid);
305
+ activeUuidRef.current = null;
306
+ declineActiveCallWhenOnline();
307
+ return;
308
+ }
319
309
  // iOS: the system banner is the ring UI (Telegram-style); the in-app
320
- // card would only cover the screen under it.
321
310
  if (isIOS) {setCallSystemRinging(true);}
322
311
  try {
323
312
  callKeep.displayIncomingCall(
@@ -339,7 +328,7 @@ export const useCallKeep = (): void => {
339
328
  if (isIOS && isOutgoing && !activeUuidRef.current && callKeep.startCall) {
340
329
  // Unique per call: CallKit refuses a UUID it has seen before, and
341
330
  // the callId only arrives with the token.
342
- const uuid = toUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
331
+ const uuid = callKitUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
343
332
  activeUuidRef.current = uuid;
344
333
  outgoingReportedRef.current = 'none';
345
334
  trace('startCall', uuid);
@@ -382,6 +371,7 @@ export const useCallKeep = (): void => {
382
371
  }, [
383
372
  callKeep,
384
373
  ready,
374
+ dispatch,
385
375
  isIOS,
386
376
  isRinging,
387
377
  isLive,
@@ -1,33 +1,61 @@
1
1
  import { store } from '../roomStore';
2
- import { setIncomingCallToken } from '../roomStore/callSlice';
2
+ import { acceptIncomingCall, setIncomingCallToken } from '../roomStore/callSlice';
3
+ import { endSystemIncomingCall } from '../components/VideoCalls/systemCall';
4
+ import { declineActiveCallWhenOnline } from '../components/VideoCalls/callActions';
3
5
  import { isCallPush, parseCallPush } from './callPush';
6
+ import { normalizeRoomJid } from './normalizeRoomJid';
4
7
 
5
- /**
6
- * Turn an incoming-call push into a ringing call.
7
- *
8
- * On mobile the XMPP socket dies whenever the OS backgrounds or kills the
9
- * app, and the `call-token` stanza that normally opens the ring screen is
10
- * lost with it. The backend also delivers the call as a data push, so this
11
- * is the path every call takes when the app was not in the foreground.
12
- *
13
- * Returns true when the payload WAS a call push (whether or not it could
14
- * be rung), so the caller knows to stop and not also show it as a chat
15
- * notification. A user should never get a "new message: call-state" toast.
16
- */
17
- export const handleCallPush = (
18
- data: Record<string, any> | null | undefined
19
- ): boolean => {
20
- if (!isCallPush(data)) {
21
- return false;
22
- }
8
+ const PENDING_CALL_PUSH_TTL_MS = 60_000;
9
+
10
+
11
+ export type CallPushIntent = 'ring' | 'answer' | 'decline';
12
+
13
+ let pendingPush: { data: Record<string, any>; intent: CallPushIntent; at: number } | null = null;
14
+ let unwatch: (() => void) | null = null;
15
+
16
+ const trace = (...args: unknown[]) => {
17
+ if (__DEV__) {console.log('[calls] push:', ...args);}
18
+ };
19
+
20
+ const callsConfigured = (): boolean =>
21
+ store.getState().chatSettingStore.config?.videoCalls !== undefined;
22
+
23
+ const holdUntilConfigured = (data: Record<string, any>, intent: CallPushIntent): void => {
24
+ pendingPush = { data, intent, at: Date.now() };
25
+ if (unwatch) {return;}
26
+ unwatch = store.subscribe(() => {
27
+ if (!callsConfigured()) {return;}
28
+ unwatch?.();
29
+ unwatch = null;
30
+ const held = pendingPush;
31
+ pendingPush = null;
32
+ if (!held) {return;}
33
+ if (Date.now() - held.at > PENDING_CALL_PUSH_TTL_MS) {
34
+ trace('held call push expired');
35
+ endSystemIncomingCall(parseCallPush(held.data).callId);
36
+ return;
37
+ }
38
+ ringFromPush(held.data, held.intent);
39
+ });
40
+ };
23
41
 
42
+ const applyIntent = (intent: CallPushIntent): void => {
43
+ const call = store.getState().call;
44
+ if (call.direction !== 'incoming' || call.phase !== 'ringing-incoming') {return;}
45
+ if (intent === 'answer') {store.dispatch(acceptIncomingCall());}
46
+ if (intent === 'decline') {declineActiveCallWhenOnline();}
47
+ };
48
+
49
+ const ringFromPush = (data: Record<string, any>, intent: CallPushIntent): boolean => {
24
50
  const parsed = parseCallPush(data);
25
51
  const state = store.getState();
26
52
 
27
53
  // Calls off in this deployment: swallow the push rather than ringing a
28
54
  // screen the host never opted into.
29
55
  if (state.chatSettingStore.config?.videoCalls?.enabled !== true) {
30
- return true;
56
+ trace('calls are off, dropping', parsed.callId);
57
+ endSystemIncomingCall(parsed.callId);
58
+ return false;
31
59
  }
32
60
 
33
61
  // Already ringing or in this exact call (the push raced the stanza, or
@@ -38,9 +66,15 @@ export const handleCallPush = (
38
66
  const sameCall =
39
67
  (!!parsed.callId && parsed.callId === current.callId) ||
40
68
  (!!parsed.roomBareName && parsed.roomBareName === current.roomBareName);
41
- if (sameCall || current.phase === 'in-call') {
69
+ if (sameCall) {
70
+ applyIntent(intent);
42
71
  return true;
43
72
  }
73
+ if (current.phase === 'in-call') {
74
+ // Busy with another call: no second ring.
75
+ endSystemIncomingCall(parsed.callId);
76
+ return false;
77
+ }
44
78
  }
45
79
 
46
80
  // Without a LiveKit token there is nothing to accept: the push is only
@@ -48,7 +82,8 @@ export const handleCallPush = (
48
82
  // cannot work, so treat it as handled and let the stanza (once the
49
83
  // socket is back up) open the real ring screen.
50
84
  if (!parsed.token) {
51
- return true;
85
+ endSystemIncomingCall(parsed.callId);
86
+ return false;
52
87
  }
53
88
 
54
89
  // Resolve the full room JID from the local room map. The push carries
@@ -60,11 +95,16 @@ export const handleCallPush = (
60
95
  room?.jid === parsed.roomRef
61
96
  ) as { jid?: string; name?: string; title?: string } | undefined;
62
97
 
63
- const roomJid =
64
- resolved?.jid ||
65
- (parsed.roomRef && parsed.roomRef.includes('@') ? parsed.roomRef : '');
98
+ const conference =
99
+ state.chatSettingStore.config?.xmppSettings?.conference || '';
100
+ const fromRef = parsed.roomRef
101
+ ? normalizeRoomJid(parsed.roomRef, conference)
102
+ : '';
103
+ const roomJid = resolved?.jid || (fromRef.includes('@') ? fromRef : '');
66
104
  if (!roomJid) {
67
- return true;
105
+ trace('no room for call push', parsed.roomRef);
106
+ endSystemIncomingCall(parsed.callId);
107
+ return false;
68
108
  }
69
109
 
70
110
  store.dispatch(
@@ -78,6 +118,37 @@ export const handleCallPush = (
78
118
  peerXmppUsername: parsed.callerXmppUsername,
79
119
  })
80
120
  );
121
+ trace('ringing', parsed.callId, intent);
122
+ applyIntent(intent);
123
+ return true;
124
+ };
81
125
 
126
+ export const handleCallPush = (
127
+ data: Record<string, any> | null | undefined,
128
+ intent: CallPushIntent = 'ring'
129
+ ): boolean => {
130
+ if (!isCallPush(data)) {
131
+ return false;
132
+ }
133
+ if (!callsConfigured()) {
134
+ trace('holding call push until the chat is configured');
135
+ holdUntilConfigured(data as Record<string, any>, intent);
136
+ return true;
137
+ }
138
+ ringFromPush(data as Record<string, any>, intent);
82
139
  return true;
83
140
  };
141
+
142
+ export const answerCallFromPush = (
143
+ data: Record<string, any> | null | undefined
144
+ ): boolean => handleCallPush(data, 'answer');
145
+
146
+ export const declineCallFromPush = (
147
+ data: Record<string, any> | null | undefined
148
+ ): boolean => handleCallPush(data, 'decline');
149
+
150
+ export const __resetCallPushForTests = (): void => {
151
+ unwatch?.();
152
+ unwatch = null;
153
+ pendingPush = null;
154
+ };
package/src/main.ts CHANGED
@@ -33,6 +33,14 @@ export { XmppProvider } from './context/xmppProvider';
33
33
  export { useUnread } from './hooks/useUnreadMessagesCounter';
34
34
 
35
35
  export { handlePushPayload, openRoomFromPush } from './helpers/pushPayload';
36
+ export { callKitUuid } from './components/VideoCalls/systemCall';
37
+ export {
38
+ answerCallFromPush,
39
+ declineCallFromPush,
40
+ } from './helpers/incomingCallFromPush';
41
+ export type { CallPushIntent } from './helpers/incomingCallFromPush';
42
+ export { isCallPush, parseCallPush } from './helpers/callPush';
43
+ export type { ParsedCallPush } from './helpers/callPush';
36
44
  export type { PushPayloadOutcome } from './helpers/pushPayload';
37
45
  export {
38
46
  registerPushToken,
@@ -73,7 +81,7 @@ export {
73
81
  presentSealedPush,
74
82
  SEALED_PUSH_TASK,
75
83
  } from './push/sealedPushTask';
76
- export type { SealedPushTaskOptions } from './push/sealedPushTask';
84
+ export type { SealedPushTaskOptions, ShownMessagePush } from './push/sealedPushTask';
77
85
  export {
78
86
  openSealedPush,
79
87
  isSealedPush,