From c51967a5aa43d8fbd60cf1d4a826262b4df65685 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 25 Sep 2026 14:50:17 +0200 Subject: [PATCH 1/3] fix: iPhone Duo / iOS 27 resizable-window readiness (#3818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal From iOS 27 every iPhone app is resizable โ€” Split View, and the fold/unfold of iPhone Duo (ships 2026-10-23). This makes the SDK and SampleApp correct on those devices. No device detection is involved anywhere: Duo is treated as a resizable window, not a device to branch on. Linear: [RN-429](https://linear.app/stream/issue/RN-429/iphone-duo-ios-27-resizable-window-readiness) **The two defects that matter:** 1. **Safe-area insets are asymmetric, and `top` is zero.** Measured on device: `top: 0, bottom: 34, left: 0, right: 84`, on *both* Duo displays. iOS 27 stacks the status bar, Dynamic Island, toolbars and tab bars down a single vertical edge โ€” an 84pt reserved column. Nothing in the SDK or SampleApp read `insets.left` / `insets.right`, so channel-row timestamps, the header compose button, unread badges and the scroll-to-bottom button rendered underneath the system indicators. 2. **`inactive` was treated as `background`.** In Split View a *visible* app sits `inactive`. `useAppStateListener` conflated the two, so `useIsOnline` called `client.closeConnection()` and showed the offline banner **while the user was reading the chat** (on by default via `closeConnectionOnBackground`). Typing indicators stopped the same way, and `KeyboardCompatibleView` had its own copy of the bug that dropped the keyboard listeners. ## ๐Ÿ›  Implementation details ### Horizontal insets: the integrator wraps, the SDK insets only what a wrapper cannot reach An integrator wraps their screens in ``. `develop` has no horizontal inset handling at all, so any integrator supporting landscape on a notched phone already does this โ€” and adding SDK padding on top would silently double-inset exactly them on a minor upgrade. Leaving in-tree components alone is a no-op for everyone else. A wrapper also narrows the whole subtree, so absolutely positioned children (which ignore their container's padding) come along for free. The SDK insets itself only where a consumer's wrapper cannot reach: - **React Native `Modal`s**, which render in their own native window โ€” `BottomSheetModal` (new `useHorizontalInsets`) and the channel details `Modal`. - **Image gallery header and footer**, hosted by `OverlayProvider` at the app root. They use `SafeAreaView` `edges` rather than raw inset values, so a nested instance contributes nothing when an ancestor has already narrowed the subtree. - `SafeAreaViewWrapper` now passes all four edges. `useHorizontalInsets` keeps `left` and `right` separate (they differ on iOS 27) and omits a side whose inset is 0, because a `paddingLeft: 0` longhand would override a `padding` shorthand set by the component or a consumer theme. ### Grid and tile sizing A wrapper can supply layout, but not a number. New `useWindowContentWidth` (window width minus the horizontal insets) sizes the channel-details media grid and its skeleton, the attachment picker grid and tiles, and the image gallery grid. It degrades safely: an unwrapped app gets slightly small tiles, where plain window width would overflow for anyone who wraps. Grids stay at 3 columns. `ImageGrid` remounts on a column-count change, since `FlatList` rejects `numColumns` changes on a mounted list. ### App state `useAppStateListener` and `KeyboardCompatibleView` now treat only a real `background` transition as backgrounding, each callback firing once per transition. This also stops the connection dropping during Control Center and the app switcher. The previous test *codified* the bug and was rewritten. ### Smaller fixes - **`StreamShimmerView`** reads `traitCollection.displayScale` instead of a once-cached `UIScreen.main.scale`, and per-view `window.windowScene.activationState` instead of the app-wide `applicationState`. - **`MessageItemView`** reads the window, not the screen. `screen` โ‰  `window` in both directions: an 834pt iPad screen behind a 417pt window, and the Duo's 466pt outer screen behind its 951pt inner window. - **`useScreenDimensions`** is marked `@deprecated` in favour of `useViewport` โ€” same API, measured against the window. Behaviour unchanged; removal in the next major. ### SampleApp Every screen wraps itself in ``, with the header inside the wrapper. The hand-rolled `insets.left/right` padding in `ScreenHeader` and on the channel-list search bar is gone. `BottomTabs` keeps its own horizontal padding, because it is the navigator's tab bar and renders outside every screen. The toast is sized as a percentage of its container instead of the window. ### Measured geometry, since the published figures are wrong | Device / mode | window (pt) | screen (pt) | insets t/b/l/r | | --- | --- | --- | --- | | Duo, outer display, folded | 466 ร— 678 @3x | 466 ร— 678 @3x | 0 / 34 / 0 / 84 | | Duo, inner display, unfolded, landscape | **951 ร— 669 @3x** | **466 ร— 678 @3x** | 0 / 34 / 0 / 84 | | iPad Pro 11", iPhone-only app in a compatibility window | **417** ร— 1210 @2x | **834** ร— 1210 @2x | 32 / 20 / 0 / 0 | The inner display is 669 ร— 951pt, not the widely cited 626 ร— 890: 669 ร— 3 = 2007 and 951 ร— 3 = 2853, matching the framebuffer exactly. ## ๐ŸŽจ UI Changes On the Duo, nothing the app draws enters the 84pt reserved column any more, on either display. Previously the row timestamps, the header compose button, the unread badges and the scroll-to-bottom button (406โ€“446pt) all sat inside it. Folded, content now ends at the 382pt boundary (466 โˆ’ 84). Unfolded, it ends at 867pt (951 โˆ’ 84) or before. On phones without horizontal insets in portrait there is no visible change. In landscape, content now clears the Dynamic Island or camera cutout on whichever side it is. ## ๐Ÿงช Testing | Gate | Result | | --- | --- | | `yarn lint` | exit 0, zero warnings | | `cd package && yarn test:typecheck` | exit 0 | | `yarn build` | succeeds (the strict type gate) | | `yarn test:unit` | 185 suites passed (2 skipped), 1506 tests passed (14 skipped), 12 snapshots | **On device.** The same 13-screen tour on every device: channel list, channel, attachment picker, channel details, Photos & Videos, gallery, gallery grid, Threads, Mentions, Drafts, Reminders, Pinned Messages and Files. Each screen was captured, and the inset regions were measured from pixels. | Device | Orientations | Result | | --- | --- | --- | | iPhone Duo (iOS 27.1), outer display | portrait | reserved column clear on every screen except the six headers below (captured before their fix) | | iPhone Duo, inner display (unfolded) | landscape | reserved column clear on every screen; live unfold re-lays out without a relaunch | | iPhone 18 Pro Max (iOS 27.0) | portrait + landscape | 62pt side strips clear; portrait pixel-identical to the previous verified head, apart from content | | Pixel 10 emulator (Android 17, gesture nav) | portrait + both landscape rotations | 54dp cutout strip clear on whichever side it is; no double padding on the other side | | Pixel 10 Pro Fold emulator (Android 17) | unfolded + folded, 3 orientations each | cover-screen cutout strip clear; media grid and attachment picker re-lay out on fold and are pixel-identical after unfolding again | In every run the only thing inside an inset region is the gallery grid's dimmed backdrop, which is intended. This device pass caught one regression from the inset simplification: six SampleApp screens (Mentions, Drafts, Reminders, Photos & Videos, Pinned Messages, Files) rendered their header outside the new wrapper. The Duo's compose button went under the system controls, and titles centred on the full window. It is fixed in `fix(SampleApp): wrap screen headers in the horizontal safe area` and re-verified on the Duo inner display, the Pro Max and both Android emulators. The Duo outer display has not been re-captured since the fix; it has the same root cause. **Not verified:** the Duo inner display in portrait (the simulator pins the unfolded scene to landscape); Android 3-button navigation, where the nav bar sits on a side in landscape; the toast, thread detail, message actions, polls and the keyboard; Expo. ## Known trade-offs and follow-ups - **The media and picker grids are 3 columns.** On the Duo's inner display and on the Pixel Fold unfolded, the middle column crosses the fold. Apple's Duo guidance asks for an even column count, but develop has 3 columns too, so this is not a regression. - **Rotating the media grid keeps the pixel offset, not the row.** Landscape's larger tiles land you several rows earlier. `develop` behaves the same way. - **ExpoMessaging does not launch on iOS 27.** Expo SDK 57's prebuild template predates the scene lifecycle. This needs SDK 58 or a config plugin, and is out of scope here. - **Two-pane layout on the Duo's inner display.** Apple's HIG presents this as the main adaptation for that display. For us it means `ChannelList` + `Channel` side by side, which needs a product decision. - **Native chrome in SampleApp.** Verified empirically: iOS relocates real native bars into the reserved column. SampleApp forgoes that by using `headerShown: false` and a custom JS tab bar. - **Unrelated, spotted in passing:** the Drafts list renders a literal `{{count}} Files` (`useMessagePreviewText.tsx:105`). ## โ˜‘๏ธ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [x] Documentation is updated (JSDoc on the new hooks, `@deprecated` on `useScreenDimensions`) - [ ] New code is tested in main example apps, including all possible scenarios - [x] SampleApp iOS โ€” iPhone Duo (iOS 27.1, both displays) and iPhone 18 Pro Max (iOS 27.0), portrait and landscape - [x] SampleApp Android โ€” Pixel 10 and Pixel 10 Pro Fold emulators (Android 17), portrait and both landscape directions, folded and unfolded - [ ] Expo iOS and Android โ€” untouched; `ExpoMessaging` does not launch on iOS 27 at all (see above) --------- Co-authored-by: Ivan Sekovanikj --- .../SampleApp/src/components/BottomTabs.tsx | 4 +- .../src/components/ToastComponent/Toast.tsx | 8 +- .../screens/AdvancedUserSelectorScreen.tsx | 187 +++++++++--------- .../src/screens/ChannelDetailsScreen.tsx | 12 +- .../src/screens/ChannelFilesScreen.tsx | 10 +- .../src/screens/ChannelImagesScreen.tsx | 10 +- .../src/screens/ChannelListScreen.tsx | 8 +- .../screens/ChannelPinnedMessagesScreen.tsx | 14 +- .../SampleApp/src/screens/ChannelScreen.tsx | 9 +- .../SampleApp/src/screens/DraftScreen.tsx | 14 +- .../SampleApp/src/screens/MentionsScreen.tsx | 30 +-- .../src/screens/NewDirectMessagingScreen.tsx | 8 +- .../NewGroupChannelAddMemberScreen.tsx | 6 +- .../NewGroupChannelAssignNameScreen.tsx | 6 +- .../SampleApp/src/screens/RemindersScreen.tsx | 14 +- .../src/screens/ThreadListScreen.tsx | 9 +- .../SampleApp/src/screens/ThreadScreen.tsx | 9 +- .../shared-native/ios/StreamShimmerView.swift | 92 +++++++-- .../AttachmentMediaPicker.tsx | 2 + .../AttachmentPickerItem.tsx | 14 +- .../ChannelDetails/components/modal/Modal.tsx | 3 + .../navigation-section/MediaList.tsx | 19 +- .../MediaListLoadingSkeleton.tsx | 6 +- .../components/ImageGalleryFooter.tsx | 5 +- .../components/ImageGalleryHeader.tsx | 24 ++- .../ImageGallery/components/ImageGrid.tsx | 11 +- .../KeyboardCompatibleView.tsx | 6 +- .../MessageItemView/MessageItemView.tsx | 2 +- .../UIComponents/BottomSheetModal.tsx | 8 +- .../UIComponents/SafeAreaViewWrapper.tsx | 2 +- .../__tests__/useAppStateListener.test.tsx | 127 +++++++----- package/src/hooks/useAppStateListener.ts | 22 ++- package/src/hooks/useHorizontalInsets.ts | 37 ++++ package/src/hooks/useScreenDimensions.ts | 7 + package/src/hooks/useWindowContentWidth.ts | 31 +++ 35 files changed, 534 insertions(+), 242 deletions(-) create mode 100644 package/src/hooks/useHorizontalInsets.ts create mode 100644 package/src/hooks/useWindowContentWidth.ts diff --git a/examples/SampleApp/src/components/BottomTabs.tsx b/examples/SampleApp/src/components/BottomTabs.tsx index 4c867adc20..7512905830 100644 --- a/examples/SampleApp/src/components/BottomTabs.tsx +++ b/examples/SampleApp/src/components/BottomTabs.tsx @@ -131,7 +131,7 @@ export const BottomTabs: React.FC = (props) => { const { theme: { semantics }, } = useTheme(); - const { bottom } = useSafeAreaInsets(); + const { bottom, left, right } = useSafeAreaInsets(); return ( = (props) => { backgroundColor: semantics.backgroundCoreElevation1, borderTopColor: semantics.borderCoreSubtle, paddingBottom: bottom, + paddingLeft: left, + paddingRight: right, }, ]} > diff --git a/examples/SampleApp/src/components/ToastComponent/Toast.tsx b/examples/SampleApp/src/components/ToastComponent/Toast.tsx index 57d3518ccb..86ed2d3df2 100644 --- a/examples/SampleApp/src/components/ToastComponent/Toast.tsx +++ b/examples/SampleApp/src/components/ToastComponent/Toast.tsx @@ -1,4 +1,4 @@ -import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { Easing, SlideInDown, SlideOutDown } from 'react-native-reanimated'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -7,8 +7,6 @@ import { useInAppNotificationsState, useTheme } from 'stream-chat-react-native'; import { useLegacyColors } from '../../theme/useLegacyColors'; -const { width } = Dimensions.get('window'); - type KnownSeverity = 'error' | 'success' | 'warning' | 'info'; const severityIconMap: Record = { @@ -67,7 +65,9 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', }, toast: { - width: width * 0.9, + // Percentage of the parent's content box, so the container's safe area padding is already + // accounted for. A window-derived width is not. + width: '90%', borderRadius: 12, padding: 12, marginBottom: 8, diff --git a/examples/SampleApp/src/screens/AdvancedUserSelectorScreen.tsx b/examples/SampleApp/src/screens/AdvancedUserSelectorScreen.tsx index 4bd3f625e8..2a048c6e4b 100644 --- a/examples/SampleApp/src/screens/AdvancedUserSelectorScreen.tsx +++ b/examples/SampleApp/src/screens/AdvancedUserSelectorScreen.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Alert, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { KeyboardCompatibleView, useTheme, version } from 'stream-chat-react-native'; @@ -8,6 +8,7 @@ import { useAppContext } from '../context/AppContext'; import { useLegacyColors } from '../theme/useLegacyColors'; const styles = StyleSheet.create({ + safeArea: { flex: 1 }, bottomContainer: { paddingHorizontal: 16, }, @@ -168,102 +169,104 @@ export const AdvancedUserSelectorScreen: React.FC = () => { return isValid; }; return ( - - - - - { - setApiKeyError(false); - setApiKey(text); - }} - value={apiKey} - /> - { - setUserIdError(false); - setUserId(text); - }} - value={userId} - /> - { - setUserTokenError(false); - setUserToken(text); - }} - value={userToken} - /> - { - setUserName(text); - }} - value={userName} - /> - - - { - if (!isValidInput()) { - return; - } + + + + + + { + setApiKeyError(false); + setApiKey(text); + }} + value={apiKey} + /> + { + setUserIdError(false); + setUserId(text); + }} + value={userId} + /> + { + setUserTokenError(false); + setUserToken(text); + }} + value={userToken} + /> + { + setUserName(text); + }} + value={userName} + /> + + + { + if (!isValidInput()) { + return; + } - try { - await loginUser({ - apiKey, - userId, - userName, - userToken, - }); - } catch (e) { - Alert.alert( - `Login resulted in error. Please make sure you have entered valid credentials. Error: ${(e as Error).message}`, - ); - console.warn(e); - } - }} - style={[ - styles.bottomInnerContainer, - { - backgroundColor: button_background, - }, - ]} - > - + + Login + + + - Login + Stream SDK v{version} - - - Stream SDK v{version} - + - - + + ); }; diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx index 073f4fc1c6..fc5635de07 100644 --- a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -1,5 +1,9 @@ import React, { useCallback } from 'react'; +import { StyleSheet } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; + import { useNavigation, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -125,7 +129,9 @@ const ChannelDetailsScreenInner = () => { ChannelMemberActionsSheet: MemberActionsSheet, }} > - + + + ); }; @@ -141,3 +147,7 @@ export const ChannelDetailsScreen: React.FC = ({ ); }; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index 9c32f36afc..b8fab03ee4 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import type { RouteProp } from '@react-navigation/native'; import { ChannelDetailsContextProvider, @@ -32,11 +34,13 @@ export const ChannelFilesScreen: React.FC = ({ useTheme(); return ( - + - + + + - + ); }; diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index 14b9a18a5b..fe3561e3b8 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import type { RouteProp } from '@react-navigation/native'; import { ChannelDetailsContextProvider, MediaList } from 'stream-chat-react-native'; @@ -24,11 +26,13 @@ export const ChannelImagesScreen: React.FC = ({ }, }) => { return ( - + - + + + - + ); }; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index 9258afcb6d..94e6c880e8 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -10,6 +10,8 @@ import { View, } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { NavigationProp, useNavigation, useScrollToTop } from '@react-navigation/native'; import { Channel, MessageResponse } from 'stream-chat'; import { @@ -175,7 +177,8 @@ export const ChannelListScreen: React.FC = () => { } return ( - { { backgroundColor: white, borderColor: grey_whisper, + marginHorizontal: 8, }, ]} > @@ -258,6 +262,6 @@ export const ChannelListScreen: React.FC = () => { - + ); }; diff --git a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx index 4ace8fb3bb..6c511b949c 100644 --- a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx @@ -1,6 +1,8 @@ import React, { useCallback } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { useNavigation, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { @@ -104,13 +106,15 @@ export const ChannelPinnedMessagesScreen: React.FC { useTheme(); return ( - + - - - + + + + + - + ); }; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 012c9a057c..303fafcb95 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -263,8 +265,9 @@ export const ChannelScreen: React.FC = ({ navigation, route } return ( - navigation.goBack()} style={[styles.flex, { backgroundColor: 'transparent' }]} > @@ -306,7 +309,7 @@ export const ChannelScreen: React.FC = ({ navigation, route /> )} - + ); }; diff --git a/examples/SampleApp/src/screens/DraftScreen.tsx b/examples/SampleApp/src/screens/DraftScreen.tsx index 053d3139be..cb706c00b1 100644 --- a/examples/SampleApp/src/screens/DraftScreen.tsx +++ b/examples/SampleApp/src/screens/DraftScreen.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useTheme } from 'stream-chat-react-native'; @@ -19,7 +21,8 @@ export const DraftsScreen: React.FC = () => { const { white_snow } = useLegacyColors(); return ( - = () => { ]} > - - + + + + ); }; const styles = StyleSheet.create({ + flex: { + flex: 1, + }, container: { flex: 1, }, diff --git a/examples/SampleApp/src/screens/MentionsScreen.tsx b/examples/SampleApp/src/screens/MentionsScreen.tsx index 5c6b85f221..31372afad1 100644 --- a/examples/SampleApp/src/screens/MentionsScreen.tsx +++ b/examples/SampleApp/src/screens/MentionsScreen.tsx @@ -1,6 +1,8 @@ import React, { RefObject, useMemo, useRef } from 'react'; import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { useScrollToTop } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MessageResponse } from 'stream-chat'; @@ -16,6 +18,9 @@ import { useLegacyColors } from '../theme/useLegacyColors'; import type { BottomTabNavigatorParamList } from '../types'; const styles = StyleSheet.create({ + flex: { + flex: 1, + }, container: { flex: 1, }, @@ -67,7 +72,8 @@ export const MentionsScreen: React.FC = () => { usePaginatedSearchedMessages(messageFilters); return ( - = () => { ]} > - - + + + + ); }; diff --git a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index 68cca68708..6dadbfa7fb 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -347,7 +347,13 @@ export const NewDirectMessagingScreen: React.FC = } if (!currentChannel.current) { - return renderUserSearch({ inSafeArea: false }); + // Only the channel path below sits inside a `SafeAreaView`; this one needs its own horizontal + // insets. `inSafeArea` stays false - it governs the header's *top* inset, which is unchanged. + return ( + + {renderUserSearch({ inSafeArea: false })} + + ); } return ( diff --git a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx index 04611b9016..4e688d3279 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAddMemberScreen.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useRef } from 'react'; import { FlatList, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Search, TextInputRef, useTheme } from 'stream-chat-react-native'; @@ -113,7 +115,7 @@ export const NewGroupChannelAddMemberScreen: React.FC = ({ navigation }) } return ( - + = ({ navigation }) /> - + ); }; diff --git a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx index 3c2e8eec0d..e30ad54f59 100644 --- a/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx +++ b/examples/SampleApp/src/screens/NewGroupChannelAssignNameScreen.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; import { StyleSheet, Text, TextInput, View } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -113,7 +115,7 @@ export const NewGroupChannelAssignNameScreen: React.FC + } @@ -182,6 +184,6 @@ export const NewGroupChannelAssignNameScreen: React.FC )} - + ); }; diff --git a/examples/SampleApp/src/screens/RemindersScreen.tsx b/examples/SampleApp/src/screens/RemindersScreen.tsx index 94e4a24790..f2c192311b 100644 --- a/examples/SampleApp/src/screens/RemindersScreen.tsx +++ b/examples/SampleApp/src/screens/RemindersScreen.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useTheme } from 'stream-chat-react-native'; @@ -21,7 +23,8 @@ export const RemindersScreen: React.FC = () => { const { white_snow } = useLegacyColors(); return ( - = () => { ]} > - - + + + + ); }; const styles = StyleSheet.create({ + flex: { + flex: 1, + }, container: { flex: 1, }, diff --git a/examples/SampleApp/src/screens/ThreadListScreen.tsx b/examples/SampleApp/src/screens/ThreadListScreen.tsx index 52d1b7a5eb..969d66d1a9 100644 --- a/examples/SampleApp/src/screens/ThreadListScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadListScreen.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -36,7 +38,8 @@ export const ThreadListScreen: React.FC = () => { const isFocused = useIsFocused(); return ( - = () => { }); }} /> - + ); }; diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 787724baa2..1ae49cf901 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -1,5 +1,7 @@ import React, { useCallback } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; + +import { SafeAreaView } from 'react-native-safe-area-context'; import { type RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -145,8 +147,9 @@ export const ThreadScreen: React.FC = ({ navigation, route }) ); return ( - navigation.goBack()} style={[styles.container, { backgroundColor: white }]} > @@ -171,6 +174,6 @@ export const ThreadScreen: React.FC = ({ navigation, route }) shouldUseFlashList={messageListImplementation === 'flashlist'} /> - + ); }; diff --git a/package/shared-native/ios/StreamShimmerView.swift b/package/shared-native/ios/StreamShimmerView.swift index d126cdd5b2..b6038e3549 100644 --- a/package/shared-native/ios/StreamShimmerView.swift +++ b/package/shared-native/ios/StreamShimmerView.swift @@ -3,6 +3,8 @@ import UIKit private protocol StreamShimmerAppLifecycleObserving: AnyObject { func shimmerAppLifecycleDidChange(isActive: Bool) + /// A scene changed activation state; observers re-evaluate their own. + func shimmerSceneLifecycleDidChange() } private final class StreamShimmerAppLifecycleCoordinator: NSObject { @@ -28,6 +30,21 @@ private final class StreamShimmerAppLifecycleCoordinator: NSObject { name: UIApplication.didEnterBackgroundNotification, object: nil ) + + // A scene can change activation state with no app-level transition (Split View). + for name in [ + UIScene.didActivateNotification, + UIScene.willDeactivateNotification, + UIScene.didEnterBackgroundNotification, + UIScene.willEnterForegroundNotification, + ] { + notificationCenter.addObserver( + self, + selector: #selector(handleSceneLifecycleChange), + name: name, + object: nil + ) + } } func addObserver(_ observer: StreamShimmerAppLifecycleObserving) { @@ -49,6 +66,13 @@ private final class StreamShimmerAppLifecycleCoordinator: NSObject { broadcastAppState(isActive: false) } + @objc + private func handleSceneLifecycleChange() { + for case let observer as StreamShimmerAppLifecycleObserving in observers.allObjects { + observer.shimmerSceneLifecycleDidChange() + } + } + private func broadcastAppState(isActive: Bool) { self.isAppActive = isActive @@ -83,8 +107,35 @@ public final class StreamShimmerView: UIView { private static let shimmerAnimationKey = "stream_shimmer_translate_x" private static let gradientLocations: [NSNumber] = [0.0, 0.35, 0.5, 0.65, 1.0] private static let gradientAlphaFactors: [CGFloat] = [0, softHighlightAlpha, 1, softHighlightAlpha, 0] - private static var animationDistanceTolerance: CGFloat { - 1 / max(UIScreen.main.scale, 1) + /// Scale of the display this view is on. Not `UIScreen.main`: an iPhone app is resizable from + /// iOS 27 and may be on a secondary display. Reports 0 before the view has a window. + private var currentDisplayScale: CGFloat { + let traitScale = traitCollection.displayScale + if traitScale > 0 { + return traitScale + } + return window?.windowScene?.screen.scale ?? 1 + } + + private var animationDistanceTolerance: CGFloat { + 1 / max(currentDisplayScale, 1) + } + + /// Whether this view's scene is on screen. Preferred over the app-wide `applicationState`, which + /// cannot distinguish scenes in Split View. `foregroundInactive` counts as on screen: that is the + /// visible-but-unfocused state a side-by-side scene reports. + private var isSceneOnScreen: Bool { + guard let activationState = window?.windowScene?.activationState else { + return isAppActive + } + switch activationState { + case .foregroundActive, .foregroundInactive: + return true + case .background, .unattached: + return false + @unknown default: + return true + } } private let baseLayer = CALayer() @@ -140,14 +191,17 @@ public final class StreamShimmerView: UIView { // a later reattach starts from a clean state. stopAnimation() } else { - // Reattaching (including reparenting across windows) re-evaluates state and restarts only - // when needed by current bounds/visibility/enablement. + // The new window may be on a display with a different scale. + syncContentsScale() updateLayersForCurrentState() } } public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.displayScale != previousTraitCollection?.displayScale { + syncContentsScale() + } if let previousTraitCollection, traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { @@ -194,10 +248,16 @@ public final class StreamShimmerView: UIView { lastAnimatedTravelDistance = 0 } + private func syncContentsScale() { + let scale = currentDisplayScale + guard shimmerLayer.contentsScale != scale else { return } + shimmerLayer.contentsScale = scale + } + private func setupLayers() { isUserInteractionEnabled = false - shimmerLayer.contentsScale = UIScreen.main.scale + shimmerLayer.contentsScale = currentDisplayScale shimmerLayer.allowsEdgeAntialiasing = true shimmerLayer.startPoint = CGPoint(x: 0, y: 0.5) shimmerLayer.endPoint = CGPoint(x: 1, y: 0.5) @@ -249,7 +309,7 @@ public final class StreamShimmerView: UIView { private func updateShimmerAnimation(for bounds: CGRect) { guard enabled, - isAppActive, + isSceneOnScreen, window != nil, !isHidden, alpha > 0.01, @@ -267,7 +327,7 @@ public final class StreamShimmerView: UIView { // restarting. Fabric can relayout the view for height-only or subpixel changes that do not // require a new horizontal sweep. if shimmerLayer.animation(forKey: Self.shimmerAnimationKey) != nil, - abs(lastAnimatedTravelDistance - animationTravelDistance) <= Self.animationDistanceTolerance, + abs(lastAnimatedTravelDistance - animationTravelDistance) <= animationDistanceTolerance, lastAnimatedDuration == shimmerDuration { return @@ -339,14 +399,22 @@ public final class StreamShimmerView: UIView { } extension StreamShimmerView: StreamShimmerAppLifecycleObserving { - func shimmerAppLifecycleDidChange(isActive: Bool) { - // iOS can drop active layer animations while the app is backgrounded. We explicitly rerun - // a state update on foreground so shimmer reliably restarts when returning to the app. - self.isAppActive = isActive - if isActive { + func shimmerSceneLifecycleDidChange() { + reevaluateForSceneState() + } + + private func reevaluateForSceneState() { + if isSceneOnScreen { updateLayersForCurrentState() } else { stopAnimation() } } + + func shimmerAppLifecycleDidChange(isActive: Bool) { + // iOS can drop active layer animations while the app is backgrounded. We explicitly rerun + // a state update on foreground so shimmer reliably restarts when returning to the app. + self.isAppActive = isActive + reevaluateForSceneState() + } } diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx index 09d1ddb230..c939244005 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Linking, StyleSheet } from 'react-native'; import { renderAttachmentPickerItem } from './AttachmentPickerItem'; + import { IOS_LIMITED_DEEPLINK, type PhotoContentItemType } from './shared'; import { useAttachmentPickerContext, useTheme, useTranslationContext } from '../../../../contexts'; @@ -170,6 +171,7 @@ export const AttachmentMediaPicker = (props: AttachmentPickerContentProps) => { contentContainerStyle={[styles.container, bottomSheetContentContainer]} data={photos} keyExtractor={keyExtractor} + key={numberOfColumns} numColumns={numberOfColumns} onEndReached={photoError ? undefined : getMorePhotos} renderItem={renderAttachmentPickerItem} diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx index 2af5aed769..97a236bf97 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx @@ -14,7 +14,7 @@ import { useMessageComposer } from '../../../../contexts/messageInputContext/hoo import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; -import { useViewport } from '../../../../hooks/useViewport'; +import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth'; import { NativeHandlers } from '../../../../native'; import { primitives } from '../../../../theme'; import type { File } from '../../../../types/types'; @@ -29,7 +29,7 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { const { asset } = props; const { numberOfAttachmentPickerImageColumns } = useAttachmentPickerContext(); const { ImageOverlaySelectedComponent } = useComponentsContext(); - const { vw } = useViewport(); + const contentWidth = useWindowContentWidth(); const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); const { uploadNewFile } = useMessageInputContext(); @@ -51,7 +51,7 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { const { duration: videoDuration, thumb_url } = asset; - const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; + const size = contentWidth / (numberOfAttachmentPickerImageColumns || 3) - 2; const selected = selectedIndex !== -1; const accessibilityLabel = useA11yLabel(selected ? 'a11y/Deselect video' : 'a11y/Select video'); @@ -105,7 +105,7 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { }, } = useTheme(); const styles = useStyles(); - const { vw } = useViewport(); + const contentWidth = useWindowContentWidth(); const { t } = useTranslationContext(); const { uploadNewFile } = useMessageInputContext(); const messageComposer = useMessageComposer(); @@ -115,7 +115,7 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { isLocalImageAttachment(attachment) ? attachment.localMetadata.previewUri === asset.uri : false, ); - const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; + const size = contentWidth / (numberOfAttachmentPickerImageColumns || 3) - 2; const selected = selectedIndex !== -1; const accessibilityLabel = useA11yLabel(selected ? 'a11y/Deselect image' : 'a11y/Select image'); @@ -163,9 +163,9 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { const AttachmentIosLimited = () => { const { numberOfAttachmentPickerImageColumns } = useAttachmentPickerContext(); const { icons } = useComponentsContext(); - const { vw } = useViewport(); + const contentWidth = useWindowContentWidth(); const { t } = useTranslationContext(); - const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; + const size = contentWidth / (numberOfAttachmentPickerImageColumns || 3) - 2; const styles = useStyles(); return ( { @@ -56,6 +58,7 @@ export const ChannelDetailsModal = ({ presentationStyle === 'fullScreen' || Platform.OS === 'android' ? { paddingTop: top } : {}, + horizontalInsets, bodyOverride, ]} > diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx index da95910d57..6bcc6789fc 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx @@ -1,12 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - FlatList, - type FlatListProps, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native'; +import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native'; import { formatMessage, @@ -30,6 +23,7 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; import { useStateStore } from '../../../../hooks/useStateStore'; +import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth'; import { isVideoPlayerAvailable } from '../../../../native'; import { primitives } from '../../../../theme'; import { FileTypes } from '../../../../types/types'; @@ -41,9 +35,6 @@ import { NotificationTargetProvider } from '../../../Notifications/NotificationT import { EmptyList } from '../../../UIComponents/EmptyList'; import { type MediaTile, useMediaList } from '../../hooks/useMediaList'; -const NUMBER_OF_COLUMNS = 3; -const GRID_GAP = primitives.spacingXxxs; - export type MediaListProps = { /** * Besides the existing default behavior of the media list, you can attach additional props to @@ -60,6 +51,9 @@ export type MediaListProps = { searchSource?: MessageSearchSource; }; +const NUMBER_OF_COLUMNS = 3; +const GRID_GAP = primitives.spacingXxxs; + const keyExtractor = (item: MediaTile, index: number) => `${item.message.id}-${index}`; const listStateSelector = (state: SearchSourceState) => ({ @@ -77,7 +71,7 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { }, } = useTheme(); const styles = useStyles(); - const { width } = useWindowDimensions(); + const width = useWindowContentWidth(); const { icons, MediaItem } = useComponentsContext(); const { addNotification } = useNotificationApi(); @@ -128,7 +122,6 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { const tiles = useMediaList(messages); - // Tile side length: full width minus the inter-column gaps, split across the columns. const tileSize = useMemo( () => (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS, [width], diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx index 4ed558f3e6..b4451633b5 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx @@ -1,7 +1,8 @@ import React, { useMemo } from 'react'; -import { StyleSheet, useWindowDimensions, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth'; import { primitives } from '../../../../theme'; import { NativeShimmerView } from '../../../UIComponents/NativeShimmerView'; @@ -17,7 +18,8 @@ export const MediaListLoadingSkeleton = () => { const { theme: { semantics }, } = useTheme(); - const { width } = useWindowDimensions(); + // Same derivation as `MediaList`, so the skeleton's tiles match the grid that replaces it. + const width = useWindowContentWidth(); const styles = useStyles(); const tileSize = (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS; diff --git a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx index daa68a97ca..bc538ef7ce 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx @@ -127,7 +127,10 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterProps) => pointerEvents={'box-none'} style={styles.wrapper} > - + {asset.type === FileTypes.Video ? ( ImageGalleryVideoControls ? ( diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index 812f58071b..0a3bed619f 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -18,6 +18,23 @@ import { ImageGalleryState } from '../../../state-store/image-gallery-state-stor import { primitives } from '../../../theme'; import { getDateString } from '../../../utils/i18n/getDateString'; import { Button } from '../../ui/Button/Button'; +import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; + +// Never called - it exists only so `ReturnType` below can name the animated component's type. +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in type position only +const createAnimatedSafeAreaViewType = () => Animated.createAnimatedComponent(SafeAreaView); + +/** + * A frame-aware wrapper, matching `ImageGalleryFooter`. `SafeAreaView` compares the window inset + * against its own measured frame, so a nested instance contributes nothing once an ancestor has + * already narrowed the subtree - unlike `useSafeAreaInsets`, which is window-global and would + * double-pad this header inside `ChannelDetailsModal`, which insets its own root. + * + * `createAnimatedComponent` is guarded because a stripped-down Reanimated mock may not provide it. + */ +const ReanimatedSafeAreaView = ( + Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) : SafeAreaView +) as ReturnType; const imageGallerySelector = (state: ImageGalleryState) => ({ asset: state.assets[state.currentIndex], @@ -69,7 +86,10 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => { onLayout={(event) => setHeight(event.nativeEvent.layout.height)} pointerEvents={'box-none'} > - +