@ethora/chat-component-rn 26.10.2 → 26.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/lib/commonjs/components/Header/HeaderRoomList.js +72 -69
  2. package/lib/commonjs/components/Header/HeaderRoomList.js.map +1 -1
  3. package/lib/commonjs/components/MainComponents/ChatHeader.js +13 -11
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/MessageContainer.js +10 -2
  6. package/lib/commonjs/components/MainComponents/MessageContainer.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/RoomList.js +8 -18
  8. package/lib/commonjs/components/MainComponents/RoomList.js.map +1 -1
  9. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +17 -14
  10. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  11. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js +131 -17
  12. package/lib/commonjs/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  13. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js +6 -1
  14. package/lib/commonjs/components/RoomComponents/ChatRoomItem.js.map +1 -1
  15. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +203 -0
  16. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
  17. package/lib/commonjs/components/VideoCalls/CallButtons.js +3 -1
  18. package/lib/commonjs/components/VideoCalls/CallButtons.js.map +1 -1
  19. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +123 -129
  20. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  21. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +212 -414
  22. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  23. package/lib/commonjs/components/VideoCalls/callActions.js +61 -0
  24. package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -0
  25. package/lib/commonjs/components/VideoCalls/callSessionController.js +461 -0
  26. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -0
  27. package/lib/commonjs/components/VideoCalls/callUiStore.js +91 -0
  28. package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
  29. package/lib/commonjs/components/VideoCalls/livekitRuntime.js +1 -0
  30. package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
  31. package/lib/commonjs/components/VideoCalls/useDraggable.js +59 -0
  32. package/lib/commonjs/components/VideoCalls/useDraggable.js.map +1 -0
  33. package/lib/commonjs/helpers/createRoomFromApi.js +4 -0
  34. package/lib/commonjs/helpers/createRoomFromApi.js.map +1 -1
  35. package/lib/commonjs/helpers/incomingCallFromPush.js +1 -1
  36. package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
  37. package/lib/commonjs/helpers/roomUserCount.js +7 -1
  38. package/lib/commonjs/helpers/roomUserCount.js.map +1 -1
  39. package/lib/commonjs/helpers/uniqueMembers.js +36 -0
  40. package/lib/commonjs/helpers/uniqueMembers.js.map +1 -0
  41. package/lib/commonjs/hooks/useLeaveRoom.js +52 -0
  42. package/lib/commonjs/hooks/useLeaveRoom.js.map +1 -0
  43. package/lib/commonjs/i18n/strings.js +24 -0
  44. package/lib/commonjs/i18n/strings.js.map +1 -1
  45. package/lib/commonjs/networking/api-requests/rooms.api.js +36 -1
  46. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  47. package/lib/commonjs/networking/callTokenStanza.js +47 -3
  48. package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
  49. package/lib/commonjs/networking/xmppClient.js +9 -2
  50. package/lib/commonjs/networking/xmppClient.js.map +1 -1
  51. package/lib/commonjs/version.js +1 -1
  52. package/lib/module/components/Header/HeaderRoomList.js +73 -70
  53. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  54. package/lib/module/components/MainComponents/ChatHeader.js +15 -13
  55. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  56. package/lib/module/components/MainComponents/MessageContainer.js +10 -2
  57. package/lib/module/components/MainComponents/MessageContainer.js.map +1 -1
  58. package/lib/module/components/MainComponents/RoomList.js +8 -18
  59. package/lib/module/components/MainComponents/RoomList.js.map +1 -1
  60. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +18 -15
  61. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  62. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js +132 -18
  63. package/lib/module/components/Modals/UserProfileModal/UserProfileModal.js.map +1 -1
  64. package/lib/module/components/RoomComponents/ChatRoomItem.js +6 -1
  65. package/lib/module/components/RoomComponents/ChatRoomItem.js.map +1 -1
  66. package/lib/module/components/VideoCalls/ActiveCallBar.js +195 -0
  67. package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
  68. package/lib/module/components/VideoCalls/CallButtons.js +3 -1
  69. package/lib/module/components/VideoCalls/CallButtons.js.map +1 -1
  70. package/lib/module/components/VideoCalls/VideoCallOverlay.js +125 -131
  71. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  72. package/lib/module/components/VideoCalls/VideoCallSession.js +213 -417
  73. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  74. package/lib/module/components/VideoCalls/callActions.js +55 -0
  75. package/lib/module/components/VideoCalls/callActions.js.map +1 -0
  76. package/lib/module/components/VideoCalls/callSessionController.js +450 -0
  77. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -0
  78. package/lib/module/components/VideoCalls/callUiStore.js +84 -0
  79. package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
  80. package/lib/module/components/VideoCalls/livekitRuntime.js +1 -0
  81. package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
  82. package/lib/module/components/VideoCalls/useDraggable.js +55 -0
  83. package/lib/module/components/VideoCalls/useDraggable.js.map +1 -0
  84. package/lib/module/helpers/createRoomFromApi.js +4 -0
  85. package/lib/module/helpers/createRoomFromApi.js.map +1 -1
  86. package/lib/module/helpers/incomingCallFromPush.js +1 -1
  87. package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
  88. package/lib/module/helpers/roomUserCount.js +7 -1
  89. package/lib/module/helpers/roomUserCount.js.map +1 -1
  90. package/lib/module/helpers/uniqueMembers.js +31 -0
  91. package/lib/module/helpers/uniqueMembers.js.map +1 -0
  92. package/lib/module/hooks/useLeaveRoom.js +48 -0
  93. package/lib/module/hooks/useLeaveRoom.js.map +1 -0
  94. package/lib/module/i18n/strings.js +24 -0
  95. package/lib/module/i18n/strings.js.map +1 -1
  96. package/lib/module/networking/api-requests/rooms.api.js +35 -1
  97. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  98. package/lib/module/networking/callTokenStanza.js +47 -3
  99. package/lib/module/networking/callTokenStanza.js.map +1 -1
  100. package/lib/module/networking/xmppClient.js +8 -1
  101. package/lib/module/networking/xmppClient.js.map +1 -1
  102. package/lib/module/version.js +1 -1
  103. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  104. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  105. package/lib/typescript/components/MainComponents/MessageContainer.d.ts.map +1 -1
  106. package/lib/typescript/components/MainComponents/RoomList.d.ts.map +1 -1
  107. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  108. package/lib/typescript/components/Modals/UserProfileModal/UserProfileModal.d.ts.map +1 -1
  109. package/lib/typescript/components/RoomComponents/ChatRoomItem.d.ts.map +1 -1
  110. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +21 -0
  111. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
  112. package/lib/typescript/components/VideoCalls/CallButtons.d.ts.map +1 -1
  113. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts +0 -6
  114. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  115. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +15 -22
  116. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  117. package/lib/typescript/components/VideoCalls/callActions.d.ts +15 -0
  118. package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -0
  119. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +63 -0
  120. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -0
  121. package/lib/typescript/components/VideoCalls/callUiStore.d.ts +24 -0
  122. package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
  123. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +2 -0
  124. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
  125. package/lib/typescript/components/VideoCalls/useDraggable.d.ts +19 -0
  126. package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +1 -0
  127. package/lib/typescript/helpers/createRoomFromApi.d.ts.map +1 -1
  128. package/lib/typescript/helpers/roomUserCount.d.ts +1 -0
  129. package/lib/typescript/helpers/roomUserCount.d.ts.map +1 -1
  130. package/lib/typescript/helpers/uniqueMembers.d.ts +11 -0
  131. package/lib/typescript/helpers/uniqueMembers.d.ts.map +1 -0
  132. package/lib/typescript/hooks/useLeaveRoom.d.ts +9 -0
  133. package/lib/typescript/hooks/useLeaveRoom.d.ts.map +1 -0
  134. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  135. package/lib/typescript/networking/api-requests/rooms.api.d.ts +8 -0
  136. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  137. package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
  138. package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
  139. package/lib/typescript/version.d.ts +1 -1
  140. package/package.json +1 -1
  141. package/src/components/Header/HeaderRoomList.tsx +66 -61
  142. package/src/components/MainComponents/ChatHeader.tsx +13 -8
  143. package/src/components/MainComponents/MessageContainer.tsx +14 -1
  144. package/src/components/MainComponents/RoomList.tsx +8 -14
  145. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +16 -13
  146. package/src/components/Modals/UserProfileModal/UserProfileModal.tsx +167 -36
  147. package/src/components/RoomComponents/ChatRoomItem.tsx +8 -1
  148. package/src/components/VideoCalls/ActiveCallBar.tsx +176 -0
  149. package/src/components/VideoCalls/CallButtons.tsx +5 -1
  150. package/src/components/VideoCalls/VideoCallOverlay.tsx +153 -183
  151. package/src/components/VideoCalls/VideoCallSession.tsx +227 -439
  152. package/src/components/VideoCalls/callActions.ts +63 -0
  153. package/src/components/VideoCalls/callSessionController.ts +439 -0
  154. package/src/components/VideoCalls/callUiStore.ts +76 -0
  155. package/src/components/VideoCalls/livekitRuntime.ts +3 -0
  156. package/src/components/VideoCalls/useDraggable.ts +46 -0
  157. package/src/helpers/createRoomFromApi.tsx +2 -0
  158. package/src/helpers/incomingCallFromPush.ts +2 -2
  159. package/src/helpers/roomUserCount.ts +6 -2
  160. package/src/helpers/uniqueMembers.ts +26 -0
  161. package/src/hooks/useLeaveRoom.ts +42 -0
  162. package/src/i18n/strings.ts +24 -0
  163. package/src/networking/api-requests/rooms.api.ts +28 -1
  164. package/src/networking/callTokenStanza.ts +46 -6
  165. package/src/networking/xmppClient.ts +10 -1
  166. package/src/version.ts +1 -1
@@ -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, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
5
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { ProfileImagePlaceholder } from '../MainComponents/ProfileImagePlaceholder';
6
- import { CameraOffIcon, CameraOnIcon, HangUpIcon, MicOffIcon, MicOnIcon, MinimizeIcon, ExpandIcon, SpeakerIcon, SpeakerOffIcon, SwitchCameraIcon } from './CallIcons';
7
- import { ensureLiveKitGlobals, loadLiveKit } from './livekitRuntime';
7
+ import { CameraOffIcon, CameraOnIcon, HangUpIcon, MicOffIcon, MicOnIcon, MinimizeIcon, SpeakerIcon, SpeakerOffIcon, SwitchCameraIcon } from './CallIcons';
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,60 @@ 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
+ export 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));
36
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}`;
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
- minimized = false,
60
62
  onToggleMinimize,
61
- onConnected,
62
- onError,
63
63
  onHangup
64
64
  }) => {
65
65
  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
-
66
+ const insets = useSafeAreaInsets();
67
+ const runtime = loadLiveKit();
68
+ const session = useCallSession();
69
+ const selfView = useDraggable();
70
+ const elapsed = useSharedCallElapsed(session.anchorAt);
71
+ // Telegram-style: tap the small tile to swap it with the big one.
72
+ const [swapped, setSwapped] = useState(false);
339
73
  if (!runtime) {
340
74
  return /*#__PURE__*/_jsx(View, {
341
75
  style: [styles.canvas, styles.centered],
@@ -348,64 +82,46 @@ export const VideoCallSession = ({
348
82
  const {
349
83
  VideoTrack
350
84
  } = runtime;
351
- const displayName = remoteName || peerName || t('call.unknownCaller');
85
+ const {
86
+ connected,
87
+ remoteJoined,
88
+ remoteVideo,
89
+ localVideo,
90
+ micOn,
91
+ cameraOn,
92
+ speakerOn,
93
+ deviceError,
94
+ remoteMicMuted,
95
+ facingMode
96
+ } = session;
97
+ // The call slice's name (the chat title) first, as web does: LiveKit's
98
+ // participant name is often just the opaque xmpp identity.
99
+ const displayName = peerName || session.remoteName || t('call.unknownCaller');
352
100
  const isVideo = kind === 'video';
353
101
  const showRemoteVideo = isVideo && !!remoteVideo;
354
- if (minimized) {
355
- return /*#__PURE__*/_jsxs(View, {
356
- style: styles.miniBar,
357
- children: [/*#__PURE__*/_jsxs(View, {
358
- style: styles.miniInfo,
359
- children: [/*#__PURE__*/_jsx(Text, {
360
- numberOfLines: 1,
361
- style: styles.miniName,
362
- children: displayName
363
- }), /*#__PURE__*/_jsx(Text, {
364
- style: styles.miniStatus,
365
- children: connected ? remoteJoined ? t('call.status.inCall') : t('call.status.waitingForPeer') : t('call.status.connecting')
366
- })]
367
- }), /*#__PURE__*/_jsx(TouchableOpacity, {
368
- accessibilityRole: "button",
369
- accessibilityLabel: t('call.action.expand'),
370
- onPress: onToggleMinimize,
371
- style: [styles.miniButton, {
372
- backgroundColor: CONTROL_IDLE
373
- }],
374
- children: /*#__PURE__*/_jsx(ExpandIcon, {
375
- color: TEXT_ON_DARK,
376
- size: 20
377
- })
378
- }), /*#__PURE__*/_jsx(TouchableOpacity, {
379
- accessibilityRole: "button",
380
- accessibilityLabel: t('call.action.hangUp'),
381
- onPress: onHangup,
382
- style: [styles.miniButton, {
383
- backgroundColor: DANGER
384
- }],
385
- children: icons?.hangup ?? /*#__PURE__*/_jsx(HangUpIcon, {
386
- color: TEXT_ON_DARK,
387
- size: 20
388
- })
389
- })]
390
- });
391
- }
102
+ const localLive = isVideo && cameraOn && !!localVideo;
103
+ // Swapping only means something while both sides have a picture; if
104
+ // either goes away, fall back to the default layout on its own.
105
+ const canSwap = showRemoteVideo && localLive;
106
+ const isSwapped = swapped && canSwap;
107
+ const mainVideo = isSwapped ? localVideo : showRemoteVideo ? remoteVideo : null;
108
+ const pipVideo = isSwapped ? remoteVideo : localLive ? localVideo : null;
109
+ // Only the front camera is mirrored, as in every camera app.
110
+ const selfMirror = facingMode === 'user';
111
+ const statusText = elapsed !== null ? formatCallDuration(elapsed) : !connected ? t('call.status.connecting') : !remoteJoined ? t('call.status.waitingForPeer') : t('call.status.inCall');
112
+ const peerMutedLabel = t('call.peerMuted', {
113
+ name: displayName
114
+ });
392
115
  return /*#__PURE__*/_jsxs(View, {
393
116
  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, {
117
+ children: [/*#__PURE__*/_jsxs(View, {
404
118
  style: styles.stage,
405
- children: [showRemoteVideo ? /*#__PURE__*/_jsx(VideoTrack, {
406
- trackRef: remoteVideo,
119
+ children: [mainVideo ? /*#__PURE__*/_jsx(VideoTrack, {
120
+ trackRef: mainVideo,
407
121
  style: styles.remoteVideo,
408
- objectFit: "cover"
122
+ objectFit: "cover",
123
+ mirror: isSwapped && selfMirror,
124
+ zOrder: 0
409
125
  }) : /*#__PURE__*/_jsxs(View, {
410
126
  style: styles.centered,
411
127
  children: [/*#__PURE__*/_jsx(ProfileImagePlaceholder, {
@@ -416,36 +132,95 @@ export const VideoCallSession = ({
416
132
  children: displayName
417
133
  }), /*#__PURE__*/_jsx(Text, {
418
134
  style: styles.stageStatus,
419
- children: !connected ? t('call.status.connecting') : !remoteJoined ? t('call.status.waitingForPeer') : isVideo ? t('call.status.cameraOff') : t('call.status.inCall')
135
+ children: isVideo && remoteJoined && elapsed !== null ? t('call.status.cameraOff') : statusText
420
136
  }), !connected && /*#__PURE__*/_jsx(ActivityIndicator, {
421
137
  color: primaryColor,
422
138
  style: styles.spinner,
423
139
  size: "small"
140
+ }), !micOn && /*#__PURE__*/_jsx(View, {
141
+ style: [styles.pill, styles.pillDanger],
142
+ children: /*#__PURE__*/_jsx(Text, {
143
+ style: styles.pillDangerText,
144
+ children: t('call.selfMuted')
145
+ })
146
+ }), remoteMicMuted && !showRemoteVideo && /*#__PURE__*/_jsx(View, {
147
+ style: styles.pill,
148
+ children: /*#__PURE__*/_jsx(Text, {
149
+ style: styles.pillText,
150
+ children: peerMutedLabel
151
+ })
152
+ })]
153
+ }), mainVideo && /*#__PURE__*/_jsxs(View, {
154
+ style: [styles.header, {
155
+ paddingTop: 14 + insets.top
156
+ }],
157
+ children: [/*#__PURE__*/_jsx(Text, {
158
+ style: styles.headerName,
159
+ numberOfLines: 1,
160
+ children: displayName
161
+ }), /*#__PURE__*/_jsx(Text, {
162
+ style: styles.headerStatus,
163
+ children: statusText
164
+ }), remoteMicMuted && /*#__PURE__*/_jsxs(View, {
165
+ style: [styles.pill, styles.headerPill],
166
+ children: [/*#__PURE__*/_jsx(MicOffIcon, {
167
+ color: TEXT_ON_DARK,
168
+ size: 14
169
+ }), /*#__PURE__*/_jsx(Text, {
170
+ style: styles.pillText,
171
+ children: peerMutedLabel
172
+ })]
424
173
  })]
425
- }), isVideo && cameraOn && localVideo && /*#__PURE__*/_jsx(View, {
426
- style: styles.pip,
427
- children: /*#__PURE__*/_jsx(VideoTrack, {
428
- trackRef: localVideo,
174
+ }), pipVideo && /*#__PURE__*/_jsx(Animated.View, {
175
+ ...selfView.panHandlers,
176
+ style: [styles.pip, {
177
+ top: 16 + insets.top
178
+ }, selfView.style],
179
+ children: /*#__PURE__*/_jsx(Pressable, {
429
180
  style: styles.pipVideo,
430
- objectFit: "cover",
431
- mirror: true
181
+ disabled: !canSwap,
182
+ onPress: () => setSwapped(value => !value),
183
+ accessibilityRole: "button",
184
+ accessibilityLabel: t('call.action.swapVideo'),
185
+ children: /*#__PURE__*/_jsx(VideoTrack, {
186
+ trackRef: pipVideo,
187
+ style: styles.pipVideo,
188
+ objectFit: "cover",
189
+ mirror: !isSwapped && selfMirror,
190
+ zOrder: 1
191
+ })
432
192
  })
433
193
  }), onToggleMinimize && /*#__PURE__*/_jsx(TouchableOpacity, {
434
194
  accessibilityRole: "button",
435
195
  accessibilityLabel: t('call.action.minimize'),
436
196
  onPress: onToggleMinimize,
437
- style: styles.minimizeButton,
197
+ style: [styles.minimizeButton, {
198
+ top: 16 + insets.top
199
+ }],
438
200
  children: /*#__PURE__*/_jsx(MinimizeIcon, {
439
201
  color: TEXT_ON_DARK,
440
202
  size: 20
441
203
  })
204
+ }), !!deviceError && /*#__PURE__*/_jsx(TouchableOpacity, {
205
+ style: [styles.deviceErrorBanner, {
206
+ top: 64 + insets.top
207
+ }],
208
+ onPress: dismissCallDeviceError,
209
+ accessibilityRole: "button",
210
+ children: /*#__PURE__*/_jsx(Text, {
211
+ style: styles.deviceErrorText,
212
+ numberOfLines: 2,
213
+ children: deviceError
214
+ })
442
215
  })]
443
216
  }), /*#__PURE__*/_jsxs(View, {
444
- style: styles.controls,
217
+ style: [styles.controls, {
218
+ paddingBottom: 20 + insets.bottom
219
+ }],
445
220
  children: [/*#__PURE__*/_jsx(TouchableOpacity, {
446
221
  accessibilityRole: "button",
447
222
  accessibilityLabel: micOn ? t('call.action.muteMic') : t('call.action.unmuteMic'),
448
- onPress: toggleMic,
223
+ onPress: toggleCallMic,
449
224
  style: [styles.controlButton, {
450
225
  backgroundColor: micOn ? CONTROL_IDLE : DANGER
451
226
  }],
@@ -457,7 +232,7 @@ export const VideoCallSession = ({
457
232
  }), isVideo && /*#__PURE__*/_jsx(TouchableOpacity, {
458
233
  accessibilityRole: "button",
459
234
  accessibilityLabel: cameraOn ? t('call.action.stopCamera') : t('call.action.startCamera'),
460
- onPress: toggleCamera,
235
+ onPress: toggleCallCamera,
461
236
  style: [styles.controlButton, {
462
237
  backgroundColor: cameraOn ? CONTROL_IDLE : DANGER
463
238
  }],
@@ -469,7 +244,7 @@ export const VideoCallSession = ({
469
244
  }), isVideo && cameraOn && /*#__PURE__*/_jsx(TouchableOpacity, {
470
245
  accessibilityRole: "button",
471
246
  accessibilityLabel: t('call.action.switchCamera'),
472
- onPress: switchCamera,
247
+ onPress: switchCallCamera,
473
248
  style: [styles.controlButton, {
474
249
  backgroundColor: CONTROL_IDLE
475
250
  }],
@@ -479,7 +254,7 @@ export const VideoCallSession = ({
479
254
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
480
255
  accessibilityRole: "button",
481
256
  accessibilityLabel: speakerOn ? t('call.action.speakerOff') : t('call.action.speakerOn'),
482
- onPress: toggleSpeaker,
257
+ onPress: toggleCallSpeaker,
483
258
  style: [styles.controlButton, {
484
259
  backgroundColor: CONTROL_IDLE
485
260
  }],
@@ -509,7 +284,8 @@ const styles = StyleSheet.create({
509
284
  flex: 1,
510
285
  alignItems: 'center',
511
286
  justifyContent: 'center',
512
- gap: 12
287
+ gap: 12,
288
+ paddingHorizontal: 24
513
289
  },
514
290
  stage: {
515
291
  flex: 1,
@@ -523,26 +299,75 @@ const styles = StyleSheet.create({
523
299
  stageName: {
524
300
  color: TEXT_ON_DARK,
525
301
  fontSize: 22,
526
- marginTop: 16
302
+ marginTop: 16,
303
+ textAlign: 'center'
527
304
  },
528
305
  stageStatus: {
529
306
  color: TEXT_MUTED,
530
- fontSize: 14
307
+ fontSize: 14,
308
+ fontVariant: ['tabular-nums']
531
309
  },
532
310
  spinner: {
533
311
  marginTop: 8
534
312
  },
313
+ header: {
314
+ position: 'absolute',
315
+ top: 0,
316
+ left: 0,
317
+ right: 0,
318
+ paddingHorizontal: 64,
319
+ paddingBottom: 14,
320
+ alignItems: 'center',
321
+ backgroundColor: 'rgba(0,0,0,0.35)',
322
+ gap: 2
323
+ },
324
+ headerName: {
325
+ color: TEXT_ON_DARK,
326
+ fontSize: 15,
327
+ fontWeight: '600'
328
+ },
329
+ headerStatus: {
330
+ color: 'rgba(255,255,255,0.7)',
331
+ fontSize: 13,
332
+ fontVariant: ['tabular-nums']
333
+ },
334
+ headerPill: {
335
+ marginTop: 4,
336
+ backgroundColor: 'rgba(0,0,0,0.45)'
337
+ },
338
+ pill: {
339
+ flexDirection: 'row',
340
+ alignItems: 'center',
341
+ gap: 6,
342
+ paddingVertical: 4,
343
+ paddingHorizontal: 10,
344
+ borderRadius: 999,
345
+ backgroundColor: CONTROL_IDLE
346
+ },
347
+ pillText: {
348
+ color: TEXT_ON_DARK,
349
+ fontSize: 12
350
+ },
351
+ pillDanger: {
352
+ backgroundColor: 'rgba(229, 57, 53, 0.18)',
353
+ borderWidth: 1,
354
+ borderColor: 'rgba(229, 57, 53, 0.45)'
355
+ },
356
+ pillDangerText: {
357
+ color: '#FF8A80',
358
+ fontSize: 12
359
+ },
535
360
  pip: {
536
361
  position: 'absolute',
537
362
  right: 16,
538
- top: 16,
539
363
  width: 104,
540
364
  height: 148,
541
365
  borderRadius: 12,
542
366
  overflow: 'hidden',
543
367
  backgroundColor: '#000',
544
368
  borderWidth: 1,
545
- borderColor: 'rgba(255,255,255,0.18)'
369
+ borderColor: 'rgba(255,255,255,0.18)',
370
+ zIndex: 5
546
371
  },
547
372
  pipVideo: {
548
373
  flex: 1
@@ -550,20 +375,20 @@ const styles = StyleSheet.create({
550
375
  minimizeButton: {
551
376
  position: 'absolute',
552
377
  left: 16,
553
- top: 16,
554
378
  width: 36,
555
379
  height: 36,
556
380
  borderRadius: 18,
557
381
  alignItems: 'center',
558
382
  justifyContent: 'center',
559
- backgroundColor: 'rgba(0,0,0,0.45)'
383
+ backgroundColor: 'rgba(0,0,0,0.45)',
384
+ zIndex: 6
560
385
  },
561
386
  controls: {
562
387
  flexDirection: 'row',
563
388
  alignItems: 'center',
564
389
  justifyContent: 'center',
565
390
  gap: 16,
566
- paddingVertical: 20,
391
+ paddingTop: 20,
567
392
  paddingHorizontal: 16
568
393
  },
569
394
  controlButton: {
@@ -582,11 +407,10 @@ const styles = StyleSheet.create({
582
407
  textAlign: 'center',
583
408
  paddingHorizontal: 24
584
409
  },
585
- // Dismissible, non-blocking — the call is still connecting/connected
586
- // underneath; this only flags that camera/mic didn't come up.
410
+ // Dismissible, non-blocking: the call is still up underneath; this only
411
+ // flags that camera/mic didn't come up.
587
412
  deviceErrorBanner: {
588
413
  position: 'absolute',
589
- top: 16,
590
414
  left: 16,
591
415
  right: 16,
592
416
  zIndex: 10,
@@ -599,34 +423,6 @@ const styles = StyleSheet.create({
599
423
  color: TEXT_ON_DARK,
600
424
  fontSize: 13,
601
425
  textAlign: 'center'
602
- },
603
- miniBar: {
604
- flexDirection: 'row',
605
- alignItems: 'center',
606
- gap: 12,
607
- paddingHorizontal: 16,
608
- paddingVertical: 12,
609
- borderRadius: 16,
610
- backgroundColor: SURFACE_DARK
611
- },
612
- miniInfo: {
613
- flex: 1,
614
- minWidth: 0
615
- },
616
- miniName: {
617
- color: TEXT_ON_DARK,
618
- fontSize: 15
619
- },
620
- miniStatus: {
621
- color: TEXT_MUTED,
622
- fontSize: 12
623
- },
624
- miniButton: {
625
- width: 40,
626
- height: 40,
627
- borderRadius: 20,
628
- alignItems: 'center',
629
- justifyContent: 'center'
630
426
  }
631
427
  });
632
428
  export default VideoCallSession;