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