stream-chat-react-native-core 9.9.1 → 9.9.2-beta.2

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 (104) hide show
  1. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.js +1 -1
  2. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.js.map +1 -1
  3. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js +7 -10
  4. package/lib/commonjs/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js.map +1 -1
  5. package/lib/commonjs/components/ChannelDetails/components/modal/Modal.js +3 -1
  6. package/lib/commonjs/components/ChannelDetails/components/modal/Modal.js.map +1 -1
  7. package/lib/commonjs/components/ChannelDetails/components/navigation-section/MediaList.js +2 -2
  8. package/lib/commonjs/components/ChannelDetails/components/navigation-section/MediaList.js.map +1 -1
  9. package/lib/commonjs/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.js +2 -2
  10. package/lib/commonjs/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.js.map +1 -1
  11. package/lib/commonjs/components/ImageGallery/components/ImageGalleryFooter.js +1 -1
  12. package/lib/commonjs/components/ImageGallery/components/ImageGalleryFooter.js.map +1 -1
  13. package/lib/commonjs/components/ImageGallery/components/ImageGalleryHeader.js +5 -1
  14. package/lib/commonjs/components/ImageGallery/components/ImageGalleryHeader.js.map +1 -1
  15. package/lib/commonjs/components/ImageGallery/components/ImageGrid.js +6 -6
  16. package/lib/commonjs/components/ImageGallery/components/ImageGrid.js.map +1 -1
  17. package/lib/commonjs/components/KeyboardCompatibleView/KeyboardCompatibleView.js +2 -2
  18. package/lib/commonjs/components/KeyboardCompatibleView/KeyboardCompatibleView.js.map +1 -1
  19. package/lib/commonjs/components/Message/MessageItemView/MessageItemView.js +1 -1
  20. package/lib/commonjs/components/UIComponents/BottomSheetModal.js +5 -2
  21. package/lib/commonjs/components/UIComponents/BottomSheetModal.js.map +1 -1
  22. package/lib/commonjs/components/UIComponents/SafeAreaViewWrapper.js +1 -1
  23. package/lib/commonjs/components/UIComponents/SafeAreaViewWrapper.js.map +1 -1
  24. package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js +3 -0
  25. package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
  26. package/lib/commonjs/hooks/useAppStateListener.js +10 -6
  27. package/lib/commonjs/hooks/useAppStateListener.js.map +1 -1
  28. package/lib/commonjs/hooks/useHorizontalInsets.js +21 -0
  29. package/lib/commonjs/hooks/useHorizontalInsets.js.map +1 -0
  30. package/lib/commonjs/hooks/useScreenDimensions.js.map +1 -1
  31. package/lib/commonjs/hooks/useWindowContentWidth.js +15 -0
  32. package/lib/commonjs/hooks/useWindowContentWidth.js.map +1 -0
  33. package/lib/commonjs/version.json +1 -1
  34. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.js +1 -1
  35. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.js.map +1 -1
  36. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js +7 -10
  37. package/lib/module/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.js.map +1 -1
  38. package/lib/module/components/ChannelDetails/components/modal/Modal.js +3 -1
  39. package/lib/module/components/ChannelDetails/components/modal/Modal.js.map +1 -1
  40. package/lib/module/components/ChannelDetails/components/navigation-section/MediaList.js +2 -2
  41. package/lib/module/components/ChannelDetails/components/navigation-section/MediaList.js.map +1 -1
  42. package/lib/module/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.js +2 -2
  43. package/lib/module/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.js.map +1 -1
  44. package/lib/module/components/ImageGallery/components/ImageGalleryFooter.js +1 -1
  45. package/lib/module/components/ImageGallery/components/ImageGalleryFooter.js.map +1 -1
  46. package/lib/module/components/ImageGallery/components/ImageGalleryHeader.js +5 -1
  47. package/lib/module/components/ImageGallery/components/ImageGalleryHeader.js.map +1 -1
  48. package/lib/module/components/ImageGallery/components/ImageGrid.js +6 -6
  49. package/lib/module/components/ImageGallery/components/ImageGrid.js.map +1 -1
  50. package/lib/module/components/KeyboardCompatibleView/KeyboardCompatibleView.js +2 -2
  51. package/lib/module/components/KeyboardCompatibleView/KeyboardCompatibleView.js.map +1 -1
  52. package/lib/module/components/Message/MessageItemView/MessageItemView.js +1 -1
  53. package/lib/module/components/UIComponents/BottomSheetModal.js +5 -2
  54. package/lib/module/components/UIComponents/BottomSheetModal.js.map +1 -1
  55. package/lib/module/components/UIComponents/SafeAreaViewWrapper.js +1 -1
  56. package/lib/module/components/UIComponents/SafeAreaViewWrapper.js.map +1 -1
  57. package/lib/module/hooks/messagePreview/useMessagePreviewText.js +3 -0
  58. package/lib/module/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
  59. package/lib/module/hooks/useAppStateListener.js +10 -6
  60. package/lib/module/hooks/useAppStateListener.js.map +1 -1
  61. package/lib/module/hooks/useHorizontalInsets.js +21 -0
  62. package/lib/module/hooks/useHorizontalInsets.js.map +1 -0
  63. package/lib/module/hooks/useScreenDimensions.js.map +1 -1
  64. package/lib/module/hooks/useWindowContentWidth.js +15 -0
  65. package/lib/module/hooks/useWindowContentWidth.js.map +1 -0
  66. package/lib/module/version.json +1 -1
  67. package/lib/typescript/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.d.ts.map +1 -1
  68. package/lib/typescript/components/ChannelDetails/components/modal/Modal.d.ts.map +1 -1
  69. package/lib/typescript/components/ChannelDetails/components/navigation-section/MediaList.d.ts.map +1 -1
  70. package/lib/typescript/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.d.ts.map +1 -1
  71. package/lib/typescript/components/ImageGallery/components/ImageGalleryFooter.d.ts.map +1 -1
  72. package/lib/typescript/components/ImageGallery/components/ImageGalleryHeader.d.ts.map +1 -1
  73. package/lib/typescript/components/ImageGallery/components/ImageGrid.d.ts.map +1 -1
  74. package/lib/typescript/components/KeyboardCompatibleView/KeyboardCompatibleView.d.ts.map +1 -1
  75. package/lib/typescript/components/UIComponents/BottomSheetModal.d.ts.map +1 -1
  76. package/lib/typescript/hooks/messagePreview/useMessagePreviewText.d.ts.map +1 -1
  77. package/lib/typescript/hooks/useAppStateListener.d.ts.map +1 -1
  78. package/lib/typescript/hooks/useHorizontalInsets.d.ts +25 -0
  79. package/lib/typescript/hooks/useHorizontalInsets.d.ts.map +1 -0
  80. package/lib/typescript/hooks/useScreenDimensions.d.ts +7 -0
  81. package/lib/typescript/hooks/useScreenDimensions.d.ts.map +1 -1
  82. package/lib/typescript/hooks/useWindowContentWidth.d.ts +25 -0
  83. package/lib/typescript/hooks/useWindowContentWidth.d.ts.map +1 -0
  84. package/package.json +1 -1
  85. package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx +2 -0
  86. package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx +7 -7
  87. package/src/components/ChannelDetails/components/modal/Modal.tsx +3 -0
  88. package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx +6 -13
  89. package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx +4 -2
  90. package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +4 -1
  91. package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +22 -2
  92. package/src/components/ImageGallery/components/ImageGrid.tsx +7 -4
  93. package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx +4 -2
  94. package/src/components/Message/MessageItemView/MessageItemView.tsx +1 -1
  95. package/src/components/UIComponents/BottomSheetModal.tsx +7 -1
  96. package/src/components/UIComponents/SafeAreaViewWrapper.tsx +1 -1
  97. package/src/hooks/__tests__/useAppStateListener.test.tsx +80 -47
  98. package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx +59 -0
  99. package/src/hooks/messagePreview/useMessagePreviewText.tsx +4 -0
  100. package/src/hooks/useAppStateListener.ts +15 -7
  101. package/src/hooks/useHorizontalInsets.ts +37 -0
  102. package/src/hooks/useScreenDimensions.ts +7 -0
  103. package/src/hooks/useWindowContentWidth.ts +31 -0
  104. package/src/version.json +1 -1
@@ -1,12 +1,5 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import {
3
- ActivityIndicator,
4
- FlatList,
5
- type FlatListProps,
6
- StyleSheet,
7
- useWindowDimensions,
8
- View,
9
- } from 'react-native';
2
+ import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native';
10
3
 
11
4
  import {
12
5
  formatMessage,
@@ -30,6 +23,7 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
30
23
  import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
31
24
  import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions';
32
25
  import { useStateStore } from '../../../../hooks/useStateStore';
26
+ import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth';
33
27
  import { isVideoPlayerAvailable } from '../../../../native';
34
28
  import { primitives } from '../../../../theme';
35
29
  import { FileTypes } from '../../../../types/types';
@@ -41,9 +35,6 @@ import { NotificationTargetProvider } from '../../../Notifications/NotificationT
41
35
  import { EmptyList } from '../../../UIComponents/EmptyList';
42
36
  import { type MediaTile, useMediaList } from '../../hooks/useMediaList';
43
37
 
44
- const NUMBER_OF_COLUMNS = 3;
45
- const GRID_GAP = primitives.spacingXxxs;
46
-
47
38
  export type MediaListProps = {
48
39
  /**
49
40
  * Besides the existing default behavior of the media list, you can attach additional props to
@@ -60,6 +51,9 @@ export type MediaListProps = {
60
51
  searchSource?: MessageSearchSource;
61
52
  };
62
53
 
54
+ const NUMBER_OF_COLUMNS = 3;
55
+ const GRID_GAP = primitives.spacingXxxs;
56
+
63
57
  const keyExtractor = (item: MediaTile, index: number) => `${item.message.id}-${index}`;
64
58
 
65
59
  const listStateSelector = (state: SearchSourceState<MessageResponse>) => ({
@@ -77,7 +71,7 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => {
77
71
  },
78
72
  } = useTheme();
79
73
  const styles = useStyles();
80
- const { width } = useWindowDimensions();
74
+ const width = useWindowContentWidth();
81
75
  const { icons, MediaItem } = useComponentsContext();
82
76
 
83
77
  const { addNotification } = useNotificationApi();
@@ -128,7 +122,6 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => {
128
122
 
129
123
  const tiles = useMediaList(messages);
130
124
 
131
- // Tile side length: full width minus the inter-column gaps, split across the columns.
132
125
  const tileSize = useMemo(
133
126
  () => (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS,
134
127
  [width],
@@ -1,7 +1,8 @@
1
1
  import React, { useMemo } from 'react';
2
- import { StyleSheet, useWindowDimensions, View } from 'react-native';
2
+ import { StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
5
+ import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth';
5
6
  import { primitives } from '../../../../theme';
6
7
  import { NativeShimmerView } from '../../../UIComponents/NativeShimmerView';
7
8
 
@@ -17,7 +18,8 @@ export const MediaListLoadingSkeleton = () => {
17
18
  const {
18
19
  theme: { semantics },
19
20
  } = useTheme();
20
- const { width } = useWindowDimensions();
21
+ // Same derivation as `MediaList`, so the skeleton's tiles match the grid that replaces it.
22
+ const width = useWindowContentWidth();
21
23
  const styles = useStyles();
22
24
 
23
25
  const tileSize = (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS;
@@ -127,7 +127,10 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterProps) =>
127
127
  pointerEvents={'box-none'}
128
128
  style={styles.wrapper}
129
129
  >
130
- <ReanimatedSafeAreaView edges={['bottom']} style={[styles.container, footerStyle]}>
130
+ <ReanimatedSafeAreaView
131
+ edges={['bottom', 'left', 'right']}
132
+ style={[styles.container, footerStyle]}
133
+ >
131
134
  {asset.type === FileTypes.Video ? (
132
135
  ImageGalleryVideoControls ? (
133
136
  <ImageGalleryVideoControls attachmentId={asset.id} />
@@ -18,6 +18,23 @@ import { ImageGalleryState } from '../../../state-store/image-gallery-state-stor
18
18
  import { primitives } from '../../../theme';
19
19
  import { getDateString } from '../../../utils/i18n/getDateString';
20
20
  import { Button } from '../../ui/Button/Button';
21
+ import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper';
22
+
23
+ // Never called - it exists only so `ReturnType` below can name the animated component's type.
24
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in type position only
25
+ const createAnimatedSafeAreaViewType = () => Animated.createAnimatedComponent(SafeAreaView);
26
+
27
+ /**
28
+ * A frame-aware wrapper, matching `ImageGalleryFooter`. `SafeAreaView` compares the window inset
29
+ * against its own measured frame, so a nested instance contributes nothing once an ancestor has
30
+ * already narrowed the subtree - unlike `useSafeAreaInsets`, which is window-global and would
31
+ * double-pad this header inside `ChannelDetailsModal`, which insets its own root.
32
+ *
33
+ * `createAnimatedComponent` is guarded because a stripped-down Reanimated mock may not provide it.
34
+ */
35
+ const ReanimatedSafeAreaView = (
36
+ Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) : SafeAreaView
37
+ ) as ReturnType<typeof createAnimatedSafeAreaViewType>;
21
38
 
22
39
  const imageGallerySelector = (state: ImageGalleryState) => ({
23
40
  asset: state.assets[state.currentIndex],
@@ -69,7 +86,10 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => {
69
86
  onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
70
87
  pointerEvents={'box-none'}
71
88
  >
72
- <Animated.View style={[styles.container, { paddingTop: topInset }, headerStyle]}>
89
+ <ReanimatedSafeAreaView
90
+ edges={['left', 'right']}
91
+ style={[styles.container, { paddingTop: topInset }, headerStyle]}
92
+ >
73
93
  <View style={styles.innerContainer}>
74
94
  <View style={styles.leftContainer}>
75
95
  <Button
@@ -90,7 +110,7 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => {
90
110
  </View>
91
111
  <View style={styles.rightContainer} accessibilityLabel='Right element' />
92
112
  </View>
93
- </Animated.View>
113
+ </ReanimatedSafeAreaView>
94
114
  </View>
95
115
  );
96
116
  };
@@ -6,7 +6,7 @@ import type { ImageGalleryGridProps } from './types';
6
6
  import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContextBase';
7
7
  import { useTheme } from '../../../contexts/themeContext/ThemeContext';
8
8
  import { useStateStore } from '../../../hooks/useStateStore';
9
- import { useViewport } from '../../../hooks/useViewport';
9
+ import { useWindowContentWidth } from '../../../hooks/useWindowContentWidth';
10
10
  import type {
11
11
  ImageGalleryAsset,
12
12
  ImageGalleryState,
@@ -33,12 +33,12 @@ export type GridImageItem = ImageGalleryAsset & {
33
33
 
34
34
  const GridImage = ({ item }: { item: GridImageItem }) => {
35
35
  const styles = useStyles();
36
- const { vw } = useViewport();
36
+ const contentWidth = useWindowContentWidth();
37
37
  const { ...restItem } = item;
38
38
 
39
39
  const { numberOfImageGalleryGridColumns, selectAndClose, thumb_url, type, uri } = restItem;
40
40
 
41
- const size = vw(100) / (numberOfImageGalleryGridColumns || 3) - 2;
41
+ const size = contentWidth / (numberOfImageGalleryGridColumns || 3) - 2;
42
42
 
43
43
  return (
44
44
  <Pressable accessibilityLabel='Grid Image' onPress={selectAndClose}>
@@ -76,6 +76,8 @@ export const ImageGalleryGrid = (props: ImageGalleryGridProps) => {
76
76
  } = useTheme();
77
77
  const styles = useStyles();
78
78
 
79
+ const columns = numberOfImageGalleryGridColumns || 3;
80
+
79
81
  const imageGridItems = assets.map((photo, index) => ({
80
82
  ...photo,
81
83
  numberOfImageGalleryGridColumns,
@@ -91,7 +93,8 @@ export const ImageGalleryGrid = (props: ImageGalleryGridProps) => {
91
93
  contentContainerStyle={styles.contentContainer}
92
94
  data={imageGridItems as GridImageItem[]}
93
95
  keyExtractor={(item, index) => `${item.uri}-${index}`}
94
- numColumns={numberOfImageGalleryGridColumns || 3}
96
+ key={columns}
97
+ numColumns={columns}
95
98
  renderItem={renderItem}
96
99
  style={container}
97
100
  />
@@ -215,11 +215,13 @@ export class KeyboardCompatibleView extends React.Component<
215
215
  };
216
216
 
217
217
  _handleAppStateChange = (nextAppState: AppStateStatus) => {
218
- if (this.state.appState.match(/inactive|background/) && nextAppState === 'active') {
218
+ // `inactive` is visible-but-unfocused (Split View), so it must not tear down the listeners.
219
+ // Mirrors `useAppStateListener`.
220
+ if (this.state.appState === 'background' && nextAppState === 'active') {
219
221
  this.setKeyboardListeners();
220
222
  }
221
223
 
222
- if (nextAppState.match(/inactive|background/)) {
224
+ if (nextAppState === 'background') {
223
225
  this.unsetKeyboardListeners();
224
226
  }
225
227
 
@@ -128,7 +128,7 @@ export type MessageItemViewPropsWithContext = Pick<
128
128
  >;
129
129
 
130
130
  const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => {
131
- const { width } = Dimensions.get('screen');
131
+ const { width } = Dimensions.get('window');
132
132
  const {
133
133
  alignment,
134
134
  channel,
@@ -45,6 +45,7 @@ import { useResolvedModalAccessibilityProps } from '../../a11y/hooks/useResolved
45
45
  import { BottomSheetProvider } from '../../contexts/bottomSheetContext/BottomSheetContext';
46
46
  import { useTheme } from '../../contexts/themeContext/ThemeContext';
47
47
  import { useScreenOrientation, useStableCallback } from '../../hooks';
48
+ import { useHorizontalInsets } from '../../hooks/useHorizontalInsets';
48
49
  import { primitives } from '../../theme';
49
50
 
50
51
  const supportedOrientations: ModalProps['supportedOrientations'] = [
@@ -661,6 +662,7 @@ const useStyles = () => {
661
662
  const {
662
663
  theme: { semantics },
663
664
  } = useTheme();
665
+ const horizontalInsets = useHorizontalInsets();
664
666
  return useMemo(
665
667
  () =>
666
668
  StyleSheet.create({
@@ -671,6 +673,10 @@ const useStyles = () => {
671
673
  },
672
674
  contentContainer: {
673
675
  flex: 1,
676
+ // A RN `Modal` is its own native window, so a consumer's `SafeAreaView` never reaches
677
+ // this - it insets itself. Spread rather than assigned: a `paddingLeft: 0` longhand
678
+ // would override a themed `paddingHorizontal` for that side.
679
+ ...horizontalInsets,
674
680
  },
675
681
  handle: {
676
682
  alignSelf: 'center',
@@ -691,6 +697,6 @@ const useStyles = () => {
691
697
  flex: 1,
692
698
  },
693
699
  }),
694
- [semantics.backgroundCoreScrim, semantics.backgroundCoreElevation1],
700
+ [semantics.backgroundCoreScrim, semantics.backgroundCoreElevation1, horizontalInsets],
695
701
  );
696
702
  };
@@ -27,7 +27,7 @@ export const SafeAreaViewWrapper = ({
27
27
  }: PropsWithChildren<{ style: ViewStyle }> & SafeAreaViewProps) => {
28
28
  return (
29
29
  <SafeAreaProvider>
30
- <SafeAreaView edges={['bottom', 'top']} style={style} {...restProps}>
30
+ <SafeAreaView edges={['bottom', 'left', 'right', 'top']} style={style} {...restProps}>
31
31
  {children}
32
32
  </SafeAreaView>
33
33
  </SafeAreaProvider>
@@ -5,54 +5,87 @@ import { renderHook, waitFor } from '@testing-library/react-native';
5
5
  import { useAppStateListener } from '../useAppStateListener';
6
6
 
7
7
  describe('useAppStateListener', () => {
8
- const onForegroundFn = jest.fn();
9
- const onBackgroundFn = jest.fn();
10
- const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener');
11
- AppState.currentState = 'active';
12
-
13
- it.each<[AppStateStatus, jest.Mock, number]>([
14
- ['background', onBackgroundFn, 1],
15
- ['active', onForegroundFn, 1],
16
- ['inactive', onBackgroundFn, 2],
17
- ['active', onForegroundFn, 2],
18
- ['background', onBackgroundFn, 3],
19
- ])(
20
- 'Appropriate callback called when appstate is changed to %s)',
21
- async (newAppState, expectedCallback, times) => {
22
- renderHook(
23
- (props: {
24
- onBackground?: (() => void) | undefined;
25
- onForeground?: (() => void) | undefined;
26
- }) => useAppStateListener(props.onForeground, props.onBackground),
27
- {
28
- initialProps: {
29
- onBackground: onBackgroundFn,
30
- onForeground: onForegroundFn,
31
- },
32
- },
33
- );
34
- const appStateOnChangeMockFunc = addEventListenerSpy.mock.calls[0][1];
35
-
36
- appStateOnChangeMockFunc(newAppState);
37
- await waitFor(() => {
38
- expect(expectedCallback).toHaveBeenCalledTimes(times);
39
- });
40
- },
41
- );
42
- it('check unmount behavior', async () => {
8
+ const renderListener = (onForegroundFn: jest.Mock, onBackgroundFn: jest.Mock) => {
9
+ const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener');
10
+ addEventListenerSpy.mockClear();
11
+ const utils = renderHook(() => useAppStateListener(onForegroundFn, onBackgroundFn));
12
+ const emit = addEventListenerSpy.mock.calls[0][1] as (state: AppStateStatus) => void;
13
+ return { ...utils, addEventListenerSpy, emit };
14
+ };
15
+
16
+ beforeEach(() => {
43
17
  jest.clearAllMocks();
44
- const { unmount } = renderHook(
45
- (props: {
46
- onBackground?: (() => void) | undefined;
47
- onForeground?: (() => void) | undefined;
48
- }) => useAppStateListener(props.onForeground, props.onBackground),
49
- {
50
- initialProps: {
51
- onBackground: onBackgroundFn,
52
- onForeground: onForegroundFn,
53
- },
54
- },
55
- );
18
+ AppState.currentState = 'active';
19
+ });
20
+
21
+ it('calls onBackground when the app is backgrounded', async () => {
22
+ const onForeground = jest.fn();
23
+ const onBackground = jest.fn();
24
+ const { emit } = renderListener(onForeground, onBackground);
25
+
26
+ emit('inactive');
27
+ emit('background');
28
+
29
+ await waitFor(() => {
30
+ expect(onBackground).toHaveBeenCalledTimes(1);
31
+ });
32
+ expect(onForeground).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it('calls onForeground when the app returns to active from background', async () => {
36
+ const onForeground = jest.fn();
37
+ const onBackground = jest.fn();
38
+ const { emit } = renderListener(onForeground, onBackground);
39
+
40
+ emit('inactive');
41
+ emit('background');
42
+ emit('inactive');
43
+ emit('active');
44
+
45
+ await waitFor(() => {
46
+ expect(onForeground).toHaveBeenCalledTimes(1);
47
+ });
48
+ expect(onBackground).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ // Regression: an app in Split View sits `inactive` while fully visible. Treating that as
52
+ // backgrounded closed the WebSocket while the user was reading the chat.
53
+ it('does NOT treat `inactive` as backgrounded', async () => {
54
+ const onForeground = jest.fn();
55
+ const onBackground = jest.fn();
56
+ const { emit } = renderListener(onForeground, onBackground);
57
+
58
+ emit('inactive');
59
+ emit('active');
60
+ emit('inactive');
61
+
62
+ await waitFor(() => {
63
+ expect(onBackground).not.toHaveBeenCalled();
64
+ });
65
+ expect(onForeground).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('fires each callback once per transition even with repeated events', async () => {
69
+ const onForeground = jest.fn();
70
+ const onBackground = jest.fn();
71
+ const { emit } = renderListener(onForeground, onBackground);
72
+
73
+ emit('background');
74
+ emit('background');
75
+ emit('active');
76
+ emit('active');
77
+
78
+ await waitFor(() => {
79
+ expect(onBackground).toHaveBeenCalledTimes(1);
80
+ });
81
+ expect(onForeground).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it('check unmount behavior', async () => {
85
+ const onForeground = jest.fn();
86
+ const onBackground = jest.fn();
87
+ const { addEventListenerSpy, unmount } = renderListener(onForeground, onBackground);
88
+
56
89
  const { remove: appStateOnChangeSubscriptionRemoveMockFunc } =
57
90
  addEventListenerSpy.mock.results[0].value;
58
91
 
@@ -0,0 +1,59 @@
1
+ import React, { PropsWithChildren } from 'react';
2
+
3
+ import { renderHook, waitFor } from '@testing-library/react-native';
4
+
5
+ import type { Attachment } from 'stream-chat';
6
+
7
+ import { Chat } from '../../../components/Chat/Chat';
8
+ import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
9
+ import {
10
+ generateAudioAttachment,
11
+ generateFileAttachment,
12
+ generateImageAttachment,
13
+ } from '../../../mock-builders/attachments';
14
+ import { generateMessage } from '../../../mock-builders/generator/message';
15
+ import { useMessagePreviewText } from '../useMessagePreviewText';
16
+
17
+ describe('useMessagePreviewText', () => {
18
+ const render = async (message: ReturnType<typeof generateMessage>) => {
19
+ const { client } = await initiateClientWithChannels();
20
+ const wrapper = ({ children }: PropsWithChildren) => <Chat client={client}>{children}</Chat>;
21
+ const { result } = renderHook(() => useMessagePreviewText({ message }), { wrapper });
22
+ return result;
23
+ };
24
+
25
+ const withAttachments = (attachments?: Attachment[]) =>
26
+ generateMessage({ attachments, text: '' });
27
+
28
+ // Drafts can have no text and no attachments; they rendered "{{count}} Files" / "0 Audios".
29
+ it.each([
30
+ ['absent', undefined],
31
+ ['empty', []],
32
+ ])('returns an empty preview when attachments are %s', async (_, attachments) => {
33
+ const result = await render(withAttachments(attachments));
34
+ expect(result.current).toBe('');
35
+ });
36
+
37
+ it('counts mixed attachments as files', async () => {
38
+ const result = await render(
39
+ withAttachments([generateImageAttachment(), generateFileAttachment()]),
40
+ );
41
+ // Counted copy comes from the translations, which load asynchronously.
42
+ await waitFor(() => expect(result.current).toBe('2 Files'));
43
+ });
44
+
45
+ it('counts audio-only attachments as audios', async () => {
46
+ const result = await render(
47
+ withAttachments([generateAudioAttachment(), generateAudioAttachment()]),
48
+ );
49
+ // Counted copy comes from the translations, which load asynchronously.
50
+ await waitFor(() => expect(result.current).toBe('2 Audios'));
51
+ });
52
+
53
+ it('prefers the message text over its attachments', async () => {
54
+ const result = await render(
55
+ generateMessage({ attachments: [generateFileAttachment()], text: 'hello' }),
56
+ );
57
+ expect(result.current).toBe('hello');
58
+ });
59
+ });
@@ -64,6 +64,10 @@ export const useMessagePreviewText = ({
64
64
  return message.text;
65
65
  }
66
66
 
67
+ if (!attachmentsLength) {
68
+ return '';
69
+ }
70
+
67
71
  if (onlyImages) {
68
72
  if (images?.length === 1) {
69
73
  return t('Photo');
@@ -5,8 +5,8 @@ export const useAppStateListener = (onForeground?: () => void, onBackground?: ()
5
5
  // React Native 0.87 widened `AppState.currentState` to `string | null | undefined` (it was
6
6
  // `AppStateStatus` up to 0.86). Normalise once here so the comparisons below stay total on
7
7
  // every supported version.
8
- const appStateRef = useRef<AppStateStatus>(
9
- (AppState.currentState as AppStateStatus | null | undefined) ?? 'unknown',
8
+ const isBackgroundedRef = useRef(
9
+ ((AppState.currentState as AppStateStatus | null | undefined) ?? 'unknown') === 'background',
10
10
  );
11
11
  const onForegroundRef = useRef(onForeground);
12
12
  const onBackgroundRef = useRef(onBackground);
@@ -17,13 +17,21 @@ export const useAppStateListener = (onForeground?: () => void, onBackground?: ()
17
17
 
18
18
  useEffect(() => {
19
19
  const handleAppStateChange = (nextAppState: AppStateStatus) => {
20
- const prevAppState = appStateRef.current;
21
- if (prevAppState.match(/inactive|background/) && nextAppState === 'active') {
20
+ // Only `background` is backgrounded. `inactive` is visible-but-unfocused, and from iOS 27 an
21
+ // app in Split View stays `inactive` the whole time it is side by side. Transitions pass
22
+ // through `inactive` in both directions, so ignoring it still observes both.
23
+ if (nextAppState === 'background') {
24
+ if (!isBackgroundedRef.current) {
25
+ isBackgroundedRef.current = true;
26
+ onBackgroundRef.current?.();
27
+ }
28
+ return;
29
+ }
30
+
31
+ if (nextAppState === 'active' && isBackgroundedRef.current) {
32
+ isBackgroundedRef.current = false;
22
33
  onForegroundRef.current?.();
23
- } else if (prevAppState === 'active' && nextAppState.match(/inactive|background/)) {
24
- onBackgroundRef.current?.();
25
34
  }
26
- appStateRef.current = nextAppState;
27
35
  };
28
36
  const subscription = AppState.addEventListener('change', handleAppStateChange);
29
37
 
@@ -0,0 +1,37 @@
1
+ import { useMemo } from 'react';
2
+ import { ViewStyle } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
4
+
5
+ /**
6
+ * Horizontal safe area padding for a surface that spans the full window width.
7
+ *
8
+ * **Only for surfaces an integrator cannot wrap.** Everything the SDK renders in-tree is left
9
+ * alone: the integrator wraps their screen in a `SafeAreaView` and the whole subtree - including
10
+ * absolutely positioned children, which track their containing block's border box - narrows with
11
+ * it. Applying this to an in-tree surface double-pads it for every integrator who already wraps.
12
+ *
13
+ * In practice that leaves React Native's own `Modal`, which renders in a separate native window.
14
+ * A bottom sheet or an overlay is *not* automatically out of reach - check where it actually
15
+ * mounts. Prefer a `SafeAreaView` (see `ImageGalleryHeader`/`Footer`) wherever one fits: it
16
+ * compares the window inset against its own measured frame, so a nested instance contributes
17
+ * nothing once an ancestor has narrowed the subtree. This hook reads `useSafeAreaInsets`, which is
18
+ * window-global and has no such protection.
19
+ *
20
+ * `left` and `right` are never collapsed into one value: from iOS 27 the system stacks its controls
21
+ * down a single edge, so the two differ (measured 0 and 84 on iPhone Duo).
22
+ *
23
+ * A side whose inset is 0 is omitted rather than emitted as `paddingLeft: 0`, because a longhand
24
+ * overrides a `padding` shorthand for that side - emitting a zero would silently drop padding the
25
+ * component or a consumer's theme had already set.
26
+ */
27
+ export const useHorizontalInsets = (): ViewStyle => {
28
+ const { left, right } = useSafeAreaInsets();
29
+
30
+ return useMemo<ViewStyle>(
31
+ () => ({
32
+ ...(left > 0 ? { paddingLeft: left } : null),
33
+ ...(right > 0 ? { paddingRight: right } : null),
34
+ }),
35
+ [left, right],
36
+ );
37
+ };
@@ -6,6 +6,13 @@ import { Dimensions, ScaledSize } from 'react-native';
6
6
  * a percentage of the screen height (vh) and viewport width (vw). It
7
7
  * dynamically updates dimensions on changes in device orientation.
8
8
  *
9
+ * @deprecated These are percentages of the *screen*, not of the window the app is laid out in. The
10
+ * two diverge once the window is resizable (Split View, fold/unfold, Android multi-window): we
11
+ * measured an 834pt screen behind a 417pt window, and an outer 466pt screen behind a 951pt window.
12
+ *
13
+ * Use {@link useViewport}, the same API measured against the window, or `onLayout` on your own view
14
+ * inside a component that does not fill the window.
15
+ *
9
16
  * @returns {Object} An object containing functions vh and vw.
10
17
  */
11
18
  export const useScreenDimensions = (rounded?: boolean) => {
@@ -0,0 +1,31 @@
1
+ import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context';
2
+
3
+ /**
4
+ * Width available to a surface that spans the window, minus the horizontal safe area.
5
+ *
6
+ * Deliberately *not* measured. `onLayout` reports a frame after the window has already resized, so
7
+ * anything sized from it - tile sizes in a grid, for instance - visibly resizes a frame late on a
8
+ * rotation or a fold.
9
+ *
10
+ * The frame comes from `useSafeAreaFrame`, not `useWindowDimensions`: the frame and the insets are
11
+ * delivered by the *same* native event and set in the same handler, so they update together in one
12
+ * render. Mixing `Dimensions` with the insets desyncs them - the window width lands first and the
13
+ * insets a render later, producing an extra intermediate size (and, for anything deriving an image
14
+ * URL from it, an extra fetch).
15
+ *
16
+ * Assumes the caller fills the window. A component rendered inside a narrower container must pass
17
+ * that container's width via `containerWidth`, or measure it itself and accept the frame of lag
18
+ * that comes with measuring.
19
+ *
20
+ * @param containerWidth Width of the surface, when it is known and narrower than the window.
21
+ *
22
+ * @example
23
+ * const contentWidth = useWindowContentWidth();
24
+ * const tileSize = contentWidth / columns;
25
+ */
26
+ export const useWindowContentWidth = (containerWidth?: number) => {
27
+ const { width: frameWidth } = useSafeAreaFrame();
28
+ const { left, right } = useSafeAreaInsets();
29
+
30
+ return containerWidth ?? frameWidth - left - right;
31
+ };
package/src/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "9.9.1"
2
+ "version": "9.9.2-beta.2"
3
3
  }