@ethora/chat-component-rn 26.10.7 → 26.10.8

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 (88) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/commonjs/components/MessageBubble/Message.js +16 -6
  3. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  4. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
  5. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  6. package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
  7. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
  8. package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
  9. package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
  10. package/lib/commonjs/components/VideoCalls/useCallKeep.js +83 -66
  11. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  12. package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
  13. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  14. package/lib/commonjs/main.js +33 -0
  15. package/lib/commonjs/main.js.map +1 -1
  16. package/lib/commonjs/networking/callTokenStanza.js +6 -0
  17. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  18. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
  19. package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  20. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  21. package/lib/commonjs/push/sealedPush.js +13 -2
  22. package/lib/commonjs/push/sealedPush.js.map +1 -1
  23. package/lib/commonjs/push/sealedPushTask.js +43 -17
  24. package/lib/commonjs/push/sealedPushTask.js.map +1 -1
  25. package/lib/commonjs/version.js +1 -1
  26. package/lib/module/components/MessageBubble/Message.js +16 -6
  27. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  28. package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
  29. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  30. package/lib/module/components/VideoCalls/callActions.js +29 -0
  31. package/lib/module/components/VideoCalls/callActions.js.map +1 -1
  32. package/lib/module/components/VideoCalls/systemCall.js +73 -0
  33. package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
  34. package/lib/module/components/VideoCalls/useCallKeep.js +77 -64
  35. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  36. package/lib/module/helpers/incomingCallFromPush.js +92 -22
  37. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  38. package/lib/module/main.js +3 -0
  39. package/lib/module/main.js.map +1 -1
  40. package/lib/module/networking/callTokenStanza.js +6 -0
  41. package/lib/module/networking/callTokenStanza.js.map +1 -1
  42. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
  43. package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
  44. package/lib/module/networking/xmppClient.js.map +1 -1
  45. package/lib/module/push/sealedPush.js +11 -1
  46. package/lib/module/push/sealedPush.js.map +1 -1
  47. package/lib/module/push/sealedPushTask.js +42 -17
  48. package/lib/module/push/sealedPushTask.js.map +1 -1
  49. package/lib/module/version.js +1 -1
  50. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  51. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  52. package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
  53. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
  54. package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
  55. package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
  56. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
  57. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  58. package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
  59. package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
  60. package/lib/typescript/main.d.ts +5 -0
  61. package/lib/typescript/main.d.ts.map +1 -1
  62. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  63. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
  64. package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
  65. package/lib/typescript/networking/xmppClient.d.ts +2 -4
  66. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  67. package/lib/typescript/push/sealedPush.d.ts +6 -0
  68. package/lib/typescript/push/sealedPush.d.ts.map +1 -1
  69. package/lib/typescript/push/sealedPushTask.d.ts +18 -7
  70. package/lib/typescript/push/sealedPushTask.d.ts.map +1 -1
  71. package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
  72. package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
  73. package/lib/typescript/version.d.ts +1 -1
  74. package/package.json +1 -1
  75. package/src/components/MessageBubble/Message.tsx +16 -5
  76. package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
  77. package/src/components/VideoCalls/callActions.ts +28 -0
  78. package/src/components/VideoCalls/systemCall.ts +109 -0
  79. package/src/components/VideoCalls/useCallKeep.ts +89 -97
  80. package/src/helpers/incomingCallFromPush.ts +97 -26
  81. package/src/main.ts +8 -0
  82. package/src/networking/callTokenStanza.ts +10 -0
  83. package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
  84. package/src/networking/xmppClient.ts +2 -2
  85. package/src/push/sealedPush.ts +11 -0
  86. package/src/push/sealedPushTask.ts +61 -15
  87. package/src/types/models/xmpp.model.ts +2 -1
  88. package/src/version.ts +1 -1
@@ -4,10 +4,16 @@ import { useDispatch, useSelector } from 'react-redux';
4
4
  import { RootState, store } from '../../roomStore';
5
5
  import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
6
6
  import { sendCallStateSignal } from '../../networking/callTokenStanza';
7
- import { terminateActiveCall } from './callActions';
7
+ import { declineActiveCallWhenOnline, terminateActiveCall } from './callActions';
8
8
  import { setCallMicEnabled, useCallSession } from './callSessionController';
9
9
  import { loadLiveKit } from './livekitRuntime';
10
10
  import { setCallSystemRinging } from './callUiStore';
11
+ import {
12
+ callKitUuid,
13
+ loadCallKeep,
14
+ rememberSystemCallAction,
15
+ takeSystemCallAction,
16
+ } from './systemCall';
11
17
 
12
18
  /**
13
19
  * Native incoming-call UI (Android ConnectionService / iOS CallKit) via
@@ -27,95 +33,15 @@ import { setCallSystemRinging } from './callUiStore';
27
33
  * Platform reality, stated plainly:
28
34
  * - Android: full value. A ConnectionService call shows the system
29
35
  * 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.
36
+ * - iOS: this hook only runs while `<Chat>` is mounted. A CallKit screen
37
+ * from a killed app comes from the host: its PushKit handler reports
38
+ * the VoIP push to CallKit natively under `callKitUuid(callId)` and
39
+ * hands the payload to `handlePushPayload`. This hook then adopts that
40
+ * call (same UUID, CallKit answers "CallUUIDAlreadyExists" to our own
41
+ * report) and replays what the user did on it before JS was up.
35
42
  */
36
43
 
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
- };
44
+ export { loadCallKeep };
119
45
 
120
46
  const trace = (...args: unknown[]) => {
121
47
  if (__DEV__) {console.log('[calls] CallKit:', ...args);}
@@ -192,7 +118,25 @@ export const useCallKeep = (): void => {
192
118
  useEffect(() => {
193
119
  if (!callKeep) {return;}
194
120
 
195
- const onAnswer = () => {
121
+ const isOtherCall = (payload: any): boolean => {
122
+ const uuid = String(payload?.callUUID || '').toLowerCase();
123
+ if (!uuid) {return false;}
124
+ const active = activeUuidRef.current?.toLowerCase() || null;
125
+ if (active === uuid) {return false;}
126
+ const current = store.getState().call;
127
+ const ringingUuid =
128
+ current.direction === 'incoming' && current.callId
129
+ ? callKitUuid(current.callId)
130
+ : null;
131
+ return ringingUuid !== uuid;
132
+ };
133
+
134
+ const onAnswer = (payload?: any) => {
135
+ if (isOtherCall(payload)) {
136
+ trace('answer before the call arrived', payload?.callUUID);
137
+ rememberSystemCallAction(String(payload.callUUID), 'answer');
138
+ return;
139
+ }
196
140
  answeredRef.current = true;
197
141
  // The user answered from the system screen, which on Android may
198
142
  // mean the app was not even in the foreground.
@@ -206,7 +150,12 @@ export const useCallKeep = (): void => {
206
150
  }
207
151
  };
208
152
 
209
- const onEnd = () => {
153
+ const onEnd = (payload?: any) => {
154
+ if (isOtherCall(payload)) {
155
+ trace('declined before the call arrived', payload?.callUUID);
156
+ rememberSystemCallAction(String(payload.callUUID), 'end');
157
+ return;
158
+ }
210
159
  const snapshot = store.getState().call;
211
160
  if (snapshot.phase === 'idle') {return;}
212
161
  if (
@@ -262,16 +211,46 @@ export const useCallKeep = (): void => {
262
211
  callKeep.addEventListener('didResetProvider', onReset);
263
212
  // The system's verdict on displayIncomingCall ("Unentitled" means the
264
213
  // 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');
214
+ const onDisplayed = ({
215
+ error,
216
+ errorCode,
217
+ fromPushKit,
218
+ }: {
219
+ error?: string;
220
+ errorCode?: string;
221
+ fromPushKit?: string;
222
+ }) => {
223
+ trace(
224
+ `incoming call shown${fromPushKit === '1' ? ' (PushKit)' : ''}`,
225
+ error ? `failed: ${errorCode || ''} ${error}` : 'ok'
226
+ );
227
+ if (errorCode === 'CallUUIDAlreadyExists') {return;}
267
228
  // 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);}
229
+ if (error && fromPushKit !== '1') {setCallSystemRinging(false);}
270
230
  };
271
231
  callKeep.addEventListener('didDisplayIncomingCall', onDisplayed);
272
232
  callKeep.addEventListener('didDeactivateAudioSession', onAudioDeactivated);
273
233
 
234
+ const replay: Record<string, (data: any) => void> = {
235
+ RNCallKeepPerformAnswerCallAction: onAnswer,
236
+ RNCallKeepPerformEndCallAction: onEnd,
237
+ RNCallKeepDidDisplayIncomingCall: onDisplayed,
238
+ RNCallKeepDidActivateAudioSession: onAudioActivated,
239
+ RNCallKeepDidDeactivateAudioSession: onAudioDeactivated,
240
+ RNCallKeepDidPerformSetMutedCallAction: onMuted,
241
+ };
242
+ const onLoadWithEvents = (events: { name: string; data: any }[]) => {
243
+ if (!Array.isArray(events) || events.length === 0) {return;}
244
+ trace('replaying', events.map((e) => e.name).join(', '));
245
+ callKeep.clearInitialEvents?.();
246
+ for (const event of events) {
247
+ replay[event.name]?.(event.data);
248
+ }
249
+ };
250
+ callKeep.addEventListener('didLoadWithEvents', onLoadWithEvents);
251
+
274
252
  return () => {
253
+ callKeep.removeEventListener('didLoadWithEvents');
275
254
  callKeep.removeEventListener('answerCall');
276
255
  callKeep.removeEventListener('endCall');
277
256
  callKeep.removeEventListener('didPerformSetMutedCallAction');
@@ -313,11 +292,23 @@ export const useCallKeep = (): void => {
313
292
 
314
293
  if (isRinging) {
315
294
  if (activeUuidRef.current) {return;}
316
- const uuid = toUuid(call.callId || call.roomJid || String(Date.now()));
295
+ const uuid = callKitUuid(call.callId || call.roomJid || String(Date.now()));
317
296
  activeUuidRef.current = uuid;
318
297
  answeredRef.current = false;
298
+ const earlier = takeSystemCallAction(uuid);
299
+ if (earlier === 'answer') {
300
+ trace('answered earlier on the system screen', uuid);
301
+ answeredRef.current = true;
302
+ dispatch(acceptIncomingCall());
303
+ return;
304
+ }
305
+ if (earlier === 'end') {
306
+ trace('declined earlier on the system screen', uuid);
307
+ activeUuidRef.current = null;
308
+ declineActiveCallWhenOnline();
309
+ return;
310
+ }
319
311
  // iOS: the system banner is the ring UI (Telegram-style); the in-app
320
- // card would only cover the screen under it.
321
312
  if (isIOS) {setCallSystemRinging(true);}
322
313
  try {
323
314
  callKeep.displayIncomingCall(
@@ -339,7 +330,7 @@ export const useCallKeep = (): void => {
339
330
  if (isIOS && isOutgoing && !activeUuidRef.current && callKeep.startCall) {
340
331
  // Unique per call: CallKit refuses a UUID it has seen before, and
341
332
  // the callId only arrives with the token.
342
- const uuid = toUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
333
+ const uuid = callKitUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
343
334
  activeUuidRef.current = uuid;
344
335
  outgoingReportedRef.current = 'none';
345
336
  trace('startCall', uuid);
@@ -382,6 +373,7 @@ export const useCallKeep = (): void => {
382
373
  }, [
383
374
  callKeep,
384
375
  ready,
376
+ dispatch,
385
377
  isIOS,
386
378
  isRinging,
387
379
  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,
@@ -528,6 +528,16 @@ export const onCallTokenMessage = (stanza: Element): boolean => {
528
528
  return true;
529
529
  }
530
530
 
531
+ if (
532
+ callState.direction === 'incoming' &&
533
+ callState.phase !== 'idle' &&
534
+ !!callId &&
535
+ callState.callId === callId
536
+ ) {
537
+ callTrace('drop call-token: call already known from push', { callId });
538
+ return true;
539
+ }
540
+
531
541
  store.dispatch(
532
542
  setIncomingCallToken({
533
543
  roomJid,
@@ -1,18 +1,51 @@
1
1
  import { Client, xml } from '@xmpp/client';
2
+ import { shouldEncrypt } from '../../e2ee/send';
3
+
4
+ export interface ReactionStanzaData {
5
+ firstName?: string;
6
+ lastName?: string;
7
+ photoURL?: string;
8
+ push?: boolean;
9
+ messageAuthor?: string;
10
+ messageText?: string;
11
+ isMediafile?: string;
12
+ mimetype?: string;
13
+ fileName?: string;
14
+ }
15
+
16
+ export const REACTION_MESSAGE_TEXT_MAX = 120;
2
17
 
3
18
  export function sendMessageReaction(
4
19
  client: Client,
5
20
  messageId: string,
6
21
  roomJid: string,
7
22
  reactionsList: string[],
8
- data: any,
9
- reactionSymbol?: any,
23
+ data: ReactionStanzaData = {}
10
24
  ) {
11
25
  const id = `message-reaction:${Date.now().toString()}`;
12
26
 
13
- const dataReaction = {
14
- senderFirstName: data.firstName,
15
- senderLastName: data.lastName,
27
+ const push = Boolean(data.push) && reactionsList.length > 0;
28
+ const messageText =
29
+ push && !shouldEncrypt(roomJid)
30
+ ? String(data.messageText ?? '').slice(0, REACTION_MESSAGE_TEXT_MAX)
31
+ : '';
32
+
33
+ const dataReaction: Record<string, string> = {
34
+ senderFirstName: data.firstName ?? '',
35
+ senderLastName: data.lastName ?? '',
36
+ senderJID: client.jid?.toString() ?? '',
37
+ roomJid,
38
+ ...(data.photoURL ? { photoURL: data.photoURL } : {}),
39
+ ...(push
40
+ ? {
41
+ push: 'true',
42
+ messageAuthor: data.messageAuthor ?? '',
43
+ messageText,
44
+ isMediafile: data.isMediafile ?? '',
45
+ mimetype: data.mimetype ?? '',
46
+ fileName: data.fileName ?? '',
47
+ }
48
+ : {}),
16
49
  };
17
50
 
18
51
  const message = xml(
@@ -31,7 +64,6 @@ export function sendMessageReaction(
31
64
  xmlns: 'urn:xmpp:reactions:0',
32
65
  },
33
66
  ...reactionsList.map((reaction) => xml('reaction', {}, reaction ?? ''))
34
- // xml('reaction', {}, reactionSymbol ?? '👋'),
35
67
  ),
36
68
  xml('data', dataReaction),
37
69
  xml('store', { xmlns: 'urn:xmpp:hints' })
@@ -16,7 +16,7 @@ import {
16
16
  HistoryPageCursor,
17
17
  } from './xmpp/getHistory.xmpp';
18
18
  import { sendTextMessage } from './xmpp/sendTextMessage.xmpp';
19
- import { sendMessageReaction } from './xmpp/sendMessageReaction.xmpp';
19
+ import { sendMessageReaction, type ReactionStanzaData } from './xmpp/sendMessageReaction.xmpp';
20
20
  import { sendReceivedReceipt } from './xmpp/sendReceivedReceipt.xmpp';
21
21
  import { sendDisplayedMarker } from './xmpp/sendDisplayedMarker.xmpp';
22
22
  import { sendTextMessageWithTranslateTag } from './xmpp/sendTextMessageWithTranslateTag.xmpp';
@@ -1646,7 +1646,7 @@ export class XmppClient {
1646
1646
  messageId: string,
1647
1647
  roomJid: string,
1648
1648
  reactionsList: string[],
1649
- data?: { firstName?: string; lastName?: string }
1649
+ data?: ReactionStanzaData
1650
1650
  ) {
1651
1651
  sendMessageReaction(
1652
1652
  this.client,
@@ -20,6 +20,8 @@ export interface SealedPushEnvelope {
20
20
  msgID: string;
21
21
  /** Recipient. */
22
22
  userJid: string;
23
+ /** 'reaction' for a push about an emoji put on the user's message; absent for a message. */
24
+ type?: string;
23
25
  /** Base64 of the sealed box; absent when the service could not seal. */
24
26
  enc?: string;
25
27
  }
@@ -35,8 +37,14 @@ export interface SealedPushContent {
35
37
  msgID: string;
36
38
  /** Sender avatar URL, '' when none. */
37
39
  image: string;
40
+ /** 'reaction' with the emoji in `reaction`; absent for a message. */
41
+ type?: string;
42
+ reaction?: string;
38
43
  }
39
44
 
45
+ export const isReactionPush = (envelope: Pick<SealedPushEnvelope, 'type'>): boolean =>
46
+ envelope.type === 'reaction';
47
+
40
48
  export type SealedPushFailure =
41
49
  /** No key on this device (never generated, or the store is not readable). */
42
50
  | 'no-key'
@@ -68,6 +76,7 @@ export const sealedPushEnvelopeOf = (
68
76
  jid: str(data.jid),
69
77
  msgID: str(data.msgID),
70
78
  userJid: str(data.userJid),
79
+ ...(str(data.type) ? { type: str(data.type) } : {}),
71
80
  ...(enc ? { enc } : {}),
72
81
  };
73
82
  };
@@ -88,6 +97,8 @@ const parseContent = (plaintext: Uint8Array): SealedPushContent | null => {
88
97
  chatJid: str(parsed.chatJid),
89
98
  msgID: str(parsed.msgID),
90
99
  image: str(parsed.image),
100
+ ...(str(parsed.type) ? { type: str(parsed.type) } : {}),
101
+ ...(str(parsed.reaction) ? { reaction: str(parsed.reaction) } : {}),
91
102
  };
92
103
  } catch {
93
104
  return null;