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