@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
@@ -1,19 +1,13 @@
1
- import React, {
2
- FC,
3
- useCallback,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from 'react';
1
+ import React, { FC, useEffect, useState } from 'react';
9
2
  import {
10
3
  ActivityIndicator,
11
- Platform,
4
+ Animated,
12
5
  StyleSheet,
13
6
  Text,
14
7
  TouchableOpacity,
15
8
  View,
16
9
  } from 'react-native';
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
11
  import { VideoCallIcons } from '../../types/types';
18
12
  import { ProfileImagePlaceholder } from '../MainComponents/ProfileImagePlaceholder';
19
13
  import {
@@ -28,7 +22,16 @@ import {
28
22
  SpeakerOffIcon,
29
23
  SwitchCameraIcon,
30
24
  } from './CallIcons';
31
- import { ensureLiveKitGlobals, loadLiveKit } from './livekitRuntime';
25
+ import { loadLiveKit } from './livekitRuntime';
26
+ import {
27
+ dismissCallDeviceError,
28
+ switchCallCamera,
29
+ toggleCallCamera,
30
+ toggleCallMic,
31
+ toggleCallSpeaker,
32
+ useCallSession,
33
+ } from './callSessionController';
34
+ import { useDraggable } from './useDraggable';
32
35
  import { useT } from '../../i18n/useT';
33
36
 
34
37
  const DEFAULT_PRIMARY = '#0052CD';
@@ -38,334 +41,69 @@ const TEXT_MUTED = '#9AA4B2';
38
41
  const DANGER = '#E53935';
39
42
  const CONTROL_IDLE = 'rgba(255, 255, 255, 0.16)';
40
43
 
41
- // A device (camera/mic) permission/availability problem during enable —
42
- // distinct from a connect failure. Mirrors the web SDK's humanizeDeviceError:
43
- // this must never fail the whole call (the user can still talk/hear once
44
- // they re-allow or plug in a device), only surface a dismissible hint. Most
45
- // relevant on the iOS Simulator, which has no real camera at all — every
46
- // video call would otherwise die at setCameraEnabled before ever connecting.
47
- const humanizeDeviceError = (error: unknown, t: (key: string) => string): string => {
48
- const name = (error as { name?: string })?.name || '';
49
- const text = String((error as Error)?.message || '').toLowerCase();
50
- if (name === 'NotAllowedError' || text.includes('permission') || text.includes('denied')) {
51
- return t('call.error.deviceBlocked');
52
- }
53
- if (name === 'NotFoundError' || text.includes('not found')) {
54
- return t('call.error.deviceNotFound');
55
- }
56
- if (name === 'NotReadableError' || text.includes('in use')) {
57
- return t('call.error.deviceInUse');
58
- }
59
- return t('call.error.deviceGeneric');
60
- };
61
-
62
44
  export interface VideoCallSessionProps {
63
- token: string;
64
- livekitUrl: string;
65
45
  kind?: 'audio' | 'video';
66
- /** Display name of the peer, shown on the audio-call canvas. */
46
+ /** Display name of the peer, shown until LiveKit reports theirs. */
67
47
  peerName?: string | null;
68
48
  primaryColor?: string;
69
49
  icons?: VideoCallIcons;
70
- /** Start with the camera on (video calls). Default true. */
71
- startWithCameraOn?: boolean;
72
- /** Start with the microphone on. Default true. */
73
- startWithMicOn?: boolean;
74
50
  /** Render the compact floating-panel layout instead of the full screen. */
75
51
  minimized?: boolean;
76
- /** Toggle between full and minimized layouts (keeps the session mounted). */
52
+ /** Toggle between full and minimized layouts. */
77
53
  onToggleMinimize?: () => void;
78
- onConnected?: () => void;
79
- onError?: (message: string) => void;
80
54
  onHangup: () => void;
81
55
  }
82
56
 
83
57
  /**
84
- * The LiveKit session, React Native edition.
85
- *
86
- * The web SDK builds this on `@livekit/components-react` hooks. That package
87
- * assumes a DOM, so here we drive `livekit-client`'s imperative `Room` API
88
- * directly and keep the derived bits (subscribed tracks, mute flags,
89
- * connection state) in local React state, re-syncing on RoomEvent. Only the
90
- * actual video surface comes from `@livekit/react-native` (`<VideoTrack />`),
91
- * which wraps the native renderer.
58
+ * Seconds since the call truly connected, counted from the server-set
59
+ * `joinedAt` anchor so both sides show the same time (web's
60
+ * useSharedCallElapsed). Null until the peer is in.
61
+ */
62
+ const useSharedCallElapsed = (anchorAt: number | null): number | null => {
63
+ const [, tick] = useState(0);
64
+ useEffect(() => {
65
+ if (anchorAt === null) {return;}
66
+ const id = setInterval(() => tick((n) => n + 1), 1000);
67
+ return () => clearInterval(id);
68
+ }, [anchorAt]);
69
+ if (anchorAt === null) {return null;}
70
+ return Math.max(0, Math.floor((Date.now() - anchorAt) / 1000));
71
+ };
72
+
73
+ export const formatCallDuration = (totalSeconds: number): string => {
74
+ const safe = Math.max(0, Math.floor(totalSeconds));
75
+ const hours = Math.floor(safe / 3600);
76
+ const minutes = Math.floor((safe % 3600) / 60);
77
+ const seconds = safe % 60;
78
+ const mm = String(minutes).padStart(2, '0');
79
+ const ss = String(seconds).padStart(2, '0');
80
+ return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
81
+ };
82
+
83
+ /**
84
+ * The call screen, React Native edition. Pure view over the LiveKit session
85
+ * owned by callSessionController.ts: it can unmount and remount (minimize,
86
+ * leaving the chat screen) without touching the connection.
92
87
  *
93
- * Requires `@livekit/react-native` >= 2.7 for the `trackRef` prop shape used
94
- * below. Both LiveKit packages are optional peer deps, see livekitRuntime.ts.
88
+ * The web SDK builds this on `@livekit/components-react` hooks, which
89
+ * assume a DOM. Only the video surface comes from `@livekit/react-native`
90
+ * (`<VideoTrack />`, `trackRef` prop, >= 2.7).
95
91
  */
96
92
  export const VideoCallSession: FC<VideoCallSessionProps> = ({
97
- token,
98
- livekitUrl,
99
93
  kind = 'video',
100
94
  peerName,
101
95
  primaryColor = DEFAULT_PRIMARY,
102
96
  icons,
103
- startWithCameraOn = true,
104
- startWithMicOn = true,
105
97
  minimized = false,
106
98
  onToggleMinimize,
107
- onConnected,
108
- onError,
109
99
  onHangup,
110
100
  }) => {
111
101
  const t = useT();
112
- const runtime = useMemo(() => loadLiveKit(), []);
113
-
114
- const roomRef = useRef<any>(null);
115
- const [connected, setConnected] = useState(false);
116
- const [deviceError, setDeviceError] = useState<string | null>(null);
117
- const [micOn, setMicOn] = useState(startWithMicOn);
118
- const [cameraOn, setCameraOn] = useState(kind === 'video' && startWithCameraOn);
119
- const [speakerOn, setSpeakerOn] = useState(kind === 'video');
120
- const [remoteVideo, setRemoteVideo] = useState<any>(null);
121
- const [localVideo, setLocalVideo] = useState<any>(null);
122
- const [remoteName, setRemoteName] = useState<string | null>(null);
123
- const [remoteJoined, setRemoteJoined] = useState(false);
124
-
125
- // Callbacks are read through a ref inside the connect effect so that a
126
- // parent re-render (new inline arrow props) never re-runs connect and
127
- // tears down a live call. Same guard the web session uses.
128
- const callbacksRef = useRef({ onConnected, onError, onHangup });
129
- useEffect(() => {
130
- callbacksRef.current = { onConnected, onError, onHangup };
131
- }, [onConnected, onError, onHangup]);
132
-
133
- // ---- track plumbing -------------------------------------------------
134
-
135
- // Build the { participant, publication, source } shape `<VideoTrack />`
136
- // expects, from whatever camera publication a participant currently has.
137
- const toTrackRef = useCallback(
138
- (participant: any, Track: any) => {
139
- if (!participant) {return null;}
140
- const publication = participant.getTrackPublication?.(Track.Source.Camera);
141
- if (!publication?.track) {return null;}
142
- return { participant, publication, source: Track.Source.Camera };
143
- },
144
- []
145
- );
146
-
147
- const resyncTracks = useCallback(() => {
148
- const room = roomRef.current;
149
- if (!room || !runtime) {return;}
150
- const { Track } = runtime;
151
-
152
- const remote = Array.from(room.remoteParticipants?.values?.() || [])[0] as any;
153
- setRemoteJoined(!!remote);
154
- setRemoteName(remote?.name || remote?.identity || null);
155
- setRemoteVideo(toTrackRef(remote, Track));
156
- setLocalVideo(toTrackRef(room.localParticipant, Track));
157
- }, [runtime, toTrackRef]);
158
-
159
- // ---- connect / teardown ---------------------------------------------
160
-
161
- useEffect(() => {
162
- if (!runtime) {
163
- callbacksRef.current.onError?.(t('call.error.sdkMissing'));
164
- return;
165
- }
166
- if (!token || !livekitUrl) {
167
- callbacksRef.current.onError?.(t('call.error.missingConfig'));
168
- return;
169
- }
170
-
171
- ensureLiveKitGlobals(runtime);
172
-
173
- const { Room, RoomEvent, AudioSession } = runtime;
174
- let disposed = false;
175
-
176
- // adaptiveStream + dynacast keep mobile data and battery sane: the SDK
177
- // drops resolution for a small tile and stops publishing layers nobody
178
- // subscribes to. Both are safe defaults for 1:1.
179
- const room = new Room({
180
- adaptiveStream: true,
181
- dynacast: true,
182
- });
183
- roomRef.current = room;
184
-
185
- const onStateChange = () => {
186
- if (disposed) {return;}
187
- resyncTracks();
188
- };
189
-
190
- room
191
- .on(RoomEvent.TrackSubscribed, onStateChange)
192
- .on(RoomEvent.TrackUnsubscribed, onStateChange)
193
- .on(RoomEvent.LocalTrackPublished, onStateChange)
194
- .on(RoomEvent.LocalTrackUnpublished, onStateChange)
195
- .on(RoomEvent.TrackMuted, onStateChange)
196
- .on(RoomEvent.TrackUnmuted, onStateChange)
197
- .on(RoomEvent.ParticipantConnected, onStateChange)
198
- .on(RoomEvent.MediaDevicesError, (error: unknown) => {
199
- if (disposed) {return;}
200
- setDeviceError(humanizeDeviceError(error, t));
201
- })
202
- .on(RoomEvent.ParticipantDisconnected, () => {
203
- if (disposed) {return;}
204
- resyncTracks();
205
- // 1:1 semantics: the other side leaving IS the end of the call.
206
- // Without this the local user sits on a black canvas until they
207
- // hang up manually.
208
- callbacksRef.current.onHangup?.();
209
- })
210
- .on(RoomEvent.Disconnected, () => {
211
- if (disposed) {return;}
212
- setConnected(false);
213
- callbacksRef.current.onHangup?.();
214
- });
215
-
216
- (async () => {
217
- // Joining the LiveKit room is the only step whose failure should fail
218
- // the call (bad token, network, server down). Everything after this
219
- // is best-effort device setup — see the separate try/catches below.
220
- try {
221
- // Claims audio focus and sets the right native audio mode. Must
222
- // start BEFORE connecting or the first seconds route to the
223
- // earpiece on Android even for a video call.
224
- await AudioSession.startAudioSession();
225
- await room.connect(livekitUrl, token);
226
- } catch (error: any) {
227
- if (disposed) {return;}
228
- callbacksRef.current.onError?.(
229
- String(error?.message || '') || t('call.error.connectFailed')
230
- );
231
- return;
232
- }
233
- if (disposed) {return;}
234
-
235
- // Devices are best-effort: if the mic/camera prompt is blocked, or —
236
- // notably — this is the iOS Simulator (no real camera hardware at
237
- // all), the call still connects. The user can hear/be heard on
238
- // audio and re-enable the camera later; failing the whole call here
239
- // (as an earlier version did, sharing one try/catch with `connect`)
240
- // meant every video call died before ever reaching the ring-answered
241
- // screen — indistinguishable from "Accept doesn't work".
242
- try {
243
- await room.localParticipant.setMicrophoneEnabled(startWithMicOn);
244
- } catch (error) {
245
- if (!disposed) {setDeviceError(humanizeDeviceError(error, t));}
246
- }
247
- if (kind === 'video') {
248
- try {
249
- await room.localParticipant.setCameraEnabled(startWithCameraOn);
250
- } catch (error) {
251
- if (!disposed) {setDeviceError(humanizeDeviceError(error, t));}
252
- }
253
- }
254
- if (disposed) {return;}
255
-
256
- // Video calls belong on the loudspeaker, audio calls on the
257
- // earpiece, matching what every native dialer does.
258
- try {
259
- await AudioSession.setAppleAudioConfiguration?.({
260
- audioCategory: 'playAndRecord',
261
- audioMode: kind === 'video' ? 'videoChat' : 'voiceChat',
262
- });
263
- } catch {
264
- // iOS-only helper, absent on Android builds of the SDK.
265
- }
266
-
267
- setConnected(true);
268
- resyncTracks();
269
- callbacksRef.current.onConnected?.();
270
- })();
271
-
272
- return () => {
273
- disposed = true;
274
- try {
275
- room.removeAllListeners?.();
276
- void room.disconnect();
277
- } catch {
278
- // Already gone.
279
- }
280
- try {
281
- void AudioSession.stopAudioSession();
282
- } catch {
283
- // Never let teardown throw, it runs during unmount.
284
- }
285
- roomRef.current = null;
286
- };
287
- // Connect exactly once per (url, token, kind). Everything else is read
288
- // through refs, see callbacksRef above.
289
- // eslint-disable-next-line react-hooks/exhaustive-deps
290
- }, [runtime, livekitUrl, token, kind]);
291
-
292
- // ---- controls --------------------------------------------------------
293
-
294
- const toggleMic = useCallback(async () => {
295
- const room = roomRef.current;
296
- if (!room) {return;}
297
- const next = !micOn;
298
- setMicOn(next);
299
- try {
300
- await room.localParticipant.setMicrophoneEnabled(next);
301
- } catch {
302
- setMicOn(!next);
303
- }
304
- }, [micOn]);
305
-
306
- const toggleCamera = useCallback(async () => {
307
- const room = roomRef.current;
308
- if (!room) {return;}
309
- const next = !cameraOn;
310
- setCameraOn(next);
311
- try {
312
- await room.localParticipant.setCameraEnabled(next);
313
- resyncTracks();
314
- } catch {
315
- setCameraOn(!next);
316
- }
317
- }, [cameraOn, resyncTracks]);
318
-
319
- const switchCamera = useCallback(async () => {
320
- const room = roomRef.current;
321
- if (!room || !runtime) {return;}
322
- try {
323
- const publication = room.localParticipant.getTrackPublication?.(
324
- runtime.Track.Source.Camera
325
- );
326
- const track = publication?.track;
327
- // `restartTrack` with the flipped facingMode is the portable way to
328
- // switch cameras; the RN SDK's `_switchCamera` helper is internal and
329
- // has moved between versions.
330
- await track?.restartTrack?.({
331
- facingMode: track?.mediaStreamTrack?.getSettings?.().facingMode ===
332
- 'environment'
333
- ? 'user'
334
- : 'environment',
335
- });
336
- resyncTracks();
337
- } catch {
338
- // Device has a single camera, or the track is mid-restart.
339
- }
340
- }, [runtime, resyncTracks]);
341
-
342
- const toggleSpeaker = useCallback(async () => {
343
- const next = !speakerOn;
344
- setSpeakerOn(next);
345
- if (!runtime) {return;}
346
- try {
347
- if (Platform.OS === 'android') {
348
- // NB: not `setAndroidAudioConfiguration` — that method has never
349
- // existed on AudioSession (the old `?.` call silently no-oped, so
350
- // the speaker icon toggled while audio stayed on the earpiece).
351
- // `selectAudioOutput` routes directly; on Android its device ids
352
- // are the literal strings 'speaker' / 'earpiece'.
353
- await runtime.AudioSession.selectAudioOutput(
354
- next ? 'speaker' : 'earpiece'
355
- );
356
- } else {
357
- await runtime.AudioSession.setAppleAudioConfiguration?.({
358
- audioCategory: 'playAndRecord',
359
- audioCategoryOptions: next ? ['defaultToSpeaker'] : [],
360
- audioMode: next ? 'videoChat' : 'voiceChat',
361
- });
362
- }
363
- } catch {
364
- setSpeakerOn(!next);
365
- }
366
- }, [runtime, speakerOn]);
367
-
368
- // ---- render ----------------------------------------------------------
102
+ const insets = useSafeAreaInsets();
103
+ const runtime = loadLiveKit();
104
+ const session = useCallSession();
105
+ const selfView = useDraggable();
106
+ const elapsed = useSharedCallElapsed(session.anchorAt);
369
107
 
370
108
  if (!runtime) {
371
109
  return (
@@ -376,25 +114,56 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
376
114
  }
377
115
 
378
116
  const { VideoTrack } = runtime;
379
- const displayName = remoteName || peerName || t('call.unknownCaller');
117
+ const {
118
+ connected,
119
+ remoteJoined,
120
+ remoteVideo,
121
+ localVideo,
122
+ micOn,
123
+ cameraOn,
124
+ speakerOn,
125
+ deviceError,
126
+ remoteMicMuted,
127
+ } = session;
128
+ // The call slice's name (the chat title) first, as web does: LiveKit's
129
+ // participant name is often just the opaque xmpp identity.
130
+ const displayName = peerName || session.remoteName || t('call.unknownCaller');
380
131
  const isVideo = kind === 'video';
381
132
  const showRemoteVideo = isVideo && !!remoteVideo;
382
133
 
134
+ const statusText =
135
+ elapsed !== null
136
+ ? formatCallDuration(elapsed)
137
+ : !connected
138
+ ? t('call.status.connecting')
139
+ : !remoteJoined
140
+ ? t('call.status.waitingForPeer')
141
+ : t('call.status.inCall');
142
+
143
+ const peerMutedLabel = t('call.peerMuted', { name: displayName });
144
+
383
145
  if (minimized) {
384
146
  return (
385
147
  <View style={styles.miniBar}>
148
+ <ProfileImagePlaceholder name={displayName} size={36} />
386
149
  <View style={styles.miniInfo}>
387
150
  <Text numberOfLines={1} style={styles.miniName}>
388
151
  {displayName}
389
152
  </Text>
390
- <Text style={styles.miniStatus}>
391
- {connected
392
- ? remoteJoined
393
- ? t('call.status.inCall')
394
- : t('call.status.waitingForPeer')
395
- : t('call.status.connecting')}
153
+ <Text numberOfLines={1} style={styles.miniStatus}>
154
+ {remoteMicMuted ? `${statusText} · ${peerMutedLabel}` : statusText}
396
155
  </Text>
397
156
  </View>
157
+ <TouchableOpacity
158
+ accessibilityRole="button"
159
+ accessibilityLabel={micOn ? t('call.action.muteMic') : t('call.action.unmuteMic')}
160
+ onPress={toggleCallMic}
161
+ style={[styles.miniButton, { backgroundColor: micOn ? CONTROL_IDLE : DANGER }]}
162
+ >
163
+ {micOn
164
+ ? (icons?.micOn ?? <MicOnIcon color={TEXT_ON_DARK} size={20} />)
165
+ : (icons?.micOff ?? <MicOffIcon color={TEXT_ON_DARK} size={20} />)}
166
+ </TouchableOpacity>
398
167
  <TouchableOpacity
399
168
  accessibilityRole="button"
400
169
  accessibilityLabel={t('call.action.expand')}
@@ -417,17 +186,6 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
417
186
 
418
187
  return (
419
188
  <View style={styles.canvas}>
420
- {!!deviceError && (
421
- <TouchableOpacity
422
- style={styles.deviceErrorBanner}
423
- onPress={() => setDeviceError(null)}
424
- accessibilityRole="button"
425
- >
426
- <Text style={styles.deviceErrorText} numberOfLines={2}>
427
- {deviceError}
428
- </Text>
429
- </TouchableOpacity>
430
- )}
431
189
  {/* Stage */}
432
190
  <View style={styles.stage}>
433
191
  {showRemoteVideo ? (
@@ -437,29 +195,50 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
437
195
  <ProfileImagePlaceholder name={displayName} size={120} />
438
196
  <Text style={styles.stageName}>{displayName}</Text>
439
197
  <Text style={styles.stageStatus}>
440
- {!connected
441
- ? t('call.status.connecting')
442
- : !remoteJoined
443
- ? t('call.status.waitingForPeer')
444
- : isVideo
445
- ? t('call.status.cameraOff')
446
- : t('call.status.inCall')}
198
+ {isVideo && remoteJoined && elapsed !== null
199
+ ? t('call.status.cameraOff')
200
+ : statusText}
447
201
  </Text>
448
202
  {!connected && (
449
- <ActivityIndicator
450
- color={primaryColor}
451
- style={styles.spinner}
452
- size="small"
453
- />
203
+ <ActivityIndicator color={primaryColor} style={styles.spinner} size="small" />
204
+ )}
205
+ {!micOn && (
206
+ <View style={[styles.pill, styles.pillDanger]}>
207
+ <Text style={styles.pillDangerText}>{t('call.selfMuted')}</Text>
208
+ </View>
209
+ )}
210
+ {remoteMicMuted && !showRemoteVideo && (
211
+ <View style={styles.pill}>
212
+ <Text style={styles.pillText}>{peerMutedLabel}</Text>
213
+ </View>
214
+ )}
215
+ </View>
216
+ )}
217
+
218
+ {/* Header strip: who, how long, and whether they can hear us. */}
219
+ {showRemoteVideo && (
220
+ <View style={[styles.header, { paddingTop: 14 + insets.top }]}>
221
+ <Text style={styles.headerName} numberOfLines={1}>
222
+ {displayName}
223
+ </Text>
224
+ <Text style={styles.headerStatus}>{statusText}</Text>
225
+ {remoteMicMuted && (
226
+ <View style={[styles.pill, styles.headerPill]}>
227
+ <MicOffIcon color={TEXT_ON_DARK} size={14} />
228
+ <Text style={styles.pillText}>{peerMutedLabel}</Text>
229
+ </View>
454
230
  )}
455
231
  </View>
456
232
  )}
457
233
 
458
- {/* Local preview, picture-in-picture */}
234
+ {/* Local preview, picture-in-picture, draggable like web's self-view. */}
459
235
  {isVideo && cameraOn && localVideo && (
460
- <View style={styles.pip}>
236
+ <Animated.View
237
+ {...selfView.panHandlers}
238
+ style={[styles.pip, { top: 16 + insets.top }, selfView.style]}
239
+ >
461
240
  <VideoTrack trackRef={localVideo} style={styles.pipVideo} objectFit="cover" mirror />
462
- </View>
241
+ </Animated.View>
463
242
  )}
464
243
 
465
244
  {onToggleMinimize && (
@@ -467,23 +246,32 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
467
246
  accessibilityRole="button"
468
247
  accessibilityLabel={t('call.action.minimize')}
469
248
  onPress={onToggleMinimize}
470
- style={styles.minimizeButton}
249
+ style={[styles.minimizeButton, { top: 16 + insets.top }]}
471
250
  >
472
251
  <MinimizeIcon color={TEXT_ON_DARK} size={20} />
473
252
  </TouchableOpacity>
474
253
  )}
254
+
255
+ {!!deviceError && (
256
+ <TouchableOpacity
257
+ style={[styles.deviceErrorBanner, { top: 64 + insets.top }]}
258
+ onPress={dismissCallDeviceError}
259
+ accessibilityRole="button"
260
+ >
261
+ <Text style={styles.deviceErrorText} numberOfLines={2}>
262
+ {deviceError}
263
+ </Text>
264
+ </TouchableOpacity>
265
+ )}
475
266
  </View>
476
267
 
477
268
  {/* Control bar */}
478
- <View style={styles.controls}>
269
+ <View style={[styles.controls, { paddingBottom: 20 + insets.bottom }]}>
479
270
  <TouchableOpacity
480
271
  accessibilityRole="button"
481
272
  accessibilityLabel={micOn ? t('call.action.muteMic') : t('call.action.unmuteMic')}
482
- onPress={toggleMic}
483
- style={[
484
- styles.controlButton,
485
- { backgroundColor: micOn ? CONTROL_IDLE : DANGER },
486
- ]}
273
+ onPress={toggleCallMic}
274
+ style={[styles.controlButton, { backgroundColor: micOn ? CONTROL_IDLE : DANGER }]}
487
275
  >
488
276
  {micOn
489
277
  ? (icons?.micOn ?? <MicOnIcon color={TEXT_ON_DARK} />)
@@ -496,11 +284,8 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
496
284
  accessibilityLabel={
497
285
  cameraOn ? t('call.action.stopCamera') : t('call.action.startCamera')
498
286
  }
499
- onPress={toggleCamera}
500
- style={[
501
- styles.controlButton,
502
- { backgroundColor: cameraOn ? CONTROL_IDLE : DANGER },
503
- ]}
287
+ onPress={toggleCallCamera}
288
+ style={[styles.controlButton, { backgroundColor: cameraOn ? CONTROL_IDLE : DANGER }]}
504
289
  >
505
290
  {cameraOn
506
291
  ? (icons?.cameraOn ?? <CameraOnIcon color={TEXT_ON_DARK} />)
@@ -512,7 +297,7 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
512
297
  <TouchableOpacity
513
298
  accessibilityRole="button"
514
299
  accessibilityLabel={t('call.action.switchCamera')}
515
- onPress={switchCamera}
300
+ onPress={switchCallCamera}
516
301
  style={[styles.controlButton, { backgroundColor: CONTROL_IDLE }]}
517
302
  >
518
303
  <SwitchCameraIcon color={TEXT_ON_DARK} />
@@ -521,17 +306,11 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
521
306
 
522
307
  <TouchableOpacity
523
308
  accessibilityRole="button"
524
- accessibilityLabel={
525
- speakerOn ? t('call.action.speakerOff') : t('call.action.speakerOn')
526
- }
527
- onPress={toggleSpeaker}
309
+ accessibilityLabel={speakerOn ? t('call.action.speakerOff') : t('call.action.speakerOn')}
310
+ onPress={toggleCallSpeaker}
528
311
  style={[styles.controlButton, { backgroundColor: CONTROL_IDLE }]}
529
312
  >
530
- {speakerOn ? (
531
- <SpeakerIcon color={TEXT_ON_DARK} />
532
- ) : (
533
- <SpeakerOffIcon color={TEXT_ON_DARK} />
534
- )}
313
+ {speakerOn ? <SpeakerIcon color={TEXT_ON_DARK} /> : <SpeakerOffIcon color={TEXT_ON_DARK} />}
535
314
  </TouchableOpacity>
536
315
 
537
316
  <TouchableOpacity
@@ -557,6 +336,7 @@ const styles = StyleSheet.create({
557
336
  alignItems: 'center',
558
337
  justifyContent: 'center',
559
338
  gap: 12,
339
+ paddingHorizontal: 24,
560
340
  },
561
341
  stage: {
562
342
  flex: 1,
@@ -571,18 +351,66 @@ const styles = StyleSheet.create({
571
351
  color: TEXT_ON_DARK,
572
352
  fontSize: 22,
573
353
  marginTop: 16,
354
+ textAlign: 'center',
574
355
  },
575
356
  stageStatus: {
576
357
  color: TEXT_MUTED,
577
358
  fontSize: 14,
359
+ fontVariant: ['tabular-nums'],
578
360
  },
579
361
  spinner: {
580
362
  marginTop: 8,
581
363
  },
364
+ header: {
365
+ position: 'absolute',
366
+ top: 0,
367
+ left: 0,
368
+ right: 0,
369
+ paddingHorizontal: 64,
370
+ paddingBottom: 14,
371
+ alignItems: 'center',
372
+ backgroundColor: 'rgba(0,0,0,0.35)',
373
+ gap: 2,
374
+ },
375
+ headerName: {
376
+ color: TEXT_ON_DARK,
377
+ fontSize: 15,
378
+ fontWeight: '600',
379
+ },
380
+ headerStatus: {
381
+ color: 'rgba(255,255,255,0.7)',
382
+ fontSize: 13,
383
+ fontVariant: ['tabular-nums'],
384
+ },
385
+ headerPill: {
386
+ marginTop: 4,
387
+ backgroundColor: 'rgba(0,0,0,0.45)',
388
+ },
389
+ pill: {
390
+ flexDirection: 'row',
391
+ alignItems: 'center',
392
+ gap: 6,
393
+ paddingVertical: 4,
394
+ paddingHorizontal: 10,
395
+ borderRadius: 999,
396
+ backgroundColor: CONTROL_IDLE,
397
+ },
398
+ pillText: {
399
+ color: TEXT_ON_DARK,
400
+ fontSize: 12,
401
+ },
402
+ pillDanger: {
403
+ backgroundColor: 'rgba(229, 57, 53, 0.18)',
404
+ borderWidth: 1,
405
+ borderColor: 'rgba(229, 57, 53, 0.45)',
406
+ },
407
+ pillDangerText: {
408
+ color: '#FF8A80',
409
+ fontSize: 12,
410
+ },
582
411
  pip: {
583
412
  position: 'absolute',
584
413
  right: 16,
585
- top: 16,
586
414
  width: 104,
587
415
  height: 148,
588
416
  borderRadius: 12,
@@ -590,6 +418,7 @@ const styles = StyleSheet.create({
590
418
  backgroundColor: '#000',
591
419
  borderWidth: 1,
592
420
  borderColor: 'rgba(255,255,255,0.18)',
421
+ zIndex: 5,
593
422
  },
594
423
  pipVideo: {
595
424
  flex: 1,
@@ -597,20 +426,20 @@ const styles = StyleSheet.create({
597
426
  minimizeButton: {
598
427
  position: 'absolute',
599
428
  left: 16,
600
- top: 16,
601
429
  width: 36,
602
430
  height: 36,
603
431
  borderRadius: 18,
604
432
  alignItems: 'center',
605
433
  justifyContent: 'center',
606
434
  backgroundColor: 'rgba(0,0,0,0.45)',
435
+ zIndex: 6,
607
436
  },
608
437
  controls: {
609
438
  flexDirection: 'row',
610
439
  alignItems: 'center',
611
440
  justifyContent: 'center',
612
441
  gap: 16,
613
- paddingVertical: 20,
442
+ paddingTop: 20,
614
443
  paddingHorizontal: 16,
615
444
  },
616
445
  controlButton: {
@@ -629,11 +458,10 @@ const styles = StyleSheet.create({
629
458
  textAlign: 'center',
630
459
  paddingHorizontal: 24,
631
460
  },
632
- // Dismissible, non-blocking — the call is still connecting/connected
633
- // underneath; this only flags that camera/mic didn't come up.
461
+ // Dismissible, non-blocking: the call is still up underneath; this only
462
+ // flags that camera/mic didn't come up.
634
463
  deviceErrorBanner: {
635
464
  position: 'absolute',
636
- top: 16,
637
465
  left: 16,
638
466
  right: 16,
639
467
  zIndex: 10,
@@ -650,9 +478,9 @@ const styles = StyleSheet.create({
650
478
  miniBar: {
651
479
  flexDirection: 'row',
652
480
  alignItems: 'center',
653
- gap: 12,
654
- paddingHorizontal: 16,
655
- paddingVertical: 12,
481
+ gap: 10,
482
+ paddingHorizontal: 12,
483
+ paddingVertical: 10,
656
484
  borderRadius: 16,
657
485
  backgroundColor: SURFACE_DARK,
658
486
  },
@@ -667,6 +495,7 @@ const styles = StyleSheet.create({
667
495
  miniStatus: {
668
496
  color: TEXT_MUTED,
669
497
  fontSize: 12,
498
+ fontVariant: ['tabular-nums'],
670
499
  },
671
500
  miniButton: {
672
501
  width: 40,