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