@ethora/chat-component-rn 26.10.3 → 26.10.5

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 (113) hide show
  1. package/lib/commonjs/components/Header/HeaderRoomList.js +72 -69
  2. package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
  3. package/lib/commonjs/components/MainComponents/ChatHeader.js +2 -1
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +248 -0
  6. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
  7. package/lib/commonjs/components/VideoCalls/CallIcons.js +41 -16
  8. package/lib/commonjs/components/VideoCalls/CallIcons.js.map +1 -1
  9. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +193 -37
  10. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  11. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +71 -109
  12. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  13. package/lib/commonjs/components/VideoCalls/callSessionController.js +74 -21
  14. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -1
  15. package/lib/commonjs/components/VideoCalls/callUiStore.js +120 -0
  16. package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
  17. package/lib/commonjs/components/VideoCalls/livekitRuntime.js +10 -1
  18. package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
  19. package/lib/commonjs/components/VideoCalls/useCallKeep.js +197 -28
  20. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  21. package/lib/commonjs/components/VideoCalls/useOngoingCallNotification.js +88 -0
  22. package/lib/commonjs/components/VideoCalls/useOngoingCallNotification.js.map +1 -0
  23. package/lib/commonjs/components/VideoCalls/usePipTile.js +232 -0
  24. package/lib/commonjs/components/VideoCalls/usePipTile.js.map +1 -0
  25. package/lib/commonjs/helpers/updateMessagesTillLast.js +3 -1
  26. package/lib/commonjs/helpers/updateMessagesTillLast.js.map +1 -1
  27. package/lib/commonjs/i18n/strings.js +54 -0
  28. package/lib/commonjs/i18n/strings.js.map +1 -1
  29. package/lib/commonjs/main.js.map +1 -1
  30. package/lib/commonjs/version.js +1 -1
  31. package/lib/module/components/Header/HeaderRoomList.js +73 -70
  32. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  33. package/lib/module/components/MainComponents/ChatHeader.js +2 -1
  34. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  35. package/lib/module/components/VideoCalls/ActiveCallBar.js +240 -0
  36. package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
  37. package/lib/module/components/VideoCalls/CallIcons.js +38 -15
  38. package/lib/module/components/VideoCalls/CallIcons.js.map +1 -1
  39. package/lib/module/components/VideoCalls/VideoCallOverlay.js +195 -39
  40. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  41. package/lib/module/components/VideoCalls/VideoCallSession.js +72 -111
  42. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  43. package/lib/module/components/VideoCalls/callSessionController.js +71 -19
  44. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -1
  45. package/lib/module/components/VideoCalls/callUiStore.js +111 -0
  46. package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
  47. package/lib/module/components/VideoCalls/livekitRuntime.js +10 -1
  48. package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
  49. package/lib/module/components/VideoCalls/useCallKeep.js +198 -29
  50. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  51. package/lib/module/components/VideoCalls/useOngoingCallNotification.js +84 -0
  52. package/lib/module/components/VideoCalls/useOngoingCallNotification.js.map +1 -0
  53. package/lib/module/components/VideoCalls/usePipTile.js +228 -0
  54. package/lib/module/components/VideoCalls/usePipTile.js.map +1 -0
  55. package/lib/module/helpers/updateMessagesTillLast.js +3 -1
  56. package/lib/module/helpers/updateMessagesTillLast.js.map +1 -1
  57. package/lib/module/i18n/strings.js +54 -0
  58. package/lib/module/i18n/strings.js.map +1 -1
  59. package/lib/module/main.js.map +1 -1
  60. package/lib/module/version.js +1 -1
  61. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  62. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  63. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +27 -0
  64. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
  65. package/lib/typescript/components/VideoCalls/CallIcons.d.ts +9 -0
  66. package/lib/typescript/components/VideoCalls/CallIcons.d.ts.map +1 -1
  67. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  68. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +7 -3
  69. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  70. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +9 -0
  71. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -1
  72. package/lib/typescript/components/VideoCalls/callUiStore.d.ts +37 -0
  73. package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
  74. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +7 -0
  75. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
  76. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +12 -0
  77. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  78. package/lib/typescript/components/VideoCalls/useOngoingCallNotification.d.ts +10 -0
  79. package/lib/typescript/components/VideoCalls/useOngoingCallNotification.d.ts.map +1 -0
  80. package/lib/typescript/components/VideoCalls/usePipTile.d.ts +46 -0
  81. package/lib/typescript/components/VideoCalls/usePipTile.d.ts.map +1 -0
  82. package/lib/typescript/helpers/updateMessagesTillLast.d.ts.map +1 -1
  83. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  84. package/lib/typescript/main.d.ts +1 -1
  85. package/lib/typescript/main.d.ts.map +1 -1
  86. package/lib/typescript/types/types.d.ts +40 -0
  87. package/lib/typescript/types/types.d.ts.map +1 -1
  88. package/lib/typescript/version.d.ts +1 -1
  89. package/package.json +1 -1
  90. package/src/components/Header/HeaderRoomList.tsx +66 -61
  91. package/src/components/MainComponents/ChatHeader.tsx +3 -0
  92. package/src/components/VideoCalls/ActiveCallBar.tsx +222 -0
  93. package/src/components/VideoCalls/CallIcons.tsx +36 -12
  94. package/src/components/VideoCalls/VideoCallOverlay.tsx +211 -37
  95. package/src/components/VideoCalls/VideoCallSession.tsx +84 -93
  96. package/src/components/VideoCalls/callSessionController.ts +73 -24
  97. package/src/components/VideoCalls/callUiStore.ts +106 -0
  98. package/src/components/VideoCalls/livekitRuntime.ts +16 -0
  99. package/src/components/VideoCalls/useCallKeep.ts +204 -33
  100. package/src/components/VideoCalls/useOngoingCallNotification.ts +113 -0
  101. package/src/components/VideoCalls/usePipTile.ts +214 -0
  102. package/src/helpers/updateMessagesTillLast.tsx +2 -1
  103. package/src/i18n/strings.ts +54 -0
  104. package/src/main.ts +3 -0
  105. package/src/types/types.ts +48 -0
  106. package/src/version.ts +1 -1
  107. package/lib/commonjs/components/VideoCalls/useDraggable.js +0 -59
  108. package/lib/commonjs/components/VideoCalls/useDraggable.js.map +0 -1
  109. package/lib/module/components/VideoCalls/useDraggable.js +0 -55
  110. package/lib/module/components/VideoCalls/useDraggable.js.map +0 -1
  111. package/lib/typescript/components/VideoCalls/useDraggable.d.ts +0 -19
  112. package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +0 -1
  113. package/src/components/VideoCalls/useDraggable.ts +0 -46
@@ -31,6 +31,13 @@ export interface CallSessionSnapshot {
31
31
  micOn: boolean;
32
32
  cameraOn: boolean;
33
33
  speakerOn: boolean;
34
+ /** Which camera the local video comes from; only the front one is mirrored. */
35
+ facingMode: 'user' | 'environment';
36
+ /**
37
+ * Bumped whenever the local camera track is re-acquired, so the self view
38
+ * can remount onto the new stream instead of holding the stopped one.
39
+ */
40
+ localVideoEpoch: number;
34
41
  /** `{ participant, publication, source }` for `<VideoTrack />`, live and unmuted only. */
35
42
  remoteVideo: any;
36
43
  localVideo: any;
@@ -53,6 +60,8 @@ const IDLE: CallSessionSnapshot = {
53
60
  micOn: true,
54
61
  cameraOn: false,
55
62
  speakerOn: false,
63
+ facingMode: 'user',
64
+ localVideoEpoch: 0,
56
65
  remoteVideo: null,
57
66
  localVideo: null,
58
67
  remoteName: null,
@@ -224,12 +233,24 @@ export const startCallSession = ({
224
233
  }
225
234
 
226
235
  ensureLiveKitGlobals(runtime);
227
- const { Room, RoomEvent, AudioSession } = runtime;
228
-
229
- // adaptiveStream + dynacast keep mobile data and battery sane: the SDK
230
- // drops resolution for a small tile and stops publishing layers nobody
231
- // subscribes to. Both are safe defaults for 1:1.
232
- const room = new Room({ adaptiveStream: true, dynacast: true });
236
+ const { Room, RoomEvent, AudioSession, VideoPresets } = runtime;
237
+
238
+ // The web session's options, verbatim. `pixelDensity: 'screen'` matters:
239
+ // with a plain `adaptiveStream: true` the subscriber asks for a layer
240
+ // sized to the view in POINTS, i.e. 2-3x below the phone's real pixels,
241
+ // and the peer's video arrived visibly blurry next to web.
242
+ const room = new Room({
243
+ adaptiveStream: { pixelDensity: 'screen' },
244
+ dynacast: true,
245
+ ...(VideoPresets
246
+ ? {
247
+ publishDefaults: {
248
+ videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
249
+ },
250
+ videoCaptureDefaults: { resolution: VideoPresets.h720.resolution },
251
+ }
252
+ : {}),
253
+ });
233
254
  const current: Session = { key, room, kind, disposed: false, everJoined: false };
234
255
  session = current;
235
256
  snapshot = {
@@ -284,7 +305,14 @@ export const startCallSession = ({
284
305
  // Claims audio focus and sets the right native audio mode. Must
285
306
  // start BEFORE connecting or the first seconds route to the earpiece
286
307
  // on Android even for a video call.
287
- await AudioSession.startAudioSession();
308
+ try {
309
+ await AudioSession.startAudioSession();
310
+ } catch {
311
+ // A call answered from the CallKit banner: CallKit owns the iOS
312
+ // audio session and activates it itself (useCallKeep forwards that
313
+ // to WebRTC), so our own activation is refused. Not a reason to
314
+ // fail the call.
315
+ }
288
316
  await room.connect(livekitUrl, token);
289
317
  } catch (error: any) {
290
318
  if (current.disposed) {return;}
@@ -335,10 +363,12 @@ export const startCallSession = ({
335
363
 
336
364
  // ---- controls -----------------------------------------------------------
337
365
 
338
- export const toggleCallMic = async (): Promise<void> => {
366
+ export const toggleCallMic = (): Promise<void> => setCallMicEnabled(!snapshot.micOn);
367
+
368
+ /** Set the mic to a given state (system call UI reports absolute values). */
369
+ export const setCallMicEnabled = async (next: boolean): Promise<void> => {
339
370
  const current = session;
340
- if (!current) {return;}
341
- const next = !snapshot.micOn;
371
+ if (!current || snapshot.micOn === next) {return;}
342
372
  emit({ micOn: next });
343
373
  try {
344
374
  await current.room.localParticipant.setMicrophoneEnabled(next);
@@ -364,26 +394,45 @@ export const toggleCallCamera = async (): Promise<void> => {
364
394
  }
365
395
  };
366
396
 
397
+ let switchingCamera = false;
398
+
367
399
  export const switchCallCamera = async (): Promise<void> => {
368
400
  const current = session;
369
401
  const runtime = loadLiveKit();
370
- if (!current || !runtime) {return;}
402
+ if (!current || !runtime || switchingCamera) {return;}
403
+ const track = current.room.localParticipant.getTrackPublication?.(
404
+ runtime.Track.Source.Camera
405
+ )?.track;
406
+ if (!track?.restartTrack) {return;}
407
+ const next = snapshot.facingMode === 'user' ? 'environment' : 'user';
408
+ switchingCamera = true;
371
409
  try {
372
- const publication = current.room.localParticipant.getTrackPublication?.(
373
- runtime.Track.Source.Camera
374
- );
375
- const track = publication?.track;
376
- // `restartTrack` with the flipped facingMode is the portable way to
377
- // switch cameras; the RN SDK's `_switchCamera` helper is internal.
378
- await track?.restartTrack?.({
379
- facingMode:
380
- track?.mediaStreamTrack?.getSettings?.().facingMode === 'environment'
381
- ? 'user'
382
- : 'environment',
410
+ // A real device change needs a new capture: react-native-webrtc's
411
+ // native applyConstraints only applies width/height/frameRate and
412
+ // ignores facingMode on both platforms, so the old approach kept the
413
+ // front camera and merely flipped our mirror flag (the picture looked
414
+ // swapped left to right). restartTrack re-acquires the camera with
415
+ // the new facing mode and swaps it into the published sender; the
416
+ // <VideoTrack /> view follows it through TrackEvent.Restarted.
417
+ await track.restartTrack({
418
+ facingMode: next,
419
+ ...(runtime.VideoPresets
420
+ ? { resolution: runtime.VideoPresets.h720.resolution }
421
+ : {}),
422
+ });
423
+ if (current.disposed) {return;}
424
+ // Trust what the camera reports: a device with a single camera stays
425
+ // on it, and the mirror must follow the real camera, not the request.
426
+ const actual = track.mediaStreamTrack?.getSettings?.()?.facingMode;
427
+ emit({
428
+ facingMode: actual === 'user' || actual === 'environment' ? actual : next,
429
+ localVideoEpoch: snapshot.localVideoEpoch + 1,
383
430
  });
384
431
  resync();
385
- } catch {
386
- // Single camera, or the track is mid-restart.
432
+ } catch (error) {
433
+ if (!current.disposed) {emit({ deviceError: humanizeDeviceError(error) });}
434
+ } finally {
435
+ switchingCamera = false;
387
436
  }
388
437
  };
389
438
 
@@ -0,0 +1,106 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { store } from '../../roomStore';
3
+
4
+ /**
5
+ * UI state of the call shared between the overlay and the header bar:
6
+ * whether the call is minimized, and which header (if any) currently hosts
7
+ * the minimized bar.
8
+ *
9
+ * Minimized, the call is a strip under the visible header (ChatHeader or
10
+ * HeaderRoomList), Telegram-style, instead of a floating panel. Each header
11
+ * registers itself while mounted; the most recently mounted one shows the
12
+ * strip (on an iPad split view both are mounted, and only one strip should
13
+ * appear). With no header mounted (the user is on a host-app screen) the
14
+ * overlay docks the same strip to the top of the screen.
15
+ */
16
+
17
+ interface CallUiState {
18
+ minimized: boolean;
19
+ /**
20
+ * iOS shows this incoming call in its own CallKit UI (banner / full
21
+ * screen), so the in-app ring card stays hidden instead of doubling it.
22
+ */
23
+ systemRinging: boolean;
24
+ /** Id of the header that shows the strip, or null when none is mounted. */
25
+ barHost: number | null;
26
+ /**
27
+ * Window y of the top of the strip slot under the hosting header, so the
28
+ * collapse animation can land exactly on the strip. Null: no header.
29
+ */
30
+ barAnchorY: number | null;
31
+ }
32
+
33
+ let state: CallUiState = {
34
+ minimized: false,
35
+ systemRinging: false,
36
+ barHost: null,
37
+ barAnchorY: null,
38
+ };
39
+ const hosts: number[] = [];
40
+ let nextHostId = 1;
41
+ const listeners = new Set<() => void>();
42
+ let storeUnsubscribe: (() => void) | null = null;
43
+
44
+ const emit = (patch: Partial<CallUiState>) => {
45
+ state = { ...state, ...patch };
46
+ listeners.forEach((listener) => listener());
47
+ };
48
+
49
+ // The next call always opens full-screen.
50
+ const ensureStoreWatcher = () => {
51
+ if (storeUnsubscribe) {return;}
52
+ storeUnsubscribe = store.subscribe(() => {
53
+ const phase = store.getState().call.phase;
54
+ if (state.minimized && phase === 'idle') {
55
+ emit({ minimized: false });
56
+ }
57
+ if (state.systemRinging && phase !== 'ringing-incoming') {
58
+ emit({ systemRinging: false });
59
+ }
60
+ });
61
+ };
62
+
63
+ const subscribe = (listener: () => void) => {
64
+ ensureStoreWatcher();
65
+ listeners.add(listener);
66
+ return () => {
67
+ listeners.delete(listener);
68
+ };
69
+ };
70
+
71
+ const getSnapshot = () => state;
72
+
73
+ /** Current state, for non-React code and tests. */
74
+ export const getCallUiState = (): CallUiState => state;
75
+
76
+ export const useCallUi = (): CallUiState =>
77
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
78
+
79
+ export const setCallMinimized = (minimized: boolean): void => {
80
+ if (state.minimized !== minimized) {emit({ minimized });}
81
+ };
82
+
83
+ export const setCallSystemRinging = (systemRinging: boolean): void => {
84
+ if (state.systemRinging !== systemRinging) {emit({ systemRinging });}
85
+ };
86
+
87
+ export const registerCallBarHost = (
88
+ onId?: (id: number) => void
89
+ ): (() => void) => {
90
+ const id = nextHostId++;
91
+ hosts.push(id);
92
+ onId?.(id);
93
+ emit({ barHost: id });
94
+ return () => {
95
+ const index = hosts.indexOf(id);
96
+ if (index >= 0) {hosts.splice(index, 1);}
97
+ const next = hosts.length ? hosts[hosts.length - 1] : null;
98
+ emit({ barHost: next, barAnchorY: next === null ? null : state.barAnchorY });
99
+ };
100
+ };
101
+
102
+ /** The hosting header reports where its strip slot is on screen. */
103
+ export const setCallBarAnchor = (hostId: number, y: number): void => {
104
+ if (state.barHost !== hostId || state.barAnchorY === y) {return;}
105
+ emit({ barAnchorY: y });
106
+ };
@@ -26,12 +26,19 @@ export interface LiveKitRuntime {
26
26
  Track: any;
27
27
  /** livekit-client `ConnectionState` enum. */
28
28
  ConnectionState: any;
29
+ /** livekit-client `VideoPresets` (h540, h216...). */
30
+ VideoPresets: any;
29
31
  /** RN `<VideoTrack trackRef={...} />` renderer. */
30
32
  VideoTrack: any;
31
33
  /** Native audio session control (routing, speaker, focus). */
32
34
  AudioSession: any;
33
35
  /** Installs the WebRTC globals livekit-client expects. */
34
36
  registerGlobals: () => void;
37
+ /**
38
+ * `@livekit/react-native-webrtc` RTCAudioSession: tells WebRTC when
39
+ * CallKit activates/deactivates the iOS audio session.
40
+ */
41
+ RTCAudioSession: any;
35
42
  }
36
43
 
37
44
  let cached: LiveKitRuntime | null | undefined;
@@ -57,15 +64,24 @@ export const loadLiveKit = (): LiveKitRuntime | null => {
57
64
  // moving subset across versions, the core package does not.
58
65
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
66
  const core = require('livekit-client');
67
+ let webrtc: any = null;
68
+ try {
69
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
70
+ webrtc = require('@livekit/react-native-webrtc');
71
+ } catch {
72
+ // Only needed for CallKit; calls work without it.
73
+ }
60
74
 
61
75
  cached = {
62
76
  Room: core.Room,
63
77
  RoomEvent: core.RoomEvent,
64
78
  Track: core.Track,
65
79
  ConnectionState: core.ConnectionState,
80
+ VideoPresets: core.VideoPresets,
66
81
  VideoTrack: rn.VideoTrack,
67
82
  AudioSession: rn.AudioSession,
68
83
  registerGlobals: rn.registerGlobals,
84
+ RTCAudioSession: webrtc?.RTCAudioSession ?? null,
69
85
  };
70
86
  } catch {
71
87
  cached = null;
@@ -1,9 +1,13 @@
1
- import { useEffect, useMemo, useRef } from 'react';
1
+ 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
5
  import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
6
6
  import { sendCallStateSignal } from '../../networking/callTokenStanza';
7
+ import { terminateActiveCall } from './callActions';
8
+ import { setCallMicEnabled, useCallSession } from './callSessionController';
9
+ import { loadLiveKit } from './livekitRuntime';
10
+ import { setCallSystemRinging } from './callUiStore';
7
11
 
8
12
  /**
9
13
  * Native incoming-call UI (Android ConnectionService / iOS CallKit) via
@@ -14,6 +18,12 @@ import { sendCallStateSignal } from '../../networking/callTokenStanza';
14
18
  * loaded lazily exactly like the LiveKit packages, so hosts that don't
15
19
  * install it get the plain in-app ring screen and nothing breaks.
16
20
  *
21
+ * On iOS every call, incoming or outgoing, is also reported to CallKit for
22
+ * as long as it lasts. That is what puts it in the Dynamic Island / the
23
+ * green status-bar pill when the app is in the background, with the
24
+ * system's own mute and end buttons, and what keeps the call's audio
25
+ * running in the background.
26
+ *
17
27
  * Platform reality, stated plainly:
18
28
  * - Android: full value. A ConnectionService call shows the system
19
29
  * incoming-call screen over the lock screen.
@@ -35,6 +45,18 @@ interface CallKeepModule {
35
45
  ) => void;
36
46
  endCall: (uuid: string) => void;
37
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;
38
60
  addEventListener: (event: string, handler: (payload: any) => void) => void;
39
61
  removeEventListener: (event: string) => void;
40
62
  setAvailable?: (available: boolean) => void;
@@ -95,16 +117,32 @@ const toUuid = (seed: string): string => {
95
117
  ].join('-');
96
118
  };
97
119
 
120
+ const trace = (...args: unknown[]) => {
121
+ if (__DEV__) {console.log('[calls] CallKit:', ...args);}
122
+ };
123
+
98
124
  export const useCallKeep = (): void => {
99
125
  const dispatch = useDispatch();
100
126
  const call = useSelector((state: RootState) => state.call);
127
+ const session = useCallSession();
101
128
  const callKeep = useMemo(() => loadCallKeep(), []);
129
+ const [ready, setReady] = useState(false);
102
130
  const activeUuidRef = useRef<string | null>(null);
103
- const readyRef = useRef(false);
131
+ // The CallKit call has been answered (from the system screen or in-app).
132
+ const answeredRef = useRef(false);
133
+ const outgoingReportedRef = useRef<'none' | 'connecting' | 'connected'>('none');
134
+ const isIOS = Platform.OS === 'ios';
104
135
 
105
136
  // One-time native setup.
106
137
  useEffect(() => {
107
- if (!callKeep) {return;}
138
+ if (!callKeep) {
139
+ if (isIOS) {
140
+ // The usual reason for "no Dynamic Island": the JS package is there
141
+ // but the binary was built without the RNCallKeep pod.
142
+ trace('unavailable (react-native-callkeep is not linked in this build)');
143
+ }
144
+ return;
145
+ }
108
146
  let cancelled = false;
109
147
 
110
148
  (async () => {
@@ -113,6 +151,11 @@ export const useCallKeep = (): void => {
113
151
  ios: {
114
152
  appName: 'Ethora',
115
153
  supportsVideo: true,
154
+ maximumCallGroups: '1',
155
+ maximumCallsPerCallGroup: '1',
156
+ // Our calls are not phone calls: keep them out of the Phone
157
+ // app's Recents, whose entries would dial back into nowhere.
158
+ includesCallsInRecents: false,
116
159
  },
117
160
  android: {
118
161
  alertTitle: 'Permissions required',
@@ -130,28 +173,35 @@ export const useCallKeep = (): void => {
130
173
  });
131
174
  if (cancelled) {return;}
132
175
  callKeep.setAvailable?.(true);
133
- readyRef.current = true;
134
- } catch {
176
+ trace('ready');
177
+ setReady(true);
178
+ } catch (error) {
179
+ trace('setup failed', error);
135
180
  // Host didn't grant phone-account permission, or the module isn't
136
181
  // linked. The in-app ring screen still works, so stay quiet.
137
- readyRef.current = false;
182
+ setReady(false);
138
183
  }
139
184
  })();
140
185
 
141
186
  return () => {
142
187
  cancelled = true;
143
188
  };
144
- }, [callKeep]);
189
+ }, [callKeep, isIOS]);
145
190
 
146
- // Bridge native answer/end actions back into Redux.
191
+ // Bridge native actions back into the call.
147
192
  useEffect(() => {
148
193
  if (!callKeep) {return;}
149
194
 
150
195
  const onAnswer = () => {
196
+ answeredRef.current = true;
151
197
  // The user answered from the system screen, which on Android may
152
198
  // mean the app was not even in the foreground.
153
199
  callKeep.backToForeground?.();
154
- if (store.getState().call.direction === 'incoming') {
200
+ // Only from the ring: CallKit also reports the answer we make on its
201
+ // behalf after an in-app accept, and re-accepting then would drop an
202
+ // in-progress call back to 'connecting'.
203
+ const current = store.getState().call;
204
+ if (current.direction === 'incoming' && current.phase === 'ringing-incoming') {
155
205
  dispatch(acceptIncomingCall());
156
206
  }
157
207
  };
@@ -165,73 +215,198 @@ export const useCallKeep = (): void => {
165
215
  ) {
166
216
  sendCallStateSignal('declined');
167
217
  dispatch(declineIncomingCall());
218
+ return;
219
+ }
220
+ // Ended from the Dynamic Island, the lock screen or a headset.
221
+ if (snapshot.phase === 'error') {return;}
222
+ terminateActiveCall(snapshot.phase === 'requesting' ? 'cancelled' : 'ended');
223
+ };
224
+
225
+ // The system mute button (Dynamic Island, lock screen, CarPlay).
226
+ const onMuted = ({ muted }: { muted: boolean }) => {
227
+ void setCallMicEnabled(!muted);
228
+ };
229
+
230
+ // Another call (e.g. a phone call) put ours on hold: stop sending audio.
231
+ const onHold = ({ hold }: { hold: boolean }) => {
232
+ if (hold) {void setCallMicEnabled(false);}
233
+ };
234
+
235
+ // CallKit owns the iOS audio session: WebRTC may only start its audio
236
+ // unit once CallKit has activated the session.
237
+ const onAudioActivated = () => {
238
+ try {
239
+ loadLiveKit()?.RTCAudioSession?.audioSessionDidActivate?.();
240
+ } catch {
241
+ // Older webrtc builds manage the session on their own.
242
+ }
243
+ };
244
+ const onAudioDeactivated = () => {
245
+ try {
246
+ loadLiveKit()?.RTCAudioSession?.audioSessionDidDeactivate?.();
247
+ } catch {
248
+ // noop
168
249
  }
169
250
  };
170
251
 
171
252
  callKeep.addEventListener('answerCall', onAnswer);
172
253
  callKeep.addEventListener('endCall', onEnd);
254
+ callKeep.addEventListener('didPerformSetMutedCallAction', onMuted);
255
+ callKeep.addEventListener('didToggleHoldCallAction', onHold);
256
+ callKeep.addEventListener('didActivateAudioSession', onAudioActivated);
257
+ // CallKit rejected or dropped our calls (e.g. a build without the voip
258
+ // background mode): there is nothing to end any more.
259
+ const onReset = () => {
260
+ trace('provider reset');
261
+ };
262
+ callKeep.addEventListener('didResetProvider', onReset);
263
+ // The system's verdict on displayIncomingCall ("Unentitled" means the
264
+ // 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');
267
+ // 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);}
270
+ };
271
+ callKeep.addEventListener('didDisplayIncomingCall', onDisplayed);
272
+ callKeep.addEventListener('didDeactivateAudioSession', onAudioDeactivated);
173
273
 
174
274
  return () => {
175
275
  callKeep.removeEventListener('answerCall');
176
276
  callKeep.removeEventListener('endCall');
277
+ callKeep.removeEventListener('didPerformSetMutedCallAction');
278
+ callKeep.removeEventListener('didToggleHoldCallAction');
279
+ callKeep.removeEventListener('didActivateAudioSession');
280
+ callKeep.removeEventListener('didDeactivateAudioSession');
281
+ callKeep.removeEventListener('didResetProvider');
282
+ callKeep.removeEventListener('didDisplayIncomingCall');
177
283
  };
178
284
  }, [callKeep, dispatch]);
179
285
 
180
- // Show / dismiss the native screen as the call state changes.
286
+ const displayName = call.roomName || 'Ethora';
287
+ const isRinging =
288
+ call.direction === 'incoming' && call.phase === 'ringing-incoming';
289
+ const isLive = call.phase === 'connecting' || call.phase === 'in-call';
290
+ const isOutgoing = call.direction === 'outgoing';
291
+ const peerConnected = session.anchorAt !== null;
292
+
293
+ // Report the call to the system as its state changes.
181
294
  useEffect(() => {
182
- if (!callKeep || !readyRef.current) {return;}
295
+ if (!callKeep || !ready) {return;}
183
296
 
184
- const isRinging =
185
- call.direction === 'incoming' && call.phase === 'ringing-incoming';
297
+ const release = () => {
298
+ if (!activeUuidRef.current) {return;}
299
+ try {
300
+ callKeep.endCall(activeUuidRef.current);
301
+ } catch {
302
+ // Screen already gone.
303
+ }
304
+ activeUuidRef.current = null;
305
+ answeredRef.current = false;
306
+ outgoingReportedRef.current = 'none';
307
+ };
186
308
 
187
- if (isRinging && !activeUuidRef.current) {
309
+ if (call.phase === 'idle' || call.phase === 'error') {
310
+ release();
311
+ return;
312
+ }
313
+
314
+ if (isRinging) {
315
+ if (activeUuidRef.current) {return;}
188
316
  const uuid = toUuid(call.callId || call.roomJid || String(Date.now()));
189
317
  activeUuidRef.current = uuid;
318
+ answeredRef.current = false;
319
+ // iOS: the system banner is the ring UI (Telegram-style); the in-app
320
+ // card would only cover the screen under it.
321
+ if (isIOS) {setCallSystemRinging(true);}
190
322
  try {
191
323
  callKeep.displayIncomingCall(
192
324
  uuid,
193
- call.peerXmppUsername || call.roomName || 'Ethora',
194
- call.roomName || undefined,
325
+ displayName,
326
+ displayName,
195
327
  'generic',
196
328
  call.kind === 'video'
197
329
  );
198
330
  } catch {
199
331
  activeUuidRef.current = null;
332
+ setCallSystemRinging(false);
200
333
  }
201
334
  return;
202
335
  }
203
336
 
204
- // Any exit from ringing (answered in-app, declined, peer cancelled)
205
- // must tear the system screen down or it hangs around ringing.
206
- if (!isRinging && activeUuidRef.current && call.phase !== 'connecting' && call.phase !== 'in-call') {
337
+ // iOS: an outgoing call is a CallKit call from the first ring, so it
338
+ // shows in the Dynamic Island even before the peer answers.
339
+ if (isIOS && isOutgoing && !activeUuidRef.current && callKeep.startCall) {
340
+ // Unique per call: CallKit refuses a UUID it has seen before, and
341
+ // the callId only arrives with the token.
342
+ const uuid = toUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
343
+ activeUuidRef.current = uuid;
344
+ outgoingReportedRef.current = 'none';
345
+ trace('startCall', uuid);
207
346
  try {
208
- callKeep.endCall(activeUuidRef.current);
347
+ callKeep.startCall(uuid, displayName, displayName, 'generic', call.kind === 'video');
209
348
  } catch {
210
- // Screen already gone.
349
+ activeUuidRef.current = null;
211
350
  }
212
- activeUuidRef.current = null;
351
+ return;
352
+ }
353
+
354
+ const uuid = activeUuidRef.current;
355
+ if (!uuid) {return;}
356
+
357
+ if (isIOS && isOutgoing) {
358
+ if (peerConnected && outgoingReportedRef.current !== 'connected') {
359
+ outgoingReportedRef.current = 'connected';
360
+ callKeep.reportConnectedOutgoingCallWithUUID?.(uuid);
361
+ } else if (
362
+ isLive &&
363
+ !peerConnected &&
364
+ outgoingReportedRef.current === 'none'
365
+ ) {
366
+ outgoingReportedRef.current = 'connecting';
367
+ callKeep.reportConnectingOutgoingCallWithUUID?.(uuid);
368
+ }
369
+ return;
213
370
  }
214
371
 
215
- if (call.phase === 'idle' && activeUuidRef.current) {
372
+ // Incoming, accepted with the in-app button: tell CallKit, or its
373
+ // screen keeps ringing on top of the call.
374
+ if (isIOS && isLive && !answeredRef.current) {
375
+ answeredRef.current = true;
216
376
  try {
217
- callKeep.endCall(activeUuidRef.current);
377
+ callKeep.answerIncomingCall?.(uuid);
218
378
  } catch {
219
379
  // noop
220
380
  }
221
- activeUuidRef.current = null;
222
381
  }
223
382
  }, [
224
383
  callKeep,
225
- call.direction,
384
+ ready,
385
+ isIOS,
386
+ isRinging,
387
+ isLive,
388
+ isOutgoing,
389
+ peerConnected,
390
+ displayName,
226
391
  call.phase,
227
392
  call.callId,
228
393
  call.roomJid,
229
- call.roomName,
230
- call.peerXmppUsername,
394
+ call.startedAt,
231
395
  call.kind,
232
396
  ]);
233
397
 
234
- // Nothing to clean up on unmount beyond the active screen: this hook
398
+ // Keep the system mute button in step with ours.
399
+ useEffect(() => {
400
+ const uuid = activeUuidRef.current;
401
+ if (!callKeep || !ready || !isIOS || !uuid || !isLive) {return;}
402
+ try {
403
+ callKeep.setMutedCall?.(uuid, !session.micOn);
404
+ } catch {
405
+ // noop
406
+ }
407
+ }, [callKeep, ready, isIOS, isLive, session.micOn]);
408
+
409
+ // Nothing to clean up on unmount beyond the active call: this hook
235
410
  // lives for the lifetime of the provider.
236
411
  useEffect(
237
412
  () => () => {
@@ -245,8 +420,4 @@ export const useCallKeep = (): void => {
245
420
  },
246
421
  [callKeep]
247
422
  );
248
-
249
- // iOS-only note kept as a runtime no-op so the import isn't unused when
250
- // the platform check is tree-shaken in release builds.
251
- void Platform.OS;
252
423
  };