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 ( & | 'bottomInset' | 'topInset' | 'disableAttachmentPicker' + | 'shouldRenderAttachmentPicker' | 'numberOfAttachmentPickerImageColumns' | 'numberOfAttachmentImagesToLoadPerCall' > @@ -389,6 +390,7 @@ export type ChannelPropsWithContext = Pick & const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), + shouldRenderAttachmentPicker = true, additionalKeyboardAvoidingViewProps, additionalPressableProps, additionalTextInputProps, @@ -1571,6 +1573,7 @@ const ChannelWithContext = (props: PropsWithChildren) = closePicker: handleClosePicker, disableAttachmentPicker, openPicker: handleOpenPicker, + shouldRenderAttachmentPicker, topInset, numberOfAttachmentPickerImageColumns, attachmentPickerBottomSheetHeight, @@ -1583,6 +1586,7 @@ const ChannelWithContext = (props: PropsWithChildren) = handleClosePicker, disableAttachmentPicker, handleOpenPicker, + shouldRenderAttachmentPicker, topInset, numberOfAttachmentPickerImageColumns, attachmentPickerBottomSheetHeight, @@ -1805,7 +1809,7 @@ const ChannelWithContext = (props: PropsWithChildren) = {children} - + {shouldRenderAttachmentPicker ? : null} diff --git a/package/src/components/ChannelDetails/components/modal/Modal.tsx b/package/src/components/ChannelDetails/components/modal/Modal.tsx index 0437339e92..74174decaa 100644 --- a/package/src/components/ChannelDetails/components/modal/Modal.tsx +++ b/package/src/components/ChannelDetails/components/modal/Modal.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useHorizontalInsets } from '../../../../hooks/useHorizontalInsets'; type ChannelDetailsModalProps = { children: React.ReactNode; @@ -29,6 +30,7 @@ export const ChannelDetailsModal = ({ } = useTheme(); const styles = useStyles(); const { top } = useSafeAreaInsets(); + const horizontalInsets = useHorizontalInsets(); const { signalStore } = useChannelDetailsContext(); useEffect(() => { @@ -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'} > - +