@ethora/chat-component-rn 26.10.1 → 26.10.3

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 (145) hide show
  1. package/lib/commonjs/components/MainComponents/ChatHeader.js +11 -10
  2. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  3. package/lib/commonjs/components/MainComponents/MessageContainer.js +10 -2
  4. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/RoomList.js +8 -18
  6. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  7. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +17 -14
  8. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  9. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +131 -17
  10. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  11. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js +6 -1
  12. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js.map +1 -1
  13. package/lib/commonjs/components/VideoCalls/CallButtons.js +3 -1
  14. package/lib/commonjs/components/VideoCalls/CallButtons.js.map +1 -1
  15. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +123 -111
  16. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  17. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +207 -346
  18. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  19. package/lib/commonjs/components/VideoCalls/callActions.js +61 -0
  20. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -0
  21. package/lib/commonjs/components/VideoCalls/callSessionController.js +431 -0
  22. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -0
  23. package/lib/commonjs/components/VideoCalls/useDraggable.js +59 -0
  24. package/lib/commonjs/components/VideoCalls/useDraggable.js.map +1 -0
  25. package/lib/commonjs/helpers/createRoomFromApi.js +4 -0
  26. package/lib/commonjs/helpers/createRoomFromApi.js.map +1 -1
  27. package/lib/commonjs/helpers/incomingCallFromPush.js +1 -1
  28. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  29. package/lib/commonjs/helpers/roomUserCount.js +7 -1
  30. package/lib/commonjs/helpers/roomUserCount.js.map +1 -1
  31. package/lib/commonjs/helpers/uniqueMembers.js +36 -0
  32. package/lib/commonjs/helpers/uniqueMembers.js.map +1 -0
  33. package/lib/commonjs/hooks/useLeaveRoom.js +52 -0
  34. package/lib/commonjs/hooks/useLeaveRoom.js.map +1 -0
  35. package/lib/commonjs/hooks/useLogout.js +15 -10
  36. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  37. package/lib/commonjs/i18n/strings.js +18 -0
  38. package/lib/commonjs/i18n/strings.js.map +1 -1
  39. package/lib/commonjs/networking/api-requests/rooms.api.js +36 -1
  40. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  41. package/lib/commonjs/networking/callTokenStanza.js +47 -3
  42. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  43. package/lib/commonjs/networking/xmppClient.js +9 -2
  44. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  45. package/lib/commonjs/version.js +1 -1
  46. package/lib/module/components/MainComponents/ChatHeader.js +13 -12
  47. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  48. package/lib/module/components/MainComponents/MessageContainer.js +10 -2
  49. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  50. package/lib/module/components/MainComponents/RoomList.js +8 -18
  51. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  52. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +18 -15
  53. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  54. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +132 -18
  55. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  56. package/lib/module/components/RoomComponents/ChatRoomItem.js +6 -1
  57. package/lib/module/components/RoomComponents/ChatRoomItem.js.map +1 -1
  58. package/lib/module/components/VideoCalls/CallButtons.js +3 -1
  59. package/lib/module/components/VideoCalls/CallButtons.js.map +1 -1
  60. package/lib/module/components/VideoCalls/VideoCallOverlay.js +125 -113
  61. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  62. package/lib/module/components/VideoCalls/VideoCallSession.js +208 -348
  63. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  64. package/lib/module/components/VideoCalls/callActions.js +55 -0
  65. package/lib/module/components/VideoCalls/callActions.js.map +1 -0
  66. package/lib/module/components/VideoCalls/callSessionController.js +420 -0
  67. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -0
  68. package/lib/module/components/VideoCalls/useDraggable.js +55 -0
  69. package/lib/module/components/VideoCalls/useDraggable.js.map +1 -0
  70. package/lib/module/helpers/createRoomFromApi.js +4 -0
  71. package/lib/module/helpers/createRoomFromApi.js.map +1 -1
  72. package/lib/module/helpers/incomingCallFromPush.js +1 -1
  73. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  74. package/lib/module/helpers/roomUserCount.js +7 -1
  75. package/lib/module/helpers/roomUserCount.js.map +1 -1
  76. package/lib/module/helpers/uniqueMembers.js +31 -0
  77. package/lib/module/helpers/uniqueMembers.js.map +1 -0
  78. package/lib/module/hooks/useLeaveRoom.js +48 -0
  79. package/lib/module/hooks/useLeaveRoom.js.map +1 -0
  80. package/lib/module/hooks/useLogout.js +15 -10
  81. package/lib/module/hooks/useLogout.js.map +1 -1
  82. package/lib/module/i18n/strings.js +18 -0
  83. package/lib/module/i18n/strings.js.map +1 -1
  84. package/lib/module/networking/api-requests/rooms.api.js +35 -1
  85. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  86. package/lib/module/networking/callTokenStanza.js +47 -3
  87. package/lib/module/networking/callTokenStanza.js.map +1 -1
  88. package/lib/module/networking/xmppClient.js +8 -1
  89. package/lib/module/networking/xmppClient.js.map +1 -1
  90. package/lib/module/version.js +1 -1
  91. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  92. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  93. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  94. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  95. package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
  96. package/lib/typescript/components/RoomComponents/ChatRoomItem.d.ts.map +1 -1
  97. package/lib/typescript/components/VideoCalls/CallButtons.d.ts.map +1 -1
  98. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts +0 -6
  99. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  100. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +9 -20
  101. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  102. package/lib/typescript/components/VideoCalls/callActions.d.ts +15 -0
  103. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -0
  104. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +61 -0
  105. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -0
  106. package/lib/typescript/components/VideoCalls/useDraggable.d.ts +19 -0
  107. package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +1 -0
  108. package/lib/typescript/helpers/createRoomFromApi.d.ts.map +1 -1
  109. package/lib/typescript/helpers/roomUserCount.d.ts +1 -0
  110. package/lib/typescript/helpers/roomUserCount.d.ts.map +1 -1
  111. package/lib/typescript/helpers/uniqueMembers.d.ts +11 -0
  112. package/lib/typescript/helpers/uniqueMembers.d.ts.map +1 -0
  113. package/lib/typescript/hooks/useLeaveRoom.d.ts +9 -0
  114. package/lib/typescript/hooks/useLeaveRoom.d.ts.map +1 -0
  115. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  116. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  117. package/lib/typescript/networking/api-requests/rooms.api.d.ts +8 -0
  118. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  119. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  120. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  121. package/lib/typescript/version.d.ts +1 -1
  122. package/package.json +1 -1
  123. package/src/components/MainComponents/ChatHeader.tsx +10 -8
  124. package/src/components/MainComponents/MessageContainer.tsx +14 -1
  125. package/src/components/MainComponents/RoomList.tsx +8 -14
  126. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +16 -13
  127. package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +167 -36
  128. package/src/components/RoomComponents/ChatRoomItem.tsx +8 -1
  129. package/src/components/VideoCalls/CallButtons.tsx +5 -1
  130. package/src/components/VideoCalls/VideoCallOverlay.tsx +156 -163
  131. package/src/components/VideoCalls/VideoCallSession.tsx +207 -378
  132. package/src/components/VideoCalls/callActions.ts +63 -0
  133. package/src/components/VideoCalls/callSessionController.ts +421 -0
  134. package/src/components/VideoCalls/useDraggable.ts +46 -0
  135. package/src/helpers/createRoomFromApi.tsx +2 -0
  136. package/src/helpers/incomingCallFromPush.ts +2 -2
  137. package/src/helpers/roomUserCount.ts +6 -2
  138. package/src/helpers/uniqueMembers.ts +26 -0
  139. package/src/hooks/useLeaveRoom.ts +42 -0
  140. package/src/hooks/useLogout.tsx +15 -10
  141. package/src/i18n/strings.ts +18 -0
  142. package/src/networking/api-requests/rooms.api.ts +28 -1
  143. package/src/networking/callTokenStanza.ts +46 -6
  144. package/src/networking/xmppClient.ts +10 -1
  145. 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,421 @@
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
+ /** `{ participant, publication, source }` for `<VideoTrack />`, live and unmuted only. */
35
+ remoteVideo: any;
36
+ localVideo: any;
37
+ remoteName: string | null;
38
+ remoteJoined: boolean;
39
+ /** Peer joined but has no live microphone. */
40
+ remoteMicMuted: boolean;
41
+ /**
42
+ * When the call truly connected: the later of the two LiveKit `joinedAt`
43
+ * stamps. The server sets them, so both sides count from the same moment
44
+ * (web's useSharedCallElapsed). Null until the peer is in.
45
+ */
46
+ anchorAt: number | null;
47
+ }
48
+
49
+ const IDLE: CallSessionSnapshot = {
50
+ active: false,
51
+ connected: false,
52
+ deviceError: null,
53
+ micOn: true,
54
+ cameraOn: false,
55
+ speakerOn: false,
56
+ remoteVideo: null,
57
+ localVideo: null,
58
+ remoteName: null,
59
+ remoteJoined: false,
60
+ remoteMicMuted: false,
61
+ anchorAt: null,
62
+ };
63
+
64
+ interface Session {
65
+ key: string;
66
+ room: any;
67
+ kind: 'audio' | 'video';
68
+ disposed: boolean;
69
+ everJoined: boolean;
70
+ }
71
+
72
+ let session: Session | null = null;
73
+ let snapshot: CallSessionSnapshot = IDLE;
74
+ const listeners = new Set<() => void>();
75
+ let storeUnsubscribe: (() => void) | null = null;
76
+
77
+ const emit = (patch: Partial<CallSessionSnapshot>) => {
78
+ snapshot = { ...snapshot, ...patch };
79
+ listeners.forEach((listener) => listener());
80
+ };
81
+
82
+ const subscribe = (listener: () => void) => {
83
+ listeners.add(listener);
84
+ return () => {
85
+ listeners.delete(listener);
86
+ };
87
+ };
88
+
89
+ const getSnapshot = () => snapshot;
90
+
91
+ export const useCallSession = (): CallSessionSnapshot =>
92
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
93
+
94
+ const sessionKey = (livekitUrl: string, token: string) => `${livekitUrl}|${token}`;
95
+
96
+ // A device (camera/mic) permission/availability problem during enable,
97
+ // distinct from a connect failure. Mirrors the web SDK's humanizeDeviceError.
98
+ const humanizeDeviceError = (error: unknown): string => {
99
+ const name = (error as { name?: string })?.name || '';
100
+ const text = String((error as Error)?.message || '').toLowerCase();
101
+ if (name === 'NotAllowedError' || text.includes('permission') || text.includes('denied')) {
102
+ return translateCallString('call.error.deviceBlocked');
103
+ }
104
+ if (name === 'NotFoundError' || text.includes('not found')) {
105
+ return translateCallString('call.error.deviceNotFound');
106
+ }
107
+ if (name === 'NotReadableError' || text.includes('in use')) {
108
+ return translateCallString('call.error.deviceInUse');
109
+ }
110
+ return translateCallString('call.error.deviceGeneric');
111
+ };
112
+
113
+ // Only a live, unmuted track gets a video surface, so the stage never
114
+ // flips between an avatar and a black frame (web's hasLiveVideo).
115
+ const liveVideoRef = (participant: any, Track: any) => {
116
+ if (!participant) {return null;}
117
+ const publication = participant.getTrackPublication?.(Track.Source.Camera);
118
+ if (!publication?.track || publication.isMuted) {return null;}
119
+ return { participant, publication, source: Track.Source.Camera };
120
+ };
121
+
122
+ const joinedAtMs = (participant: any): number => {
123
+ const value = participant?.joinedAt;
124
+ const ms = value ? new Date(value).getTime() : 0;
125
+ return Number.isFinite(ms) ? ms : 0;
126
+ };
127
+
128
+ const resync = () => {
129
+ const current = session;
130
+ const runtime = loadLiveKit();
131
+ if (!current || current.disposed || !runtime) {return;}
132
+ const { Track } = runtime;
133
+ const room = current.room;
134
+
135
+ const remote = Array.from(room.remoteParticipants?.values?.() || [])[0] as any;
136
+ if (remote) {current.everJoined = true;}
137
+
138
+ const remoteMic = remote?.getTrackPublication?.(Track.Source.Microphone);
139
+ const localJoined = joinedAtMs(room.localParticipant);
140
+ const remoteJoinedAt = joinedAtMs(remote);
141
+
142
+ emit({
143
+ remoteJoined: !!remote,
144
+ remoteName: remote?.name || remote?.identity || null,
145
+ remoteVideo: liveVideoRef(remote, Track),
146
+ localVideo: liveVideoRef(room.localParticipant, Track),
147
+ remoteMicMuted: !!remote && (!remoteMic || remoteMic.isMuted !== false),
148
+ anchorAt:
149
+ remote && localJoined && remoteJoinedAt
150
+ ? Math.max(localJoined, remoteJoinedAt)
151
+ : remote
152
+ ? snapshot.anchorAt ?? Date.now()
153
+ : null,
154
+ });
155
+ };
156
+
157
+ export const stopCallSession = (): void => {
158
+ const current = session;
159
+ if (!current) {return;}
160
+ current.disposed = true;
161
+ session = null;
162
+ try {
163
+ current.room.removeAllListeners?.();
164
+ void current.room.disconnect();
165
+ } catch {
166
+ // Already gone.
167
+ }
168
+ try {
169
+ void loadLiveKit()?.AudioSession?.stopAudioSession();
170
+ } catch {
171
+ // Never let teardown throw.
172
+ }
173
+ snapshot = IDLE;
174
+ listeners.forEach((listener) => listener());
175
+ };
176
+
177
+ // Tear the session down as soon as the call slice says there is no call to
178
+ // carry it any more, independent of what UI happens to be mounted.
179
+ const ensureStoreWatcher = () => {
180
+ if (storeUnsubscribe) {return;}
181
+ storeUnsubscribe = store.subscribe(() => {
182
+ if (!session) {return;}
183
+ const call = store.getState().call;
184
+ const live =
185
+ !!call.token && (call.phase === 'connecting' || call.phase === 'in-call');
186
+ if (!live || !session.key.endsWith(`|${call.token}`)) {
187
+ stopCallSession();
188
+ }
189
+ });
190
+ };
191
+
192
+ export interface StartCallSessionOptions {
193
+ token: string;
194
+ livekitUrl: string;
195
+ kind: 'audio' | 'video';
196
+ startWithMicOn?: boolean;
197
+ startWithCameraOn?: boolean;
198
+ }
199
+
200
+ /**
201
+ * Join the LiveKit room for the current call. Idempotent per (url, token):
202
+ * calling it again from a remounted overlay re-attaches to the same room.
203
+ */
204
+ export const startCallSession = ({
205
+ token,
206
+ livekitUrl,
207
+ kind,
208
+ startWithMicOn = true,
209
+ startWithCameraOn = true,
210
+ }: StartCallSessionOptions): void => {
211
+ const key = sessionKey(livekitUrl, token);
212
+ if (session?.key === key) {return;}
213
+ stopCallSession();
214
+ ensureStoreWatcher();
215
+
216
+ const runtime = loadLiveKit();
217
+ if (!runtime) {
218
+ store.dispatch(setCallError(translateCallString('call.error.sdkMissing')));
219
+ return;
220
+ }
221
+ if (!token || !livekitUrl) {
222
+ store.dispatch(setCallError(translateCallString('call.error.missingConfig')));
223
+ return;
224
+ }
225
+
226
+ 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 });
233
+ const current: Session = { key, room, kind, disposed: false, everJoined: false };
234
+ session = current;
235
+ snapshot = {
236
+ ...IDLE,
237
+ active: true,
238
+ micOn: startWithMicOn,
239
+ cameraOn: kind === 'video' && startWithCameraOn,
240
+ // Video calls belong on the loudspeaker, audio calls on the earpiece.
241
+ speakerOn: kind === 'video',
242
+ };
243
+ listeners.forEach((listener) => listener());
244
+
245
+ const onChange = () => {
246
+ if (!current.disposed) {resync();}
247
+ };
248
+
249
+ room
250
+ .on(RoomEvent.TrackSubscribed, onChange)
251
+ .on(RoomEvent.TrackUnsubscribed, onChange)
252
+ .on(RoomEvent.TrackPublished, onChange)
253
+ .on(RoomEvent.TrackUnpublished, onChange)
254
+ .on(RoomEvent.LocalTrackPublished, onChange)
255
+ .on(RoomEvent.LocalTrackUnpublished, onChange)
256
+ .on(RoomEvent.TrackMuted, onChange)
257
+ .on(RoomEvent.TrackUnmuted, onChange)
258
+ .on(RoomEvent.ParticipantConnected, onChange)
259
+ .on(RoomEvent.MediaDevicesError, (error: unknown) => {
260
+ if (current.disposed) {return;}
261
+ emit({ deviceError: humanizeDeviceError(error) });
262
+ })
263
+ .on(RoomEvent.ParticipantDisconnected, () => {
264
+ if (current.disposed) {return;}
265
+ resync();
266
+ // 1:1 semantics, as web's PeerLeaveWatcher: once the peer had been
267
+ // in the room, their leaving IS the end of the call. Before they
268
+ // join (outgoing "Ringing...") an empty room is expected.
269
+ if (current.everJoined && (room.remoteParticipants?.size ?? 0) === 0) {
270
+ terminateActiveCall('ended');
271
+ }
272
+ })
273
+ .on(RoomEvent.Disconnected, () => {
274
+ if (current.disposed) {return;}
275
+ emit({ connected: false });
276
+ terminateActiveCall('ended');
277
+ });
278
+
279
+ (async () => {
280
+ // Joining the LiveKit room is the only step whose failure should fail
281
+ // the call (bad token, network, server down). Device setup below is
282
+ // best-effort.
283
+ try {
284
+ // Claims audio focus and sets the right native audio mode. Must
285
+ // start BEFORE connecting or the first seconds route to the earpiece
286
+ // on Android even for a video call.
287
+ await AudioSession.startAudioSession();
288
+ await room.connect(livekitUrl, token);
289
+ } catch (error: any) {
290
+ if (current.disposed) {return;}
291
+ stopCallSession();
292
+ store.dispatch(
293
+ setCallError(
294
+ String(error?.message || '') ||
295
+ translateCallString('call.error.connectFailed')
296
+ )
297
+ );
298
+ return;
299
+ }
300
+ if (current.disposed) {return;}
301
+
302
+ // Devices are best-effort: a blocked permission or the iOS Simulator
303
+ // (no camera at all) must not kill a call that connected fine.
304
+ try {
305
+ await room.localParticipant.setMicrophoneEnabled(startWithMicOn);
306
+ } catch (error) {
307
+ if (!current.disposed) {emit({ deviceError: humanizeDeviceError(error) });}
308
+ }
309
+ if (kind === 'video') {
310
+ try {
311
+ await room.localParticipant.setCameraEnabled(startWithCameraOn);
312
+ } catch (error) {
313
+ if (!current.disposed) {
314
+ emit({ cameraOn: false, deviceError: humanizeDeviceError(error) });
315
+ }
316
+ }
317
+ }
318
+ if (current.disposed) {return;}
319
+
320
+ try {
321
+ await AudioSession.setAppleAudioConfiguration?.({
322
+ audioCategory: 'playAndRecord',
323
+ audioCategoryOptions: kind === 'video' ? ['defaultToSpeaker'] : [],
324
+ audioMode: kind === 'video' ? 'videoChat' : 'voiceChat',
325
+ });
326
+ } catch {
327
+ // iOS-only helper.
328
+ }
329
+
330
+ emit({ connected: true });
331
+ resync();
332
+ store.dispatch(setCallPhase('in-call'));
333
+ })();
334
+ };
335
+
336
+ // ---- controls -----------------------------------------------------------
337
+
338
+ export const toggleCallMic = async (): Promise<void> => {
339
+ const current = session;
340
+ if (!current) {return;}
341
+ const next = !snapshot.micOn;
342
+ emit({ micOn: next });
343
+ try {
344
+ await current.room.localParticipant.setMicrophoneEnabled(next);
345
+ } catch (error) {
346
+ if (!current.disposed) {
347
+ emit({ micOn: !next, deviceError: humanizeDeviceError(error) });
348
+ }
349
+ }
350
+ };
351
+
352
+ export const toggleCallCamera = async (): Promise<void> => {
353
+ const current = session;
354
+ if (!current) {return;}
355
+ const next = !snapshot.cameraOn;
356
+ emit({ cameraOn: next });
357
+ try {
358
+ await current.room.localParticipant.setCameraEnabled(next);
359
+ resync();
360
+ } catch (error) {
361
+ if (!current.disposed) {
362
+ emit({ cameraOn: !next, deviceError: humanizeDeviceError(error) });
363
+ }
364
+ }
365
+ };
366
+
367
+ export const switchCallCamera = async (): Promise<void> => {
368
+ const current = session;
369
+ const runtime = loadLiveKit();
370
+ if (!current || !runtime) {return;}
371
+ 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',
383
+ });
384
+ resync();
385
+ } catch {
386
+ // Single camera, or the track is mid-restart.
387
+ }
388
+ };
389
+
390
+ export const toggleCallSpeaker = async (): Promise<void> => {
391
+ const runtime = loadLiveKit();
392
+ if (!session || !runtime) {return;}
393
+ const next = !snapshot.speakerOn;
394
+ emit({ speakerOn: next });
395
+ try {
396
+ if (Platform.OS === 'android') {
397
+ // On Android `selectAudioOutput` device ids are the literal strings
398
+ // 'speaker' / 'earpiece'.
399
+ await runtime.AudioSession.selectAudioOutput(next ? 'speaker' : 'earpiece');
400
+ } else {
401
+ await runtime.AudioSession.setAppleAudioConfiguration?.({
402
+ audioCategory: 'playAndRecord',
403
+ audioCategoryOptions: next ? ['defaultToSpeaker'] : [],
404
+ audioMode: next ? 'videoChat' : 'voiceChat',
405
+ });
406
+ }
407
+ } catch {
408
+ emit({ speakerOn: !next });
409
+ }
410
+ };
411
+
412
+ export const dismissCallDeviceError = (): void => {
413
+ emit({ deviceError: null });
414
+ };
415
+
416
+ /** Reset module state. Test seam only. */
417
+ export const __resetCallSessionForTests = (): void => {
418
+ stopCallSession();
419
+ storeUnsubscribe?.();
420
+ storeUnsubscribe = null;
421
+ };
@@ -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,
@@ -2,7 +2,7 @@ import { IRoom } from '../types/types';
2
2
 
3
3
  type CountSource =
4
4
  | Pick<IRoom, 'members' | 'usersCnt'>
5
- | { members?: unknown; usersCnt?: unknown }
5
+ | { members?: unknown; usersCnt?: unknown; type?: unknown }
6
6
  | null
7
7
  | undefined;
8
8
 
@@ -17,11 +17,15 @@ export const getRoomUserCount = (room: CountSource): number => {
17
17
  typeof room?.usersCnt === 'number' && Number.isFinite(room.usersCnt)
18
18
  ? room.usersCnt
19
19
  : 0;
20
- return Math.max(cnt, members);
20
+ const total = Math.max(cnt, members);
21
+ // A private chat is two people by definition; the server's usersCnt has
22
+ // been seen to drift above that (3 for a 1:1 room).
23
+ return (room as { type?: unknown })?.type === 'private' ? Math.min(total, 2) : total;
21
24
  };
22
25
 
23
26
  /** True when the room is known to have more users than members loaded. */
24
27
  export const isRoomMembersTruncated = (room: CountSource): boolean => {
28
+ if ((room as { type?: unknown })?.type === 'private') {return false;}
25
29
  const members = Array.isArray(room?.members) ? room.members.length : 0;
26
30
  const cnt =
27
31
  typeof room?.usersCnt === 'number' && Number.isFinite(room.usersCnt)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Drop repeated entries from a room's member list, keeping the first.
3
+ *
4
+ * The same person could appear twice in a 1:1 chat ("Members: 3", four
5
+ * rows): the list came back from the server with repeats after the private
6
+ * chat was requested again for an existing room. Members are keyed by
7
+ * whatever identity the entry carries: xmppUsername, then _id, then the
8
+ * JID's localpart.
9
+ */
10
+ export const uniqueMembers = <T,>(members: T[] | undefined | null): T[] => {
11
+ if (!Array.isArray(members)) {return [];}
12
+ const seen = new Set<string>();
13
+ const out: T[] = [];
14
+ for (const member of members) {
15
+ const m = member as any;
16
+ const key = String(
17
+ m?.xmppUsername || m?._id || String(m?.jid || '').split('@')[0] || ''
18
+ ).trim();
19
+ if (key) {
20
+ if (seen.has(key)) {continue;}
21
+ seen.add(key);
22
+ }
23
+ out.push(member);
24
+ }
25
+ return out;
26
+ };
@@ -0,0 +1,42 @@
1
+ import { useCallback } from 'react';
2
+ import { useDispatch } from 'react-redux';
3
+ import { deleteRoom } from '../roomStore/roomsSlice';
4
+ import { leaveRoom } from '../networking/api-requests/rooms.api';
5
+ import { useXmppClient } from '../context/xmppProvider';
6
+
7
+ /** Rooms with a leave request in flight: a second tap waits for the first. */
8
+ const inFlight = new Set<string>();
9
+
10
+ /**
11
+ * Leave a chat, shared by the room-list swipe, the chat header and the chat
12
+ * profile. The server goes first: if it refuses, the chat stays where it is
13
+ * (dropping it locally would only have it come back on the next /chats/my).
14
+ * Then the unavailable presence to the MUC and the room out of the store.
15
+ * Resolves false on failure, so the caller can say so in its own way.
16
+ */
17
+ export const useLeaveRoom = () => {
18
+ const dispatch = useDispatch();
19
+ const { client } = useXmppClient();
20
+ return useCallback(
21
+ async (jid: string): Promise<boolean> => {
22
+ if (!jid || inFlight.has(jid)) {return true;}
23
+ inFlight.add(jid);
24
+ try {
25
+ await leaveRoom(jid.split('@')[0]);
26
+ } catch (error) {
27
+ console.warn('[ethora-rn] leave failed', error);
28
+ return false;
29
+ } finally {
30
+ inFlight.delete(jid);
31
+ }
32
+ try {
33
+ client?.leaveTheRoomStanza?.(jid);
34
+ } catch (error) {
35
+ console.error('Failed to leave the room:', error);
36
+ }
37
+ dispatch(deleteRoom({ jid }));
38
+ return true;
39
+ },
40
+ [client, dispatch]
41
+ );
42
+ };