@ethora/chat-component-rn 26.10.3 → 26.10.5

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 (113) 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 +2 -1
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js +248 -0
  6. package/lib/commonjs/components/VideoCalls/ActiveCallBar.js.map +1 -0
  7. package/lib/commonjs/components/VideoCalls/CallIcons.js +41 -16
  8. package/lib/commonjs/components/VideoCalls/CallIcons.js.map +1 -1
  9. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +193 -37
  10. package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  11. package/lib/commonjs/components/VideoCalls/VideoCallSession.js +71 -109
  12. package/lib/commonjs/components/VideoCalls/VideoCallSession.js.map +1 -1
  13. package/lib/commonjs/components/VideoCalls/callSessionController.js +74 -21
  14. package/lib/commonjs/components/VideoCalls/callSessionController.js.map +1 -1
  15. package/lib/commonjs/components/VideoCalls/callUiStore.js +120 -0
  16. package/lib/commonjs/components/VideoCalls/callUiStore.js.map +1 -0
  17. package/lib/commonjs/components/VideoCalls/livekitRuntime.js +10 -1
  18. package/lib/commonjs/components/VideoCalls/livekitRuntime.js.map +1 -1
  19. package/lib/commonjs/components/VideoCalls/useCallKeep.js +197 -28
  20. package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
  21. package/lib/commonjs/components/VideoCalls/useOngoingCallNotification.js +88 -0
  22. package/lib/commonjs/components/VideoCalls/useOngoingCallNotification.js.map +1 -0
  23. package/lib/commonjs/components/VideoCalls/usePipTile.js +232 -0
  24. package/lib/commonjs/components/VideoCalls/usePipTile.js.map +1 -0
  25. package/lib/commonjs/helpers/updateMessagesTillLast.js +3 -1
  26. package/lib/commonjs/helpers/updateMessagesTillLast.js.map +1 -1
  27. package/lib/commonjs/i18n/strings.js +54 -0
  28. package/lib/commonjs/i18n/strings.js.map +1 -1
  29. package/lib/commonjs/main.js.map +1 -1
  30. package/lib/commonjs/version.js +1 -1
  31. package/lib/module/components/Header/HeaderRoomList.js +73 -70
  32. package/lib/module/components/Header/HeaderRoomList.js.map +1 -1
  33. package/lib/module/components/MainComponents/ChatHeader.js +2 -1
  34. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  35. package/lib/module/components/VideoCalls/ActiveCallBar.js +240 -0
  36. package/lib/module/components/VideoCalls/ActiveCallBar.js.map +1 -0
  37. package/lib/module/components/VideoCalls/CallIcons.js +38 -15
  38. package/lib/module/components/VideoCalls/CallIcons.js.map +1 -1
  39. package/lib/module/components/VideoCalls/VideoCallOverlay.js +195 -39
  40. package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
  41. package/lib/module/components/VideoCalls/VideoCallSession.js +72 -111
  42. package/lib/module/components/VideoCalls/VideoCallSession.js.map +1 -1
  43. package/lib/module/components/VideoCalls/callSessionController.js +71 -19
  44. package/lib/module/components/VideoCalls/callSessionController.js.map +1 -1
  45. package/lib/module/components/VideoCalls/callUiStore.js +111 -0
  46. package/lib/module/components/VideoCalls/callUiStore.js.map +1 -0
  47. package/lib/module/components/VideoCalls/livekitRuntime.js +10 -1
  48. package/lib/module/components/VideoCalls/livekitRuntime.js.map +1 -1
  49. package/lib/module/components/VideoCalls/useCallKeep.js +198 -29
  50. package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
  51. package/lib/module/components/VideoCalls/useOngoingCallNotification.js +84 -0
  52. package/lib/module/components/VideoCalls/useOngoingCallNotification.js.map +1 -0
  53. package/lib/module/components/VideoCalls/usePipTile.js +228 -0
  54. package/lib/module/components/VideoCalls/usePipTile.js.map +1 -0
  55. package/lib/module/helpers/updateMessagesTillLast.js +3 -1
  56. package/lib/module/helpers/updateMessagesTillLast.js.map +1 -1
  57. package/lib/module/i18n/strings.js +54 -0
  58. package/lib/module/i18n/strings.js.map +1 -1
  59. package/lib/module/main.js.map +1 -1
  60. package/lib/module/version.js +1 -1
  61. package/lib/typescript/components/Header/HeaderRoomList.d.ts.map +1 -1
  62. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  63. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts +27 -0
  64. package/lib/typescript/components/VideoCalls/ActiveCallBar.d.ts.map +1 -0
  65. package/lib/typescript/components/VideoCalls/CallIcons.d.ts +9 -0
  66. package/lib/typescript/components/VideoCalls/CallIcons.d.ts.map +1 -1
  67. package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
  68. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts +7 -3
  69. package/lib/typescript/components/VideoCalls/VideoCallSession.d.ts.map +1 -1
  70. package/lib/typescript/components/VideoCalls/callSessionController.d.ts +9 -0
  71. package/lib/typescript/components/VideoCalls/callSessionController.d.ts.map +1 -1
  72. package/lib/typescript/components/VideoCalls/callUiStore.d.ts +37 -0
  73. package/lib/typescript/components/VideoCalls/callUiStore.d.ts.map +1 -0
  74. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts +7 -0
  75. package/lib/typescript/components/VideoCalls/livekitRuntime.d.ts.map +1 -1
  76. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +12 -0
  77. package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
  78. package/lib/typescript/components/VideoCalls/useOngoingCallNotification.d.ts +10 -0
  79. package/lib/typescript/components/VideoCalls/useOngoingCallNotification.d.ts.map +1 -0
  80. package/lib/typescript/components/VideoCalls/usePipTile.d.ts +46 -0
  81. package/lib/typescript/components/VideoCalls/usePipTile.d.ts.map +1 -0
  82. package/lib/typescript/helpers/updateMessagesTillLast.d.ts.map +1 -1
  83. package/lib/typescript/i18n/strings.d.ts.map +1 -1
  84. package/lib/typescript/main.d.ts +1 -1
  85. package/lib/typescript/main.d.ts.map +1 -1
  86. package/lib/typescript/types/types.d.ts +40 -0
  87. package/lib/typescript/types/types.d.ts.map +1 -1
  88. package/lib/typescript/version.d.ts +1 -1
  89. package/package.json +1 -1
  90. package/src/components/Header/HeaderRoomList.tsx +66 -61
  91. package/src/components/MainComponents/ChatHeader.tsx +3 -0
  92. package/src/components/VideoCalls/ActiveCallBar.tsx +222 -0
  93. package/src/components/VideoCalls/CallIcons.tsx +36 -12
  94. package/src/components/VideoCalls/VideoCallOverlay.tsx +211 -37
  95. package/src/components/VideoCalls/VideoCallSession.tsx +84 -93
  96. package/src/components/VideoCalls/callSessionController.ts +73 -24
  97. package/src/components/VideoCalls/callUiStore.ts +106 -0
  98. package/src/components/VideoCalls/livekitRuntime.ts +16 -0
  99. package/src/components/VideoCalls/useCallKeep.ts +204 -33
  100. package/src/components/VideoCalls/useOngoingCallNotification.ts +113 -0
  101. package/src/components/VideoCalls/usePipTile.ts +214 -0
  102. package/src/helpers/updateMessagesTillLast.tsx +2 -1
  103. package/src/i18n/strings.ts +54 -0
  104. package/src/main.ts +3 -0
  105. package/src/types/types.ts +48 -0
  106. package/src/version.ts +1 -1
  107. package/lib/commonjs/components/VideoCalls/useDraggable.js +0 -59
  108. package/lib/commonjs/components/VideoCalls/useDraggable.js.map +0 -1
  109. package/lib/module/components/VideoCalls/useDraggable.js +0 -55
  110. package/lib/module/components/VideoCalls/useDraggable.js.map +0 -1
  111. package/lib/typescript/components/VideoCalls/useDraggable.d.ts +0 -19
  112. package/lib/typescript/components/VideoCalls/useDraggable.d.ts.map +0 -1
  113. package/src/components/VideoCalls/useDraggable.ts +0 -46
@@ -6,21 +6,23 @@ import React, {
6
6
  useEffect,
7
7
  useMemo,
8
8
  useRef,
9
- useState,
10
9
  } from 'react';
11
10
  import {
12
11
  Animated,
13
12
  BackHandler,
14
13
  Easing,
15
14
  Modal,
15
+ PanResponder,
16
16
  Platform,
17
17
  StyleSheet,
18
18
  Text,
19
19
  TouchableOpacity,
20
+ useWindowDimensions,
20
21
  Vibration,
21
22
  View,
22
23
  } from 'react-native';
23
24
  import { useDispatch, useSelector } from 'react-redux';
25
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
24
26
  import { RootState } from '../../roomStore';
25
27
  import {
26
28
  acceptIncomingCall,
@@ -34,7 +36,14 @@ import type { ChatTheme } from '../../theme/theme';
34
36
  import { VideoCallSession } from './VideoCallSession';
35
37
  import { startCallSession } from './callSessionController';
36
38
  import { declineActiveCall, terminateActiveCall } from './callActions';
37
- import { useDraggable } from './useDraggable';
39
+ import {
40
+ CALL_BAR_HEIGHT,
41
+ CALL_BAR_MARGIN_TOP,
42
+ CALL_BAR_MARGIN_X,
43
+ CALL_BAR_RADIUS,
44
+ DockedCallBar,
45
+ } from './ActiveCallBar';
46
+ import { setCallMinimized, useCallUi } from './callUiStore';
38
47
  import { ProfileImagePlaceholder } from '../MainComponents/ProfileImagePlaceholder';
39
48
  import {
40
49
  AudioCallIcon,
@@ -44,6 +53,7 @@ import {
44
53
  import { VideoCallIcons } from '../../types/types';
45
54
  import { useT } from '../../i18n/useT';
46
55
  import { useCallKeep } from './useCallKeep';
56
+ import { useOngoingCallNotification } from './useOngoingCallNotification';
47
57
 
48
58
  const DEFAULT_PRIMARY = '#0052CD';
49
59
  const DANGER = '#E53935';
@@ -250,6 +260,160 @@ const RingingCard: FC<RingingCardProps> = ({
250
260
  );
251
261
  };
252
262
 
263
+ /**
264
+ * The full-screen call, collapsible into the header strip, Telegram-style:
265
+ * pulling it up morphs the whole screen into the green bubble under the
266
+ * header (position, width, height and corner radius all travel together,
267
+ * and the bubble's colour fades in on the way), so it visibly *becomes* the
268
+ * strip rather than leaving and the strip popping in. Let go past a quarter
269
+ * of the way, or flick, and it finishes; released early it springs back.
270
+ * The minimize button runs the same morph; expanding from the strip runs it
271
+ * backwards.
272
+ *
273
+ * Buttons and the draggable video tile handle their own touches; a drag
274
+ * anywhere else on the screen is ours, and only its upward part counts.
275
+ */
276
+ const CollapsibleCallSurface: FC<{
277
+ enterFromTop: boolean;
278
+ barColor: string;
279
+ onCollapsed: () => void;
280
+ children: (collapse: () => void) => ReactNode;
281
+ }> = ({ enterFromTop, barColor, onCollapsed, children }) => {
282
+ const { width, height } = useWindowDimensions();
283
+ const insets = useSafeAreaInsets();
284
+ const { barAnchorY } = useCallUi();
285
+ // Where the bubble sits: under the hosting header, or under the status
286
+ // bar when no header is on screen (the docked fallback).
287
+ const target = {
288
+ top: (barAnchorY ?? insets.top) + CALL_BAR_MARGIN_TOP,
289
+ side: CALL_BAR_MARGIN_X,
290
+ height: CALL_BAR_HEIGHT,
291
+ };
292
+ // 0 = full screen, 1 = the bubble.
293
+ const progress = useRef(new Animated.Value(enterFromTop ? 1 : 0)).current;
294
+ const collapsingRef = useRef(false);
295
+ const onCollapsedRef = useRef(onCollapsed);
296
+ onCollapsedRef.current = onCollapsed;
297
+ const heightRef = useRef(height);
298
+ heightRef.current = height;
299
+
300
+ useEffect(() => {
301
+ if (!enterFromTop) {return;}
302
+ Animated.timing(progress, {
303
+ toValue: 0,
304
+ duration: 300,
305
+ easing: Easing.out(Easing.cubic),
306
+ useNativeDriver: false,
307
+ }).start();
308
+ // Mount-only: the entrance plays once.
309
+ // eslint-disable-next-line react-hooks/exhaustive-deps
310
+ }, []);
311
+
312
+ const collapse = useCallback(() => {
313
+ if (collapsingRef.current) {return;}
314
+ collapsingRef.current = true;
315
+ Animated.timing(progress, {
316
+ toValue: 1,
317
+ duration: 280,
318
+ easing: Easing.out(Easing.cubic),
319
+ useNativeDriver: false,
320
+ }).start(() => onCollapsedRef.current());
321
+ }, [progress]);
322
+
323
+ const panHandlers = useMemo(() => {
324
+ const springBack = () =>
325
+ Animated.spring(progress, {
326
+ toValue: 0,
327
+ useNativeDriver: false,
328
+ damping: 22,
329
+ stiffness: 260,
330
+ }).start();
331
+ // How far the finger travels for the full morph.
332
+ const travel = () => heightRef.current * 0.6;
333
+ return PanResponder.create({
334
+ // Claim the touch as soon as it starts on the call screen itself
335
+ // (buttons and the video tile are deeper and win their own touches).
336
+ // Waiting for a move does not work here: the Modal's content sits
337
+ // under the host's screens in the React tree, and an ancestor there
338
+ // (a Pressable / keyboard-dismiss wrapper) takes the touch on start,
339
+ // after which no descendant is ever asked again.
340
+ onStartShouldSetPanResponder: () => !collapsingRef.current,
341
+ onMoveShouldSetPanResponder: () => !collapsingRef.current,
342
+ onPanResponderTerminationRequest: () => false,
343
+ onPanResponderMove: (_event, gesture) => {
344
+ progress.setValue(Math.min(1, Math.max(0, -gesture.dy / travel())));
345
+ },
346
+ onPanResponderRelease: (_event, gesture) => {
347
+ if (-gesture.dy / travel() > 0.25 || gesture.vy < -0.8) {
348
+ collapse();
349
+ } else {
350
+ springBack();
351
+ }
352
+ },
353
+ onPanResponderTerminate: springBack,
354
+ }).panHandlers;
355
+ }, [progress, collapse]);
356
+
357
+ const at = (from: number, to: number) =>
358
+ progress.interpolate({ inputRange: [0, 1], outputRange: [from, to] });
359
+
360
+ return (
361
+ <Animated.View
362
+ {...panHandlers}
363
+ style={[
364
+ styles.collapsible,
365
+ {
366
+ top: at(0, target.top),
367
+ left: at(0, target.side),
368
+ width: at(width, width - target.side * 2),
369
+ height: at(height, target.height),
370
+ // Rounded as soon as it starts moving (a card, like the screen's
371
+ // own corners), then tightening to the bubble's radius.
372
+ borderRadius: progress.interpolate({
373
+ inputRange: [0, 0.08, 0.7, 1],
374
+ outputRange: [0, 36, 28, CALL_BAR_RADIUS],
375
+ extrapolate: 'clamp',
376
+ }),
377
+ },
378
+ ]}
379
+ >
380
+ {/* Fixed full-screen size: the content is clipped, never re-laid out
381
+ per frame (the video views would stutter). It fades as the bubble's
382
+ colour takes over. */}
383
+ <Animated.View
384
+ style={[
385
+ styles.sessionFixed,
386
+ {
387
+ width,
388
+ height,
389
+ opacity: progress.interpolate({
390
+ inputRange: [0, 0.55, 0.85],
391
+ outputRange: [1, 0.85, 0],
392
+ extrapolate: 'clamp',
393
+ }),
394
+ },
395
+ ]}
396
+ >
397
+ {children(collapse)}
398
+ </Animated.View>
399
+ <Animated.View
400
+ pointerEvents="none"
401
+ style={[
402
+ StyleSheet.absoluteFill,
403
+ {
404
+ backgroundColor: barColor,
405
+ opacity: progress.interpolate({
406
+ inputRange: [0, 0.35, 0.9],
407
+ outputRange: [0, 0, 1],
408
+ extrapolate: 'clamp',
409
+ }),
410
+ },
411
+ ]}
412
+ />
413
+ </Animated.View>
414
+ );
415
+ };
416
+
253
417
  /**
254
418
  * A crash in the call UI must not take the chat down with it (web wraps
255
419
  * the overlay in a silent ChatErrorBoundary). Renders nothing on error and
@@ -287,8 +451,8 @@ class CallErrorBoundary extends Component<
287
451
  * 'idle'.
288
452
  *
289
453
  * The LiveKit connection itself lives in callSessionController.ts, so
290
- * switching between the full-screen Modal and the minimized panel (two
291
- * different trees here) no longer drops the call.
454
+ * switching between the full-screen Modal and the minimized header strip
455
+ * (two different trees) no longer drops the call.
292
456
  */
293
457
  const VideoCallOverlayInner: FC = () => {
294
458
  const dispatch = useDispatch();
@@ -296,7 +460,6 @@ const VideoCallOverlayInner: FC = () => {
296
460
  const { config } = useChatSettingState();
297
461
  const theme = useTheme();
298
462
  const t = useT();
299
- const panel = useDraggable();
300
463
 
301
464
  const videoCallsConfig = config?.videoCalls;
302
465
  const enabled = videoCallsConfig?.enabled === true;
@@ -309,14 +472,25 @@ const VideoCallOverlayInner: FC = () => {
309
472
  // callkeep. No-op otherwise, the in-app ring screen below is the
310
473
  // fallback and always renders.
311
474
  useCallKeep();
475
+ // Host-provided system notification (Android) with hang up / mute /
476
+ // camera buttons while the call runs.
477
+ useOngoingCallNotification(enabled ? videoCallsConfig?.ongoingCallNotification : undefined);
312
478
 
313
- const [minimized, setMinimized] = useState(false);
479
+ // Shared with the header strip (ActiveCallBar), which expands it again.
480
+ const { minimized, systemRinging } = useCallUi();
314
481
 
315
482
  const canRenderSession =
316
483
  !!call.token &&
317
484
  (call.phase === 'connecting' || call.phase === 'in-call') &&
318
485
  !!livekitUrl;
319
486
  const isMinimized = minimized && canRenderSession;
487
+ // Expanding from the header strip slides the call down from the top
488
+ // instead of fading it in.
489
+ const wasMinimizedRef = useRef(false);
490
+ const enterFromTop = wasMinimizedRef.current && !isMinimized;
491
+ useEffect(() => {
492
+ wasMinimizedRef.current = isMinimized;
493
+ }, [isMinimized]);
320
494
 
321
495
  // Join the LiveKit room once the call has a token. Idempotent, and the
322
496
  // controller tears itself down when the call slice leaves these phases.
@@ -332,12 +506,6 @@ const VideoCallOverlayInner: FC = () => {
332
506
  // eslint-disable-next-line react-hooks/exhaustive-deps
333
507
  }, [enabled, canRenderSession, call.token, livekitUrl, call.kind]);
334
508
 
335
- // Reset the minimized flag whenever the call ends, so the next call
336
- // opens full-screen.
337
- useEffect(() => {
338
- if (call.phase === 'idle') {setMinimized(false);}
339
- }, [call.phase]);
340
-
341
509
  // Give up on an unanswered outgoing dial. Unlike web (which only shows
342
510
  // the error), also tell the callee, who would otherwise keep ringing:
343
511
  // there is no incoming ring timeout on either platform.
@@ -358,10 +526,11 @@ const VideoCallOverlayInner: FC = () => {
358
526
  useEffect(() => {
359
527
  const isRinging =
360
528
  call.direction === 'incoming' && call.phase === 'ringing-incoming';
361
- if (!isRinging) {return;}
529
+ // CallKit rings (and vibrates) on its own.
530
+ if (!isRinging || systemRinging) {return;}
362
531
  Vibration.vibrate(RING_VIBRATION_PATTERN, true);
363
532
  return () => Vibration.cancel();
364
- }, [call.direction, call.phase]);
533
+ }, [call.direction, call.phase, systemRinging]);
365
534
 
366
535
  const hangupCurrent = useCallback(() => {
367
536
  if (call.direction === 'incoming' && call.phase === 'ringing-incoming') {
@@ -416,38 +585,35 @@ const VideoCallOverlayInner: FC = () => {
416
585
  const showIncomingDecision =
417
586
  call.direction === 'incoming' && call.phase === 'ringing-incoming';
418
587
 
419
- const session = (
588
+ // The system's incoming-call banner is up (iOS CallKit); answering or
589
+ // declining it drives the call from there.
590
+ if (showIncomingDecision && systemRinging) {
591
+ return null;
592
+ }
593
+
594
+ const renderSession = (collapse: () => void) => (
420
595
  <VideoCallSession
421
596
  kind={call.kind}
422
597
  peerName={call.roomName}
423
598
  primaryColor={primaryColor}
424
599
  icons={videoCallsConfig?.icons}
425
- minimized={isMinimized}
426
- onToggleMinimize={() => setMinimized((m) => !m)}
600
+ onToggleMinimize={collapse}
427
601
  onHangup={() => terminateActiveCall('ended')}
428
602
  />
429
603
  );
430
604
 
431
- // The minimized session is NOT a Modal: a modal is by definition on top
432
- // of everything and swallows touches, which is the opposite of what
433
- // minimize is for (keep talking, keep using the chat). It renders as a
434
- // floating bar the user can drag out of the way, like web's panel.
605
+ // Minimized, the call is a strip under the visible SDK header
606
+ // (HeaderCallBar), Telegram-style, so the chat stays fully usable. With
607
+ // no SDK header on screen the same strip docks to the top instead.
435
608
  if (isMinimized) {
436
- return (
437
- <Animated.View
438
- {...panel.panHandlers}
439
- style={[styles.floatingPanel, panel.style]}
440
- >
441
- {session}
442
- </Animated.View>
443
- );
609
+ return <DockedCallBar />;
444
610
  }
445
611
 
446
612
  return (
447
613
  <Modal
448
614
  visible
449
615
  transparent
450
- animationType="fade"
616
+ animationType={enterFromTop ? 'none' : 'fade'}
451
617
  statusBarTranslucent
452
618
  supportedOrientations={['portrait', 'landscape']}
453
619
  onRequestClose={hangupCurrent}
@@ -455,7 +621,13 @@ const VideoCallOverlayInner: FC = () => {
455
621
  {canRenderSession ? (
456
622
  // Full-bleed: on a phone the call owns the screen, like the native
457
623
  // dialer. The audio call keeps the same dark canvas as video.
458
- <View style={styles.sessionFull}>{session}</View>
624
+ <CollapsibleCallSurface
625
+ enterFromTop={enterFromTop}
626
+ barColor={theme.success}
627
+ onCollapsed={() => setCallMinimized(true)}
628
+ >
629
+ {renderSession}
630
+ </CollapsibleCallSurface>
459
631
  ) : (
460
632
  <View style={[styles.backdrop, { backgroundColor: theme.overlay }]}>
461
633
  {showIncomingDecision ? (
@@ -527,13 +699,15 @@ const styles = StyleSheet.create({
527
699
  flex: 1,
528
700
  backgroundColor: '#0B1220',
529
701
  },
530
- floatingPanel: {
702
+ // No flex here: `flex: 1` would override the fixed height and re-lay the
703
+ // call out on every frame of the morph.
704
+ sessionFixed: {
705
+ backgroundColor: '#0B1220',
706
+ },
707
+ collapsible: {
531
708
  position: 'absolute',
532
- right: 16,
533
- bottom: 24,
534
- left: 16,
535
- zIndex: 1001,
536
- elevation: 12,
709
+ overflow: 'hidden',
710
+ backgroundColor: '#0B1220',
537
711
  },
538
712
  ringingCard: {
539
713
  width: '100%',
@@ -2,6 +2,7 @@ import React, { FC, useEffect, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  Animated,
5
+ Pressable,
5
6
  StyleSheet,
6
7
  Text,
7
8
  TouchableOpacity,
@@ -13,11 +14,12 @@ import { ProfileImagePlaceholder } from '../MainComponents/ProfileImagePlacehold
13
14
  import {
14
15
  CameraOffIcon,
15
16
  CameraOnIcon,
17
+ FillVideoIcon,
18
+ FitVideoIcon,
16
19
  HangUpIcon,
17
20
  MicOffIcon,
18
21
  MicOnIcon,
19
22
  MinimizeIcon,
20
- ExpandIcon,
21
23
  SpeakerIcon,
22
24
  SpeakerOffIcon,
23
25
  SwitchCameraIcon,
@@ -31,7 +33,7 @@ import {
31
33
  toggleCallSpeaker,
32
34
  useCallSession,
33
35
  } from './callSessionController';
34
- import { useDraggable } from './useDraggable';
36
+ import { usePipTile } from './usePipTile';
35
37
  import { useT } from '../../i18n/useT';
36
38
 
37
39
  const DEFAULT_PRIMARY = '#0052CD';
@@ -47,9 +49,7 @@ export interface VideoCallSessionProps {
47
49
  peerName?: string | null;
48
50
  primaryColor?: string;
49
51
  icons?: VideoCallIcons;
50
- /** Render the compact floating-panel layout instead of the full screen. */
51
- minimized?: boolean;
52
- /** Toggle between full and minimized layouts. */
52
+ /** Minimize to the header strip (ActiveCallBar). */
53
53
  onToggleMinimize?: () => void;
54
54
  onHangup: () => void;
55
55
  }
@@ -59,7 +59,7 @@ export interface VideoCallSessionProps {
59
59
  * `joinedAt` anchor so both sides show the same time (web's
60
60
  * useSharedCallElapsed). Null until the peer is in.
61
61
  */
62
- const useSharedCallElapsed = (anchorAt: number | null): number | null => {
62
+ export const useSharedCallElapsed = (anchorAt: number | null): number | null => {
63
63
  const [, tick] = useState(0);
64
64
  useEffect(() => {
65
65
  if (anchorAt === null) {return;}
@@ -94,7 +94,6 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
94
94
  peerName,
95
95
  primaryColor = DEFAULT_PRIMARY,
96
96
  icons,
97
- minimized = false,
98
97
  onToggleMinimize,
99
98
  onHangup,
100
99
  }) => {
@@ -102,8 +101,14 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
102
101
  const insets = useSafeAreaInsets();
103
102
  const runtime = loadLiveKit();
104
103
  const session = useCallSession();
105
- const selfView = useDraggable();
104
+ const pipTile = usePipTile({ topInset: insets.top });
106
105
  const elapsed = useSharedCallElapsed(session.anchorAt);
106
+ // Telegram-style: tap the small tile to swap it with the big one.
107
+ const [swapped, setSwapped] = useState(false);
108
+ // The peer's video is shown whole by default: a web camera is landscape,
109
+ // and filling a portrait phone crops most of it away. The button next to
110
+ // minimize stretches it to fill (the old behaviour) and back.
111
+ const [fillRemote, setFillRemote] = useState(false);
107
112
 
108
113
  if (!runtime) {
109
114
  return (
@@ -124,12 +129,29 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
124
129
  speakerOn,
125
130
  deviceError,
126
131
  remoteMicMuted,
132
+ facingMode,
133
+ localVideoEpoch,
127
134
  } = session;
128
135
  // The call slice's name (the chat title) first, as web does: LiveKit's
129
136
  // participant name is often just the opaque xmpp identity.
130
137
  const displayName = peerName || session.remoteName || t('call.unknownCaller');
131
138
  const isVideo = kind === 'video';
132
139
  const showRemoteVideo = isVideo && !!remoteVideo;
140
+ const localLive = isVideo && cameraOn && !!localVideo;
141
+ // Swapping only means something while both sides have a picture; if
142
+ // either goes away, fall back to the default layout on its own.
143
+ const canSwap = showRemoteVideo && localLive;
144
+ const isSwapped = swapped && canSwap;
145
+ const mainVideo = isSwapped ? localVideo : showRemoteVideo ? remoteVideo : null;
146
+ const pipVideo = isSwapped ? remoteVideo : localLive ? localVideo : null;
147
+ const mainIsRemote = !!mainVideo && mainVideo === remoteVideo;
148
+ const mainFit = mainIsRemote && !fillRemote ? 'contain' : 'cover';
149
+ // Only the front camera is mirrored, as in every camera app.
150
+ const selfMirror = facingMode === 'user';
151
+ // The local view remounts when the camera is re-acquired (switching
152
+ // front/back), so it never keeps rendering the stopped stream.
153
+ const videoKey = (ref: any) =>
154
+ ref === localVideo ? `local-${localVideoEpoch}` : 'remote';
133
155
 
134
156
  const statusText =
135
157
  elapsed !== null
@@ -142,54 +164,19 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
142
164
 
143
165
  const peerMutedLabel = t('call.peerMuted', { name: displayName });
144
166
 
145
- if (minimized) {
146
- return (
147
- <View style={styles.miniBar}>
148
- <ProfileImagePlaceholder name={displayName} size={36} />
149
- <View style={styles.miniInfo}>
150
- <Text numberOfLines={1} style={styles.miniName}>
151
- {displayName}
152
- </Text>
153
- <Text numberOfLines={1} style={styles.miniStatus}>
154
- {remoteMicMuted ? `${statusText} · ${peerMutedLabel}` : statusText}
155
- </Text>
156
- </View>
157
- <TouchableOpacity
158
- accessibilityRole="button"
159
- accessibilityLabel={micOn ? t('call.action.muteMic') : t('call.action.unmuteMic')}
160
- onPress={toggleCallMic}
161
- style={[styles.miniButton, { backgroundColor: micOn ? CONTROL_IDLE : DANGER }]}
162
- >
163
- {micOn
164
- ? (icons?.micOn ?? <MicOnIcon color={TEXT_ON_DARK} size={20} />)
165
- : (icons?.micOff ?? <MicOffIcon color={TEXT_ON_DARK} size={20} />)}
166
- </TouchableOpacity>
167
- <TouchableOpacity
168
- accessibilityRole="button"
169
- accessibilityLabel={t('call.action.expand')}
170
- onPress={onToggleMinimize}
171
- style={[styles.miniButton, { backgroundColor: CONTROL_IDLE }]}
172
- >
173
- <ExpandIcon color={TEXT_ON_DARK} size={20} />
174
- </TouchableOpacity>
175
- <TouchableOpacity
176
- accessibilityRole="button"
177
- accessibilityLabel={t('call.action.hangUp')}
178
- onPress={onHangup}
179
- style={[styles.miniButton, { backgroundColor: DANGER }]}
180
- >
181
- {icons?.hangup ?? <HangUpIcon color={TEXT_ON_DARK} size={20} />}
182
- </TouchableOpacity>
183
- </View>
184
- );
185
- }
186
-
187
167
  return (
188
168
  <View style={styles.canvas}>
189
169
  {/* Stage */}
190
- <View style={styles.stage}>
191
- {showRemoteVideo ? (
192
- <VideoTrack trackRef={remoteVideo} style={styles.remoteVideo} objectFit="cover" />
170
+ <View style={styles.stage} onLayout={pipTile.onStageLayout}>
171
+ {mainVideo ? (
172
+ <VideoTrack
173
+ key={videoKey(mainVideo)}
174
+ trackRef={mainVideo}
175
+ style={styles.remoteVideo}
176
+ objectFit={mainFit}
177
+ mirror={isSwapped && selfMirror}
178
+ zOrder={0}
179
+ />
193
180
  ) : (
194
181
  <View style={styles.centered}>
195
182
  <ProfileImagePlaceholder name={displayName} size={120} />
@@ -216,7 +203,7 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
216
203
  )}
217
204
 
218
205
  {/* Header strip: who, how long, and whether they can hear us. */}
219
- {showRemoteVideo && (
206
+ {mainVideo && (
220
207
  <View style={[styles.header, { paddingTop: 14 + insets.top }]}>
221
208
  <Text style={styles.headerName} numberOfLines={1}>
222
209
  {displayName}
@@ -231,13 +218,29 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
231
218
  </View>
232
219
  )}
233
220
 
234
- {/* Local preview, picture-in-picture, draggable like web's self-view. */}
235
- {isVideo && cameraOn && localVideo && (
221
+ {/* Picture-in-picture: drag it and it snaps to a corner, pinch it
222
+ for the large size, tap it to swap it with the main video. */}
223
+ {pipVideo && (
236
224
  <Animated.View
237
- {...selfView.panHandlers}
238
- style={[styles.pip, { top: 16 + insets.top }, selfView.style]}
225
+ {...pipTile.panHandlers}
226
+ style={[styles.pip, pipTile.style]}
239
227
  >
240
- <VideoTrack trackRef={localVideo} style={styles.pipVideo} objectFit="cover" mirror />
228
+ <Pressable
229
+ style={styles.pipVideo}
230
+ disabled={!canSwap}
231
+ onPress={() => setSwapped((value) => !value)}
232
+ accessibilityRole="button"
233
+ accessibilityLabel={t('call.action.swapVideo')}
234
+ >
235
+ <VideoTrack
236
+ key={videoKey(pipVideo)}
237
+ trackRef={pipVideo}
238
+ style={styles.pipVideo}
239
+ objectFit="cover"
240
+ mirror={!isSwapped && selfMirror}
241
+ zOrder={1}
242
+ />
243
+ </Pressable>
241
244
  </Animated.View>
242
245
  )}
243
246
 
@@ -252,6 +255,23 @@ export const VideoCallSession: FC<VideoCallSessionProps> = ({
252
255
  </TouchableOpacity>
253
256
  )}
254
257
 
258
+ {mainIsRemote && (
259
+ <TouchableOpacity
260
+ accessibilityRole="button"
261
+ accessibilityLabel={
262
+ fillRemote ? t('call.action.fitVideo') : t('call.action.fillVideo')
263
+ }
264
+ onPress={() => setFillRemote((value) => !value)}
265
+ style={[styles.minimizeButton, styles.fitButton, { top: 16 + insets.top }]}
266
+ >
267
+ {fillRemote ? (
268
+ <FitVideoIcon color={TEXT_ON_DARK} size={20} />
269
+ ) : (
270
+ <FillVideoIcon color={TEXT_ON_DARK} size={20} />
271
+ )}
272
+ </TouchableOpacity>
273
+ )}
274
+
255
275
  {!!deviceError && (
256
276
  <TouchableOpacity
257
277
  style={[styles.deviceErrorBanner, { top: 64 + insets.top }]}
@@ -366,7 +386,7 @@ const styles = StyleSheet.create({
366
386
  top: 0,
367
387
  left: 0,
368
388
  right: 0,
369
- paddingHorizontal: 64,
389
+ paddingHorizontal: 108,
370
390
  paddingBottom: 14,
371
391
  alignItems: 'center',
372
392
  backgroundColor: 'rgba(0,0,0,0.35)',
@@ -409,10 +429,6 @@ const styles = StyleSheet.create({
409
429
  fontSize: 12,
410
430
  },
411
431
  pip: {
412
- position: 'absolute',
413
- right: 16,
414
- width: 104,
415
- height: 148,
416
432
  borderRadius: 12,
417
433
  overflow: 'hidden',
418
434
  backgroundColor: '#000',
@@ -434,6 +450,10 @@ const styles = StyleSheet.create({
434
450
  backgroundColor: 'rgba(0,0,0,0.45)',
435
451
  zIndex: 6,
436
452
  },
453
+ // Next to minimize, top-left: the top-right corner belongs to the tile.
454
+ fitButton: {
455
+ left: 60,
456
+ },
437
457
  controls: {
438
458
  flexDirection: 'row',
439
459
  alignItems: 'center',
@@ -475,35 +495,6 @@ const styles = StyleSheet.create({
475
495
  fontSize: 13,
476
496
  textAlign: 'center',
477
497
  },
478
- miniBar: {
479
- flexDirection: 'row',
480
- alignItems: 'center',
481
- gap: 10,
482
- paddingHorizontal: 12,
483
- paddingVertical: 10,
484
- borderRadius: 16,
485
- backgroundColor: SURFACE_DARK,
486
- },
487
- miniInfo: {
488
- flex: 1,
489
- minWidth: 0,
490
- },
491
- miniName: {
492
- color: TEXT_ON_DARK,
493
- fontSize: 15,
494
- },
495
- miniStatus: {
496
- color: TEXT_MUTED,
497
- fontSize: 12,
498
- fontVariant: ['tabular-nums'],
499
- },
500
- miniButton: {
501
- width: 40,
502
- height: 40,
503
- borderRadius: 20,
504
- alignItems: 'center',
505
- justifyContent: 'center',
506
- },
507
498
  });
508
499
 
509
500
  export default VideoCallSession;