@ethora/chat-component-rn 26.10.2 → 26.10.4

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 (166) 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 +13 -11
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/MessageContainer.js +10 -2
  6. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/RoomList.js +8 -18
  8. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  9. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +17 -14
  10. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  11. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +131 -17
  12. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  13. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js +6 -1
  14. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js.map +1 -1
  15. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +203 -0
  16. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
  17. package/lib/commonjs/components/VideoCalls/CallButtons.js +3 -1
  18. package/lib/commonjs/components/VideoCalls/CallButtons.js.map +1 -1
  19. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +123 -129
  20. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  21. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +212 -414
  22. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  23. package/lib/commonjs/components/VideoCalls/callActions.js +61 -0
  24. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -0
  25. package/lib/commonjs/components/VideoCalls/callSessionController.js +461 -0
  26. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -0
  27. package/lib/commonjs/components/VideoCalls/callUiStore.js +91 -0
  28. package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
  29. package/lib/commonjs/components/VideoCalls/livekitRuntime.js +1 -0
  30. package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
  31. package/lib/commonjs/components/VideoCalls/useDraggable.js +59 -0
  32. package/lib/commonjs/components/VideoCalls/useDraggable.js.map +1 -0
  33. package/lib/commonjs/helpers/createRoomFromApi.js +4 -0
  34. package/lib/commonjs/helpers/createRoomFromApi.js.map +1 -1
  35. package/lib/commonjs/helpers/incomingCallFromPush.js +1 -1
  36. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  37. package/lib/commonjs/helpers/roomUserCount.js +7 -1
  38. package/lib/commonjs/helpers/roomUserCount.js.map +1 -1
  39. package/lib/commonjs/helpers/uniqueMembers.js +36 -0
  40. package/lib/commonjs/helpers/uniqueMembers.js.map +1 -0
  41. package/lib/commonjs/hooks/useLeaveRoom.js +52 -0
  42. package/lib/commonjs/hooks/useLeaveRoom.js.map +1 -0
  43. package/lib/commonjs/i18n/strings.js +24 -0
  44. package/lib/commonjs/i18n/strings.js.map +1 -1
  45. package/lib/commonjs/networking/api-requests/rooms.api.js +36 -1
  46. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  47. package/lib/commonjs/networking/callTokenStanza.js +47 -3
  48. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  49. package/lib/commonjs/networking/xmppClient.js +9 -2
  50. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  51. package/lib/commonjs/version.js +1 -1
  52. package/lib/module/components/Header/HeaderRoomList.js +73 -70
  53. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  54. package/lib/module/components/MainComponents/ChatHeader.js +15 -13
  55. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  56. package/lib/module/components/MainComponents/MessageContainer.js +10 -2
  57. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  58. package/lib/module/components/MainComponents/RoomList.js +8 -18
  59. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  60. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +18 -15
  61. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  62. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +132 -18
  63. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  64. package/lib/module/components/RoomComponents/ChatRoomItem.js +6 -1
  65. package/lib/module/components/RoomComponents/ChatRoomItem.js.map +1 -1
  66. package/lib/module/components/VideoCalls/ActiveCallBar.js +195 -0
  67. package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
  68. package/lib/module/components/VideoCalls/CallButtons.js +3 -1
  69. package/lib/module/components/VideoCalls/CallButtons.js.map +1 -1
  70. package/lib/module/components/VideoCalls/VideoCallOverlay.js +125 -131
  71. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  72. package/lib/module/components/VideoCalls/VideoCallSession.js +213 -417
  73. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  74. package/lib/module/components/VideoCalls/callActions.js +55 -0
  75. package/lib/module/components/VideoCalls/callActions.js.map +1 -0
  76. package/lib/module/components/VideoCalls/callSessionController.js +450 -0
  77. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -0
  78. package/lib/module/components/VideoCalls/callUiStore.js +84 -0
  79. package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
  80. package/lib/module/components/VideoCalls/livekitRuntime.js +1 -0
  81. package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
  82. package/lib/module/components/VideoCalls/useDraggable.js +55 -0
  83. package/lib/module/components/VideoCalls/useDraggable.js.map +1 -0
  84. package/lib/module/helpers/createRoomFromApi.js +4 -0
  85. package/lib/module/helpers/createRoomFromApi.js.map +1 -1
  86. package/lib/module/helpers/incomingCallFromPush.js +1 -1
  87. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  88. package/lib/module/helpers/roomUserCount.js +7 -1
  89. package/lib/module/helpers/roomUserCount.js.map +1 -1
  90. package/lib/module/helpers/uniqueMembers.js +31 -0
  91. package/lib/module/helpers/uniqueMembers.js.map +1 -0
  92. package/lib/module/hooks/useLeaveRoom.js +48 -0
  93. package/lib/module/hooks/useLeaveRoom.js.map +1 -0
  94. package/lib/module/i18n/strings.js +24 -0
  95. package/lib/module/i18n/strings.js.map +1 -1
  96. package/lib/module/networking/api-requests/rooms.api.js +35 -1
  97. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  98. package/lib/module/networking/callTokenStanza.js +47 -3
  99. package/lib/module/networking/callTokenStanza.js.map +1 -1
  100. package/lib/module/networking/xmppClient.js +8 -1
  101. package/lib/module/networking/xmppClient.js.map +1 -1
  102. package/lib/module/version.js +1 -1
  103. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  104. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  105. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  106. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  107. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  108. package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
  109. package/lib/typescript/components/RoomComponents/ChatRoomItem.d.ts.map +1 -1
  110. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +21 -0
  111. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
  112. package/lib/typescript/components/VideoCalls/CallButtons.d.ts.map +1 -1
  113. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts +0 -6
  114. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  115. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +15 -22
  116. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  117. package/lib/typescript/components/VideoCalls/callActions.d.ts +15 -0
  118. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -0
  119. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +63 -0
  120. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -0
  121. package/lib/typescript/components/VideoCalls/callUiStore.d.ts +24 -0
  122. package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
  123. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +2 -0
  124. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
  125. package/lib/typescript/components/VideoCalls/useDraggable.d.ts +19 -0
  126. package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +1 -0
  127. package/lib/typescript/helpers/createRoomFromApi.d.ts.map +1 -1
  128. package/lib/typescript/helpers/roomUserCount.d.ts +1 -0
  129. package/lib/typescript/helpers/roomUserCount.d.ts.map +1 -1
  130. package/lib/typescript/helpers/uniqueMembers.d.ts +11 -0
  131. package/lib/typescript/helpers/uniqueMembers.d.ts.map +1 -0
  132. package/lib/typescript/hooks/useLeaveRoom.d.ts +9 -0
  133. package/lib/typescript/hooks/useLeaveRoom.d.ts.map +1 -0
  134. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  135. package/lib/typescript/networking/api-requests/rooms.api.d.ts +8 -0
  136. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  137. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  138. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  139. package/lib/typescript/version.d.ts +1 -1
  140. package/package.json +1 -1
  141. package/src/components/Header/HeaderRoomList.tsx +66 -61
  142. package/src/components/MainComponents/ChatHeader.tsx +13 -8
  143. package/src/components/MainComponents/MessageContainer.tsx +14 -1
  144. package/src/components/MainComponents/RoomList.tsx +8 -14
  145. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +16 -13
  146. package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +167 -36
  147. package/src/components/RoomComponents/ChatRoomItem.tsx +8 -1
  148. package/src/components/VideoCalls/ActiveCallBar.tsx +176 -0
  149. package/src/components/VideoCalls/CallButtons.tsx +5 -1
  150. package/src/components/VideoCalls/VideoCallOverlay.tsx +153 -183
  151. package/src/components/VideoCalls/VideoCallSession.tsx +227 -439
  152. package/src/components/VideoCalls/callActions.ts +63 -0
  153. package/src/components/VideoCalls/callSessionController.ts +439 -0
  154. package/src/components/VideoCalls/callUiStore.ts +76 -0
  155. package/src/components/VideoCalls/livekitRuntime.ts +3 -0
  156. package/src/components/VideoCalls/useDraggable.ts +46 -0
  157. package/src/helpers/createRoomFromApi.tsx +2 -0
  158. package/src/helpers/incomingCallFromPush.ts +2 -2
  159. package/src/helpers/roomUserCount.ts +6 -2
  160. package/src/helpers/uniqueMembers.ts +26 -0
  161. package/src/hooks/useLeaveRoom.ts +42 -0
  162. package/src/i18n/strings.ts +24 -0
  163. package/src/networking/api-requests/rooms.api.ts +28 -1
  164. package/src/networking/callTokenStanza.ts +46 -6
  165. package/src/networking/xmppClient.ts +10 -1
  166. package/src/version.ts +1 -1
@@ -0,0 +1,63 @@
1
+ import { store } from '../../roomStore';
2
+ import { declineIncomingCall, endCall } from '../../roomStore/callSlice';
3
+ import { addRoomMessage } from '../../roomStore/roomsSlice';
4
+ import { buildLocalCallLogMessage } from '../../helpers/callLogMessage';
5
+ import {
6
+ CallSignalState,
7
+ sendCallStateSignal,
8
+ } from '../../networking/callTokenStanza';
9
+ import { translateKey } from '../../i18n/strings';
10
+
11
+ /**
12
+ * Call teardown that doesn't need a mounted component.
13
+ *
14
+ * It used to live inside VideoCallOverlay, which meant only a rendered
15
+ * overlay could end a call. The LiveKit session now outlives the UI
16
+ * (callSessionController.ts), so the session's own "peer left" and
17
+ * "disconnected" events need the same teardown, with the same local log
18
+ * entry and the same signal to the peer.
19
+ */
20
+ export const terminateActiveCall = (state: CallSignalState): void => {
21
+ // Write a local "call ended" log entry BEFORE endCall() wipes the call
22
+ // slice. The authoritative entry is a server call-state broadcast, but
23
+ // that isn't guaranteed to arrive, so we synthesize our own. It's
24
+ // deduped against any server copy by callId in addRoomMessage.
25
+ const snap = store.getState().call;
26
+ if (snap.phase === 'idle') {return;}
27
+ if (snap.roomJid && (snap.callId || snap.connectedAt)) {
28
+ const durationMs = snap.connectedAt
29
+ ? Math.max(0, Date.now() - snap.connectedAt)
30
+ : 0;
31
+ store.dispatch(
32
+ addRoomMessage({
33
+ roomJID: snap.roomJid,
34
+ message: buildLocalCallLogMessage({
35
+ callId: snap.callId || '',
36
+ direction: snap.direction === 'incoming' ? 'incoming' : 'outgoing',
37
+ durationMs,
38
+ kind: snap.kind === 'audio' ? 'audio' : 'video',
39
+ selfXmppUsername:
40
+ store.getState().chatSettingStore.user?.xmppUsername || '',
41
+ }),
42
+ })
43
+ );
44
+ }
45
+ sendCallStateSignal(state);
46
+ store.dispatch(endCall());
47
+ };
48
+
49
+ export const declineActiveCall = (): void => {
50
+ sendCallStateSignal('declined');
51
+ store.dispatch(declineIncomingCall());
52
+ };
53
+
54
+ /** `t` for code outside React, resolved by the same rule as useT. */
55
+ export const translateCallString = (
56
+ key: string,
57
+ vars?: Record<string, string | number>
58
+ ): string => {
59
+ const settings = store.getState().chatSettingStore as any;
60
+ const locale =
61
+ settings?.uiLocale || settings?.config?.i18n?.locale || settings?.langSource;
62
+ return translateKey(key, locale, settings?.config?.i18n?.strings, vars);
63
+ };
@@ -0,0 +1,439 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import { store } from '../../roomStore';
4
+ import { setCallError, setCallPhase } from '../../roomStore/callSlice';
5
+ import { ensureLiveKitGlobals, loadLiveKit } from './livekitRuntime';
6
+ import { terminateActiveCall, translateCallString } from './callActions';
7
+
8
+ /**
9
+ * The LiveKit connection of the current call, owned outside React.
10
+ *
11
+ * It used to live in VideoCallSession's effect, so the connection was as
12
+ * long-lived as that component instance. Minimizing swapped the Modal for a
13
+ * floating View, React remounted the session, the effect cleanup
14
+ * disconnected, and the peer saw us leave (and hung up). The web SDK avoids
15
+ * that by keeping one LiveKitRoom element mounted across both layouts; the
16
+ * RN equivalent is to not tie the room to a component at all.
17
+ *
18
+ * Lifetime is driven by the call slice: a session exists while the call is
19
+ * 'connecting' or 'in-call' with a token, and is torn down by a store
20
+ * subscription the moment that stops being true (hangup, remote end,
21
+ * logout's resetCall), whether or not any call UI is mounted. Components
22
+ * only read the snapshot and call the controls.
23
+ */
24
+
25
+ export interface CallSessionSnapshot {
26
+ /** A LiveKit room exists for the current call. */
27
+ active: boolean;
28
+ connected: boolean;
29
+ /** Translated, dismissible camera/mic problem. Never fails the call. */
30
+ deviceError: string | null;
31
+ micOn: boolean;
32
+ cameraOn: boolean;
33
+ speakerOn: boolean;
34
+ /** Which camera the local video comes from; only the front one is mirrored. */
35
+ facingMode: 'user' | 'environment';
36
+ /** `{ participant, publication, source }` for `<VideoTrack />`, live and unmuted only. */
37
+ remoteVideo: any;
38
+ localVideo: any;
39
+ remoteName: string | null;
40
+ remoteJoined: boolean;
41
+ /** Peer joined but has no live microphone. */
42
+ remoteMicMuted: boolean;
43
+ /**
44
+ * When the call truly connected: the later of the two LiveKit `joinedAt`
45
+ * stamps. The server sets them, so both sides count from the same moment
46
+ * (web's useSharedCallElapsed). Null until the peer is in.
47
+ */
48
+ anchorAt: number | null;
49
+ }
50
+
51
+ const IDLE: CallSessionSnapshot = {
52
+ active: false,
53
+ connected: false,
54
+ deviceError: null,
55
+ micOn: true,
56
+ cameraOn: false,
57
+ speakerOn: false,
58
+ facingMode: 'user',
59
+ remoteVideo: null,
60
+ localVideo: null,
61
+ remoteName: null,
62
+ remoteJoined: false,
63
+ remoteMicMuted: false,
64
+ anchorAt: null,
65
+ };
66
+
67
+ interface Session {
68
+ key: string;
69
+ room: any;
70
+ kind: 'audio' | 'video';
71
+ disposed: boolean;
72
+ everJoined: boolean;
73
+ }
74
+
75
+ let session: Session | null = null;
76
+ let snapshot: CallSessionSnapshot = IDLE;
77
+ const listeners = new Set<() => void>();
78
+ let storeUnsubscribe: (() => void) | null = null;
79
+
80
+ const emit = (patch: Partial<CallSessionSnapshot>) => {
81
+ snapshot = { ...snapshot, ...patch };
82
+ listeners.forEach((listener) => listener());
83
+ };
84
+
85
+ const subscribe = (listener: () => void) => {
86
+ listeners.add(listener);
87
+ return () => {
88
+ listeners.delete(listener);
89
+ };
90
+ };
91
+
92
+ const getSnapshot = () => snapshot;
93
+
94
+ export const useCallSession = (): CallSessionSnapshot =>
95
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
96
+
97
+ const sessionKey = (livekitUrl: string, token: string) => `${livekitUrl}|${token}`;
98
+
99
+ // A device (camera/mic) permission/availability problem during enable,
100
+ // distinct from a connect failure. Mirrors the web SDK's humanizeDeviceError.
101
+ const humanizeDeviceError = (error: unknown): string => {
102
+ const name = (error as { name?: string })?.name || '';
103
+ const text = String((error as Error)?.message || '').toLowerCase();
104
+ if (name === 'NotAllowedError' || text.includes('permission') || text.includes('denied')) {
105
+ return translateCallString('call.error.deviceBlocked');
106
+ }
107
+ if (name === 'NotFoundError' || text.includes('not found')) {
108
+ return translateCallString('call.error.deviceNotFound');
109
+ }
110
+ if (name === 'NotReadableError' || text.includes('in use')) {
111
+ return translateCallString('call.error.deviceInUse');
112
+ }
113
+ return translateCallString('call.error.deviceGeneric');
114
+ };
115
+
116
+ // Only a live, unmuted track gets a video surface, so the stage never
117
+ // flips between an avatar and a black frame (web's hasLiveVideo).
118
+ const liveVideoRef = (participant: any, Track: any) => {
119
+ if (!participant) {return null;}
120
+ const publication = participant.getTrackPublication?.(Track.Source.Camera);
121
+ if (!publication?.track || publication.isMuted) {return null;}
122
+ return { participant, publication, source: Track.Source.Camera };
123
+ };
124
+
125
+ const joinedAtMs = (participant: any): number => {
126
+ const value = participant?.joinedAt;
127
+ const ms = value ? new Date(value).getTime() : 0;
128
+ return Number.isFinite(ms) ? ms : 0;
129
+ };
130
+
131
+ const resync = () => {
132
+ const current = session;
133
+ const runtime = loadLiveKit();
134
+ if (!current || current.disposed || !runtime) {return;}
135
+ const { Track } = runtime;
136
+ const room = current.room;
137
+
138
+ const remote = Array.from(room.remoteParticipants?.values?.() || [])[0] as any;
139
+ if (remote) {current.everJoined = true;}
140
+
141
+ const remoteMic = remote?.getTrackPublication?.(Track.Source.Microphone);
142
+ const localJoined = joinedAtMs(room.localParticipant);
143
+ const remoteJoinedAt = joinedAtMs(remote);
144
+
145
+ emit({
146
+ remoteJoined: !!remote,
147
+ remoteName: remote?.name || remote?.identity || null,
148
+ remoteVideo: liveVideoRef(remote, Track),
149
+ localVideo: liveVideoRef(room.localParticipant, Track),
150
+ remoteMicMuted: !!remote && (!remoteMic || remoteMic.isMuted !== false),
151
+ anchorAt:
152
+ remote && localJoined && remoteJoinedAt
153
+ ? Math.max(localJoined, remoteJoinedAt)
154
+ : remote
155
+ ? snapshot.anchorAt ?? Date.now()
156
+ : null,
157
+ });
158
+ };
159
+
160
+ export const stopCallSession = (): void => {
161
+ const current = session;
162
+ if (!current) {return;}
163
+ current.disposed = true;
164
+ session = null;
165
+ try {
166
+ current.room.removeAllListeners?.();
167
+ void current.room.disconnect();
168
+ } catch {
169
+ // Already gone.
170
+ }
171
+ try {
172
+ void loadLiveKit()?.AudioSession?.stopAudioSession();
173
+ } catch {
174
+ // Never let teardown throw.
175
+ }
176
+ snapshot = IDLE;
177
+ listeners.forEach((listener) => listener());
178
+ };
179
+
180
+ // Tear the session down as soon as the call slice says there is no call to
181
+ // carry it any more, independent of what UI happens to be mounted.
182
+ const ensureStoreWatcher = () => {
183
+ if (storeUnsubscribe) {return;}
184
+ storeUnsubscribe = store.subscribe(() => {
185
+ if (!session) {return;}
186
+ const call = store.getState().call;
187
+ const live =
188
+ !!call.token && (call.phase === 'connecting' || call.phase === 'in-call');
189
+ if (!live || !session.key.endsWith(`|${call.token}`)) {
190
+ stopCallSession();
191
+ }
192
+ });
193
+ };
194
+
195
+ export interface StartCallSessionOptions {
196
+ token: string;
197
+ livekitUrl: string;
198
+ kind: 'audio' | 'video';
199
+ startWithMicOn?: boolean;
200
+ startWithCameraOn?: boolean;
201
+ }
202
+
203
+ /**
204
+ * Join the LiveKit room for the current call. Idempotent per (url, token):
205
+ * calling it again from a remounted overlay re-attaches to the same room.
206
+ */
207
+ export const startCallSession = ({
208
+ token,
209
+ livekitUrl,
210
+ kind,
211
+ startWithMicOn = true,
212
+ startWithCameraOn = true,
213
+ }: StartCallSessionOptions): void => {
214
+ const key = sessionKey(livekitUrl, token);
215
+ if (session?.key === key) {return;}
216
+ stopCallSession();
217
+ ensureStoreWatcher();
218
+
219
+ const runtime = loadLiveKit();
220
+ if (!runtime) {
221
+ store.dispatch(setCallError(translateCallString('call.error.sdkMissing')));
222
+ return;
223
+ }
224
+ if (!token || !livekitUrl) {
225
+ store.dispatch(setCallError(translateCallString('call.error.missingConfig')));
226
+ return;
227
+ }
228
+
229
+ ensureLiveKitGlobals(runtime);
230
+ const { Room, RoomEvent, AudioSession, VideoPresets } = runtime;
231
+
232
+ // The web session's options, verbatim. `pixelDensity: 'screen'` matters:
233
+ // with a plain `adaptiveStream: true` the subscriber asks for a layer
234
+ // sized to the view in POINTS, i.e. 2-3x below the phone's real pixels,
235
+ // and the peer's video arrived visibly blurry next to web.
236
+ const room = new Room({
237
+ adaptiveStream: { pixelDensity: 'screen' },
238
+ dynacast: true,
239
+ ...(VideoPresets
240
+ ? {
241
+ publishDefaults: {
242
+ videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216],
243
+ },
244
+ videoCaptureDefaults: { resolution: VideoPresets.h720.resolution },
245
+ }
246
+ : {}),
247
+ });
248
+ const current: Session = { key, room, kind, disposed: false, everJoined: false };
249
+ session = current;
250
+ snapshot = {
251
+ ...IDLE,
252
+ active: true,
253
+ micOn: startWithMicOn,
254
+ cameraOn: kind === 'video' && startWithCameraOn,
255
+ // Video calls belong on the loudspeaker, audio calls on the earpiece.
256
+ speakerOn: kind === 'video',
257
+ };
258
+ listeners.forEach((listener) => listener());
259
+
260
+ const onChange = () => {
261
+ if (!current.disposed) {resync();}
262
+ };
263
+
264
+ room
265
+ .on(RoomEvent.TrackSubscribed, onChange)
266
+ .on(RoomEvent.TrackUnsubscribed, onChange)
267
+ .on(RoomEvent.TrackPublished, onChange)
268
+ .on(RoomEvent.TrackUnpublished, onChange)
269
+ .on(RoomEvent.LocalTrackPublished, onChange)
270
+ .on(RoomEvent.LocalTrackUnpublished, onChange)
271
+ .on(RoomEvent.TrackMuted, onChange)
272
+ .on(RoomEvent.TrackUnmuted, onChange)
273
+ .on(RoomEvent.ParticipantConnected, onChange)
274
+ .on(RoomEvent.MediaDevicesError, (error: unknown) => {
275
+ if (current.disposed) {return;}
276
+ emit({ deviceError: humanizeDeviceError(error) });
277
+ })
278
+ .on(RoomEvent.ParticipantDisconnected, () => {
279
+ if (current.disposed) {return;}
280
+ resync();
281
+ // 1:1 semantics, as web's PeerLeaveWatcher: once the peer had been
282
+ // in the room, their leaving IS the end of the call. Before they
283
+ // join (outgoing "Ringing...") an empty room is expected.
284
+ if (current.everJoined && (room.remoteParticipants?.size ?? 0) === 0) {
285
+ terminateActiveCall('ended');
286
+ }
287
+ })
288
+ .on(RoomEvent.Disconnected, () => {
289
+ if (current.disposed) {return;}
290
+ emit({ connected: false });
291
+ terminateActiveCall('ended');
292
+ });
293
+
294
+ (async () => {
295
+ // Joining the LiveKit room is the only step whose failure should fail
296
+ // the call (bad token, network, server down). Device setup below is
297
+ // best-effort.
298
+ try {
299
+ // Claims audio focus and sets the right native audio mode. Must
300
+ // start BEFORE connecting or the first seconds route to the earpiece
301
+ // on Android even for a video call.
302
+ await AudioSession.startAudioSession();
303
+ await room.connect(livekitUrl, token);
304
+ } catch (error: any) {
305
+ if (current.disposed) {return;}
306
+ stopCallSession();
307
+ store.dispatch(
308
+ setCallError(
309
+ String(error?.message || '') ||
310
+ translateCallString('call.error.connectFailed')
311
+ )
312
+ );
313
+ return;
314
+ }
315
+ if (current.disposed) {return;}
316
+
317
+ // Devices are best-effort: a blocked permission or the iOS Simulator
318
+ // (no camera at all) must not kill a call that connected fine.
319
+ try {
320
+ await room.localParticipant.setMicrophoneEnabled(startWithMicOn);
321
+ } catch (error) {
322
+ if (!current.disposed) {emit({ deviceError: humanizeDeviceError(error) });}
323
+ }
324
+ if (kind === 'video') {
325
+ try {
326
+ await room.localParticipant.setCameraEnabled(startWithCameraOn);
327
+ } catch (error) {
328
+ if (!current.disposed) {
329
+ emit({ cameraOn: false, deviceError: humanizeDeviceError(error) });
330
+ }
331
+ }
332
+ }
333
+ if (current.disposed) {return;}
334
+
335
+ try {
336
+ await AudioSession.setAppleAudioConfiguration?.({
337
+ audioCategory: 'playAndRecord',
338
+ audioCategoryOptions: kind === 'video' ? ['defaultToSpeaker'] : [],
339
+ audioMode: kind === 'video' ? 'videoChat' : 'voiceChat',
340
+ });
341
+ } catch {
342
+ // iOS-only helper.
343
+ }
344
+
345
+ emit({ connected: true });
346
+ resync();
347
+ store.dispatch(setCallPhase('in-call'));
348
+ })();
349
+ };
350
+
351
+ // ---- controls -----------------------------------------------------------
352
+
353
+ export const toggleCallMic = async (): Promise<void> => {
354
+ const current = session;
355
+ if (!current) {return;}
356
+ const next = !snapshot.micOn;
357
+ emit({ micOn: next });
358
+ try {
359
+ await current.room.localParticipant.setMicrophoneEnabled(next);
360
+ } catch (error) {
361
+ if (!current.disposed) {
362
+ emit({ micOn: !next, deviceError: humanizeDeviceError(error) });
363
+ }
364
+ }
365
+ };
366
+
367
+ export const toggleCallCamera = async (): Promise<void> => {
368
+ const current = session;
369
+ if (!current) {return;}
370
+ const next = !snapshot.cameraOn;
371
+ emit({ cameraOn: next });
372
+ try {
373
+ await current.room.localParticipant.setCameraEnabled(next);
374
+ resync();
375
+ } catch (error) {
376
+ if (!current.disposed) {
377
+ emit({ cameraOn: !next, deviceError: humanizeDeviceError(error) });
378
+ }
379
+ }
380
+ };
381
+
382
+ export const switchCallCamera = async (): Promise<void> => {
383
+ const current = session;
384
+ const runtime = loadLiveKit();
385
+ if (!current || !runtime) {return;}
386
+ const mediaTrack = current.room.localParticipant.getTrackPublication?.(
387
+ runtime.Track.Source.Camera
388
+ )?.track?.mediaStreamTrack;
389
+ if (!mediaTrack?.applyConstraints) {return;}
390
+ const next = snapshot.facingMode === 'user' ? 'environment' : 'user';
391
+ try {
392
+ // Swap the capture device inside the SAME track (react-native-webrtc's
393
+ // own camera switch). `restartTrack` stopped the published track and
394
+ // made a new one, and the native view kept showing the stopped stream:
395
+ // the picture froze on the switch.
396
+ // Keep the current capture settings (resolution, frame rate) and only
397
+ // flip the camera, exactly like the library's `_switchCamera`.
398
+ const constraints = { ...(mediaTrack.getSettings?.() || {}) };
399
+ delete constraints.deviceId;
400
+ await mediaTrack.applyConstraints({ ...constraints, facingMode: next });
401
+ if (!current.disposed) {emit({ facingMode: next });}
402
+ resync();
403
+ } catch {
404
+ // Single camera, or the switch is still in flight.
405
+ }
406
+ };
407
+
408
+ export const toggleCallSpeaker = async (): Promise<void> => {
409
+ const runtime = loadLiveKit();
410
+ if (!session || !runtime) {return;}
411
+ const next = !snapshot.speakerOn;
412
+ emit({ speakerOn: next });
413
+ try {
414
+ if (Platform.OS === 'android') {
415
+ // On Android `selectAudioOutput` device ids are the literal strings
416
+ // 'speaker' / 'earpiece'.
417
+ await runtime.AudioSession.selectAudioOutput(next ? 'speaker' : 'earpiece');
418
+ } else {
419
+ await runtime.AudioSession.setAppleAudioConfiguration?.({
420
+ audioCategory: 'playAndRecord',
421
+ audioCategoryOptions: next ? ['defaultToSpeaker'] : [],
422
+ audioMode: next ? 'videoChat' : 'voiceChat',
423
+ });
424
+ }
425
+ } catch {
426
+ emit({ speakerOn: !next });
427
+ }
428
+ };
429
+
430
+ export const dismissCallDeviceError = (): void => {
431
+ emit({ deviceError: null });
432
+ };
433
+
434
+ /** Reset module state. Test seam only. */
435
+ export const __resetCallSessionForTests = (): void => {
436
+ stopCallSession();
437
+ storeUnsubscribe?.();
438
+ storeUnsubscribe = null;
439
+ };
@@ -0,0 +1,76 @@
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
+ /** Id of the header that shows the strip, or null when none is mounted. */
20
+ barHost: number | null;
21
+ }
22
+
23
+ let state: CallUiState = { minimized: false, barHost: null };
24
+ const hosts: number[] = [];
25
+ let nextHostId = 1;
26
+ const listeners = new Set<() => void>();
27
+ let storeUnsubscribe: (() => void) | null = null;
28
+
29
+ const emit = (patch: Partial<CallUiState>) => {
30
+ state = { ...state, ...patch };
31
+ listeners.forEach((listener) => listener());
32
+ };
33
+
34
+ // The next call always opens full-screen.
35
+ const ensureStoreWatcher = () => {
36
+ if (storeUnsubscribe) {return;}
37
+ storeUnsubscribe = store.subscribe(() => {
38
+ if (state.minimized && store.getState().call.phase === 'idle') {
39
+ emit({ minimized: false });
40
+ }
41
+ });
42
+ };
43
+
44
+ const subscribe = (listener: () => void) => {
45
+ ensureStoreWatcher();
46
+ listeners.add(listener);
47
+ return () => {
48
+ listeners.delete(listener);
49
+ };
50
+ };
51
+
52
+ const getSnapshot = () => state;
53
+
54
+ /** Current state, for non-React code and tests. */
55
+ export const getCallUiState = (): CallUiState => state;
56
+
57
+ export const useCallUi = (): CallUiState =>
58
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
59
+
60
+ export const setCallMinimized = (minimized: boolean): void => {
61
+ if (state.minimized !== minimized) {emit({ minimized });}
62
+ };
63
+
64
+ export const registerCallBarHost = (
65
+ onId?: (id: number) => void
66
+ ): (() => void) => {
67
+ const id = nextHostId++;
68
+ hosts.push(id);
69
+ onId?.(id);
70
+ emit({ barHost: id });
71
+ return () => {
72
+ const index = hosts.indexOf(id);
73
+ if (index >= 0) {hosts.splice(index, 1);}
74
+ emit({ barHost: hosts.length ? hosts[hosts.length - 1] : null });
75
+ };
76
+ };
@@ -26,6 +26,8 @@ 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). */
@@ -63,6 +65,7 @@ export const loadLiveKit = (): LiveKitRuntime | null => {
63
65
  RoomEvent: core.RoomEvent,
64
66
  Track: core.Track,
65
67
  ConnectionState: core.ConnectionState,
68
+ VideoPresets: core.VideoPresets,
66
69
  VideoTrack: rn.VideoTrack,
67
70
  AudioSession: rn.AudioSession,
68
71
  registerGlobals: rn.registerGlobals,
@@ -0,0 +1,46 @@
1
+ import { useMemo, useRef } from 'react';
2
+ import { Animated, PanResponder } from 'react-native';
3
+
4
+ /**
5
+ * Drag a floating element around (web's useDraggable, for the self-view
6
+ * tile and the minimized call panel). The offset is kept between drags.
7
+ *
8
+ * The responder only claims the gesture once the finger actually moves a
9
+ * few points, so taps still reach the buttons inside the dragged element.
10
+ */
11
+ export const useDraggable = () => {
12
+ const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
13
+ const offset = useRef({ x: 0, y: 0 });
14
+
15
+ const panHandlers = useMemo(
16
+ () =>
17
+ PanResponder.create({
18
+ onMoveShouldSetPanResponder: (_event, gesture) =>
19
+ Math.abs(gesture.dx) > 4 || Math.abs(gesture.dy) > 4,
20
+ onPanResponderGrant: () => {
21
+ position.setOffset(offset.current);
22
+ position.setValue({ x: 0, y: 0 });
23
+ },
24
+ onPanResponderMove: Animated.event(
25
+ [null, { dx: position.x, dy: position.y }],
26
+ { useNativeDriver: false }
27
+ ),
28
+ onPanResponderRelease: (_event, gesture) => {
29
+ offset.current = {
30
+ x: offset.current.x + gesture.dx,
31
+ y: offset.current.y + gesture.dy,
32
+ };
33
+ position.flattenOffset();
34
+ },
35
+ onPanResponderTerminate: () => {
36
+ position.flattenOffset();
37
+ },
38
+ }).panHandlers,
39
+ [position]
40
+ );
41
+
42
+ return {
43
+ panHandlers,
44
+ style: { transform: position.getTranslateTransform() },
45
+ };
46
+ };
@@ -1,3 +1,4 @@
1
+ import { uniqueMembers } from './uniqueMembers';
1
2
  import { ApiRoom, IRoom } from '../types/types';
2
3
  import type { LastMessage } from '../types/models/message.model';
3
4
 
@@ -81,6 +82,7 @@ export const createRoomFromApi = (
81
82
  const lastMessage = mapApiLastMessage(room?.lastMessage, jid);
82
83
  const roomData: IRoom = {
83
84
  ...room,
85
+ ...(Array.isArray(room?.members) ? { members: uniqueMembers(room.members) } : {}),
84
86
  id: (room as any)?._id || '',
85
87
  jid,
86
88
  name: room?.title || '',
@@ -58,7 +58,7 @@ export const handleCallPush = (
58
58
  (room: any) =>
59
59
  (room?.jid || '').split('@')[0] === parsed.roomBareName ||
60
60
  room?.jid === parsed.roomRef
61
- ) as { jid?: string; name?: string } | undefined;
61
+ ) as { jid?: string; name?: string; title?: string } | undefined;
62
62
 
63
63
  const roomJid =
64
64
  resolved?.jid ||
@@ -70,7 +70,7 @@ export const handleCallPush = (
70
70
  store.dispatch(
71
71
  setIncomingCallToken({
72
72
  roomJid,
73
- roomName: parsed.callerName || resolved?.name || null,
73
+ roomName: parsed.callerName || resolved?.title || resolved?.name || null,
74
74
  roomBareName: parsed.roomBareName,
75
75
  token: parsed.token,
76
76
  kind: parsed.kind,