From afc818f71224ccc7a89243ae061d4af1ab35e6d1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 5 Feb 2026 21:49:26 +0100 Subject: [PATCH 01/33] fix: attachment picker performance issues --- .../AttachmentPicker/AttachmentPicker.tsx | 86 ++++++++++++------- package/src/components/Channel/Channel.tsx | 2 +- .../components/MessageInput/MessageInput.tsx | 9 +- 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 59669fd70d..c71118444d 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BackHandler, EmitterSubscription, Keyboard, Platform, StyleSheet } from 'react-native'; +import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; + import BottomSheetOriginal from '@gorhom/bottom-sheet'; import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; @@ -14,6 +16,7 @@ import { renderAttachmentPickerItem } from './components/AttachmentPickerItem'; import { useAttachmentPickerContext } from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { MessageInputContextValue } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; import { useScreenDimensions } from '../../hooks/useScreenDimensions'; import { NativeHandlers } from '../../native'; import type { File } from '../../types/types'; @@ -68,6 +71,8 @@ export type AttachmentPickerProps = Pick< numberOfAttachmentPickerImageColumns?: number; }; +const keyExtractor = (item: File) => item.asset.uri; + export const AttachmentPicker = React.forwardRef( (props: AttachmentPickerProps, ref: React.ForwardedRef) => { const { @@ -96,24 +101,28 @@ export const AttachmentPicker = React.forwardRef( const fullScreenHeight = screenVh(100); - const [currentIndex, setCurrentIndex] = useState(-1); + const [currentIndex, setCurrentIndexInternal] = useState(-1); + const setCurrentIndex = useStableCallback((_: number, toIndex: number) => + setCurrentIndexInternal(toIndex), + ); const endCursorRef = useRef(undefined); const [photoError, setPhotoError] = useState(false); const [iOSLimited, setIosLimited] = useState(false); const hasNextPageRef = useRef(true); - const [loadingPhotos, setLoadingPhotos] = useState(false); + const loadingPhotosRef = useRef(false); const [photos, setPhotos] = useState([]); - const attemptedToLoadPhotosOnOpenRef = useRef(false); + const attemptedToLoadPhotosOnOpenRef = useRef(false); - const getMorePhotos = useCallback(async () => { + const getMorePhotos = useStableCallback(async () => { + console.log('TRYING TO GET MORE PHOTOS: '); if ( hasNextPageRef.current && - !loadingPhotos && + !loadingPhotosRef.current && currentIndex > -1 && selectedPicker === 'images' ) { setPhotoError(false); - setLoadingPhotos(true); + loadingPhotosRef.current = true; const endCursor = endCursorRef.current; try { if (!NativeHandlers.getPhotos) { @@ -123,25 +132,20 @@ export const AttachmentPicker = React.forwardRef( } const results = await NativeHandlers.getPhotos({ after: endCursor, - first: numberOfAttachmentImagesToLoadPerCall ?? 60, + first: numberOfAttachmentImagesToLoadPerCall ?? 25, }); endCursorRef.current = results.endCursor; - setPhotos((prevPhotos) => - endCursor ? [...prevPhotos, ...results.assets] : results.assets, - ); + setPhotos((prevPhotos) => { + return endCursor ? [...prevPhotos, ...results.assets] : results.assets; + }); setIosLimited(results.iOSLimited); hasNextPageRef.current = !!results.hasNextPage; } catch (error) { setPhotoError(true); } - setLoadingPhotos(false); + loadingPhotosRef.current = false; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex, selectedPicker, loadingPhotos]); - - // we need to use ref here to avoid running effect when getMorePhotos changes - const getMorePhotosRef = useRef(getMorePhotos); - getMorePhotosRef.current = getMorePhotos; + }); useEffect(() => { if (selectedPicker !== 'images') { @@ -158,10 +162,10 @@ export const AttachmentPicker = React.forwardRef( hasNextPageRef.current = true; endCursorRef.current = undefined; // fetch the first page of photos again - getMorePhotosRef.current(); + getMorePhotos(); }); return unsubscribe; - }, [selectedPicker]); + }, [getMorePhotos, selectedPicker]); useEffect(() => { const backAction = () => { @@ -204,7 +208,7 @@ export const AttachmentPicker = React.forwardRef( useEffect(() => { if (currentIndex < 0) { setSelectedPicker(undefined); - if (!loadingPhotos) { + if (!loadingPhotosRef.current) { endCursorRef.current = undefined; hasNextPageRef.current = true; attemptedToLoadPhotosOnOpenRef.current = false; @@ -212,7 +216,7 @@ export const AttachmentPicker = React.forwardRef( } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex, loadingPhotos]); + }, [currentIndex]); useEffect(() => { if ( @@ -220,14 +224,14 @@ export const AttachmentPicker = React.forwardRef( selectedPicker === 'images' && endCursorRef.current === undefined && currentIndex > -1 && - !loadingPhotos + !loadingPhotosRef.current ) { getMorePhotos(); // we do this only once on open for avoiding to request permissions in rationale dialog again and again on // Android attemptedToLoadPhotosOnOpenRef.current = true; } - }, [currentIndex, selectedPicker, getMorePhotos, loadingPhotos]); + }, [currentIndex, selectedPicker, getMorePhotos]); const selectedPhotos = useMemo( () => @@ -249,7 +253,12 @@ export const AttachmentPicker = React.forwardRef( * Snap points changing cause a rerender of the position, * this is an issue if you are calling close on the bottom sheet. */ - const snapPoints = [initialSnapPoint, finalSnapPoint]; + const snapPoints = useMemo( + () => [initialSnapPoint, finalSnapPoint], + [initialSnapPoint, finalSnapPoint], + ); + + const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; const MemoizedAttachmentPickerBottomSheetHandle = useCallback( (props: BottomSheetHandleProps) => @@ -263,6 +272,21 @@ export const AttachmentPicker = React.forwardRef( [AttachmentPickerBottomSheetHandle, photoError], ); + const animatedIndex = useSharedValue(currentIndex); + + // On some occasions, onAnimate does not fire whenever we pan to close the + // bottom sheet, likely due to physics giving the pan enough momentum for + // automatic animation to not be needed. To cover those cases, we react to + // the animatedIndex shared value to make sure we do proper cleanup. + useAnimatedReaction( + () => animatedIndex.value, + (currentIndex, previousIndex) => { + if (currentIndex !== previousIndex && currentIndex === -1) { + runOnJS(setSelectedPicker)(undefined); + } + }, + ); + return ( <> {iOSLimited && } item.asset.uri} - numColumns={numberOfAttachmentPickerImageColumns ?? 3} + keyExtractor={keyExtractor} + numColumns={numberOfColumns} onEndReached={photoError ? undefined : getMorePhotos} renderItem={renderAttachmentPickerItem} testID={'attachment-picker-list'} + updateCellsBatchingPeriod={16} /> {selectedPicker === 'images' && photoError && ( diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index bd88cac067..755d59e15a 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -588,7 +588,7 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, attachmentPickerErrorButtonText, attachmentPickerErrorText, - numberOfAttachmentImagesToLoadPerCall = 60, + numberOfAttachmentImagesToLoadPerCall = 25, numberOfAttachmentPickerImageColumns = 3, giphyVersion = 'fixed_height', bottomInset = 0, diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 2f7c4706ea..6e688fa28f 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -8,6 +8,8 @@ import Animated, { FadeOut, interpolate, LinearTransition, + SlideInDown, + SlideOutDown, useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; @@ -378,7 +380,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { })); const { bottom } = useSafeAreaInsets(); - const BOTTOM_OFFSET = isKeyboardVisible ? 16 : bottom ? bottom : 16; + const BOTTOM_OFFSET = isKeyboardVisible || selectedPicker ? 16 : bottom ? bottom : 16; return ( <> @@ -539,8 +541,9 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { {!disableAttachmentPicker && selectedPicker ? ( Date: Fri, 6 Feb 2026 09:37:56 +0100 Subject: [PATCH 02/33] fix: bottom sheet animation concurrency --- .../ios/SampleApp.xcodeproj/project.pbxproj | 34 ++++++---- .../AttachmentPicker/AttachmentPicker.tsx | 64 +++++++++---------- .../components/AttachmentPickerItem.tsx | 33 ++++------ package/src/components/Channel/Channel.tsx | 35 +++++++--- .../Message/MessageSimple/MessageAvatar.tsx | 2 - .../AttachmentPickerContext.tsx | 53 +++++++++++---- 6 files changed, 131 insertions(+), 90 deletions(-) diff --git a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj index 51996b9be4..ab2d68ba57 100644 --- a/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj +++ b/examples/SampleApp/ios/SampleApp.xcodeproj/project.pbxproj @@ -284,10 +284,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-frameworks.sh\"\n"; @@ -302,6 +306,8 @@ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); name = "[CP-User] [RNFB] Core Configuration"; + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; @@ -336,10 +342,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SampleApp/Pods-SampleApp-resources.sh\"\n"; @@ -353,10 +363,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-resources.sh\"\n"; @@ -370,10 +384,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SampleApp-SampleAppTests/Pods-SampleApp-SampleAppTests-frameworks.sh\"\n"; @@ -532,8 +550,8 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = SampleApp/SampleAppRelease.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 926; DEVELOPMENT_TEAM = EHV7XZLAHA; INFOPLIST_FILE = SampleApp/Info.plist; @@ -548,7 +566,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = io.getstream.reactnative.SampleApp; PRODUCT_NAME = SampleApp; - PROVISIONING_PROFILE_SPECIFIER = "match AdHoc io.getstream.reactnative.SampleApp"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -688,10 +706,7 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -823,10 +838,7 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index c71118444d..1b44451058 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -57,21 +57,12 @@ export type AttachmentPickerProps = Pick< * Custom UI Component to render select more photos for selected gallery access in iOS. */ AttachmentPickerIOSSelectMorePhotos: React.ComponentType; - /** - * Custom UI component to render overlay component, that shows up on top of [selected - * image](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) (with tick mark) - * - * **Default** - * [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) - */ - ImageOverlaySelectedComponent: React.ComponentType; attachmentPickerErrorButtonText?: string; attachmentPickerErrorText?: string; numberOfAttachmentImagesToLoadPerCall?: number; - numberOfAttachmentPickerImageColumns?: number; }; -const keyExtractor = (item: File) => item.asset.uri; +const keyExtractor = (item: File) => item.uri; export const AttachmentPicker = React.forwardRef( (props: AttachmentPickerProps, ref: React.ForwardedRef) => { @@ -84,9 +75,7 @@ export const AttachmentPicker = React.forwardRef( AttachmentPickerErrorImage, attachmentPickerErrorText, AttachmentPickerIOSSelectMorePhotos, - ImageOverlaySelectedComponent, numberOfAttachmentImagesToLoadPerCall, - numberOfAttachmentPickerImageColumns, } = props; const { @@ -95,16 +84,23 @@ export const AttachmentPicker = React.forwardRef( colors: { white }, }, } = useTheme(); - const { closePicker, selectedPicker, setSelectedPicker, topInset } = - useAttachmentPickerContext(); + const { + closePicker, + selectedPicker, + setSelectedPicker, + topInset, + numberOfAttachmentPickerImageColumns, + } = useAttachmentPickerContext(); const { vh: screenVh } = useScreenDimensions(); const fullScreenHeight = screenVh(100); const [currentIndex, setCurrentIndexInternal] = useState(-1); - const setCurrentIndex = useStableCallback((_: number, toIndex: number) => - setCurrentIndexInternal(toIndex), - ); + const currentIndexRef = useRef(currentIndex); + const setCurrentIndex = useStableCallback((_: number, toIndex: number) => { + setCurrentIndexInternal(toIndex); + currentIndexRef.current = toIndex; + }); const endCursorRef = useRef(undefined); const [photoError, setPhotoError] = useState(false); const [iOSLimited, setIosLimited] = useState(false); @@ -114,7 +110,6 @@ export const AttachmentPicker = React.forwardRef( const attemptedToLoadPhotosOnOpenRef = useRef(false); const getMorePhotos = useStableCallback(async () => { - console.log('TRYING TO GET MORE PHOTOS: '); if ( hasNextPageRef.current && !loadingPhotosRef.current && @@ -130,13 +125,27 @@ export const AttachmentPicker = React.forwardRef( setIosLimited(false); return; } + const results = await NativeHandlers.getPhotos({ after: endCursor, first: numberOfAttachmentImagesToLoadPerCall ?? 25, }); + endCursorRef.current = results.endCursor; + // skip updating if the sheet closed in the meantime, to avoid + // confusing the bottom sheet internals setPhotos((prevPhotos) => { - return endCursor ? [...prevPhotos, ...results.assets] : results.assets; + if (endCursor) { + return [...prevPhotos, ...results.assets]; + } + + for (let i = 0; i < results.assets.length; i++) { + if (results.assets[i].uri !== prevPhotos[i]?.uri) { + return results.assets; + } + } + + return prevPhotos.slice(0, results.assets.length); }); setIosLimited(results.iOSLimited); hasNextPageRef.current = !!results.hasNextPage; @@ -215,8 +224,7 @@ export const AttachmentPicker = React.forwardRef( setPhotoError(false); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex]); + }, [currentIndex, setSelectedPicker]); useEffect(() => { if ( @@ -233,16 +241,6 @@ export const AttachmentPicker = React.forwardRef( } }, [currentIndex, selectedPicker, getMorePhotos]); - const selectedPhotos = useMemo( - () => - photos.map((asset) => ({ - asset, - ImageOverlaySelectedComponent, - numberOfAttachmentPickerImageColumns, - })), - [photos, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns], - ); - const handleHeight = attachmentPickerBottomSheetHandleHeight; const initialSnapPoint = attachmentPickerBottomSheetHeight; @@ -282,7 +280,7 @@ export const AttachmentPicker = React.forwardRef( () => animatedIndex.value, (currentIndex, previousIndex) => { if (currentIndex !== previousIndex && currentIndex === -1) { - runOnJS(setSelectedPicker)(undefined); + runOnJS(setSelectedPicker)(undefined, true); } }, ); @@ -307,7 +305,7 @@ export const AttachmentPicker = React.forwardRef( { backgroundColor: white, opacity: photoError ? 0 : 1 }, bottomSheetContentContainer, ]} - data={selectedPhotos} + data={photos} keyExtractor={keyExtractor} numColumns={numberOfColumns} onEndReached={photoError ? undefined : getMorePhotos} diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx index fb3bc84acb..4041eef07e 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx @@ -4,6 +4,7 @@ import { Alert, ImageBackground, StyleSheet, Text, View } from 'react-native'; import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat'; +import { useAttachmentPickerContext } from '../../../contexts'; import { useAttachmentManagerState } from '../../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; @@ -17,12 +18,12 @@ import { BottomSheetTouchableOpacity } from '../../BottomSheetCompatibility/Bott type AttachmentPickerItemType = { asset: File; - ImageOverlaySelectedComponent: React.ComponentType; - numberOfAttachmentPickerImageColumns?: number; }; const AttachmentVideo = (props: AttachmentPickerItemType) => { - const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = props; + const { asset } = props; + const { numberOfAttachmentPickerImageColumns, ImageOverlaySelectedComponent } = + useAttachmentPickerContext(); const { vw } = useViewport(); const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); @@ -97,7 +98,9 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { }; const AttachmentImage = (props: AttachmentPickerItemType) => { - const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = props; + const { asset } = props; + const { numberOfAttachmentPickerImageColumns, ImageOverlaySelectedComponent } = + useAttachmentPickerContext(); const { theme: { attachmentPicker: { image, imageOverlay }, @@ -159,34 +162,20 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { ); }; -export const renderAttachmentPickerItem = ({ item }: { item: AttachmentPickerItemType }) => { - const { asset, ImageOverlaySelectedComponent, numberOfAttachmentPickerImageColumns } = item; - +export const renderAttachmentPickerItem = ({ item }: { item: File }) => { /** * Expo Media Library - Result of asset type * Native Android - Gives mime type(Eg: image/jpeg, video/mp4, etc.) * Native iOS - Gives `image` or `video` * Expo Android/iOS - Gives `photo` or `video` **/ - const isVideoType = asset.type.includes('video'); + const isVideoType = item.type.includes('video'); if (isVideoType) { - return ( - - ); + return ; } - return ( - - ); + return ; }; const styles = StyleSheet.create({ diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 755d59e15a..2e51dda077 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -278,7 +278,14 @@ const debounceOptions = { export type ChannelPropsWithContext = Pick & Partial< - Pick + Pick< + AttachmentPickerContextValue, + | 'bottomInset' + | 'topInset' + | 'disableAttachmentPicker' + | 'ImageOverlaySelectedComponent' + | 'numberOfAttachmentPickerImageColumns' + > > & Partial< Pick< @@ -286,11 +293,9 @@ export type ChannelPropsWithContext = Pick & | 'AttachmentPickerError' | 'AttachmentPickerErrorImage' | 'AttachmentPickerIOSSelectMorePhotos' - | 'ImageOverlaySelectedComponent' | 'attachmentPickerErrorButtonText' | 'attachmentPickerErrorText' | 'numberOfAttachmentImagesToLoadPerCall' - | 'numberOfAttachmentPickerImageColumns' > > & Partial< @@ -1743,9 +1748,7 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorText, AttachmentPickerIOSSelectMorePhotos, attachmentSelectionBarHeight, - ImageOverlaySelectedComponent, numberOfAttachmentImagesToLoadPerCall, - numberOfAttachmentPickerImageColumns, }), [ AttachmentPickerBottomSheetHandle, @@ -1757,22 +1760,34 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorText, AttachmentPickerIOSSelectMorePhotos, attachmentSelectionBarHeight, - ImageOverlaySelectedComponent, numberOfAttachmentImagesToLoadPerCall, - numberOfAttachmentPickerImageColumns, ], ); + const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef)); + const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef)); + const attachmentPickerContext = useMemo( () => ({ bottomInset, bottomSheetRef, - closePicker: () => closePicker(bottomSheetRef), + closePicker: handleClosePicker, disableAttachmentPicker, - openPicker: () => openPicker(bottomSheetRef), + openPicker: handleOpenPicker, topInset, + ImageOverlaySelectedComponent, + numberOfAttachmentPickerImageColumns, }), - [bottomInset, bottomSheetRef, closePicker, openPicker, topInset, disableAttachmentPicker], + [ + bottomInset, + bottomSheetRef, + handleClosePicker, + disableAttachmentPicker, + handleOpenPicker, + topInset, + ImageOverlaySelectedComponent, + numberOfAttachmentPickerImageColumns, + ], ); const ownCapabilitiesContext = useCreateOwnCapabilitiesContext({ diff --git a/package/src/components/Message/MessageSimple/MessageAvatar.tsx b/package/src/components/Message/MessageSimple/MessageAvatar.tsx index ec5e58c0b6..eb02ed9ead 100644 --- a/package/src/components/Message/MessageSimple/MessageAvatar.tsx +++ b/package/src/components/Message/MessageSimple/MessageAvatar.tsx @@ -26,8 +26,6 @@ const MessageAvatarWithContext = (props: MessageAvatarPropsWithContext) => { const visible = typeof showAvatar === 'boolean' ? showAvatar : lastGroupMessage; - console.log(message); - return ( void; openPicker: () => void; setBottomInset: React.Dispatch>; - setSelectedPicker: React.Dispatch>; + setSelectedPicker: ( + valueOrUpdater: React.SetStateAction<'images' | undefined>, + debounceClose?: boolean, + ) => void; setTopInset: React.Dispatch>; topInset: number; selectedPicker?: 'images'; disableAttachmentPicker?: boolean; + /** + * Custom UI component to render overlay component, that shows up on top of [selected + * image](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) (with tick mark) + * + * **Default** + * [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) + */ + ImageOverlaySelectedComponent: React.ComponentType; + numberOfAttachmentPickerImageColumns?: number; }; export const AttachmentPickerContext = React.createContext( @@ -47,9 +60,22 @@ export const AttachmentPickerProvider = ({ const topInsetValue = value?.topInset; const [bottomInset, setBottomInset] = useState(bottomInsetValue ?? 0); - const [selectedPicker, setSelectedPicker] = useState<'images'>(); + const [selectedPicker, setSelectedPickerT] = useState<'images'>(); const [topInset, setTopInset] = useState(topInsetValue ?? 0); + const lastChangedRef = useRef(-1); + + const setSelectedPicker = useStableCallback( + (value: React.SetStateAction<'images' | undefined>, debounceClose?: boolean) => { + const now = Date.now(); + if (debounceClose && !value && now - lastChangedRef.current < 500) { + return; + } + lastChangedRef.current = now; + setSelectedPickerT(value); + }, + ); + useEffect(() => { setBottomInset(bottomInsetValue ?? 0); }, [bottomInsetValue]); @@ -58,15 +84,18 @@ export const AttachmentPickerProvider = ({ setTopInset(topInsetValue ?? 0); }, [topInsetValue]); - const combinedValue = { - selectedPicker, - setBottomInset, - setSelectedPicker, - setTopInset, - ...value, - bottomInset, - topInset, - }; + const combinedValue = useMemo( + () => ({ + selectedPicker, + setBottomInset, + setSelectedPicker, + setTopInset, + ...value, + bottomInset, + topInset, + }), + [bottomInset, selectedPicker, setSelectedPicker, topInset, value], + ); return ( Date: Fri, 6 Feb 2026 13:35:34 +0100 Subject: [PATCH 03/33] feat: introduce separate state store for picker state --- .../AttachmentPicker/AttachmentPicker.tsx | 33 ++++++------ .../components/AttachmentPickerError.tsx | 4 +- .../AttachmentPickerSelectionBar.tsx | 16 +++--- .../components/MessageInput/MessageInput.tsx | 16 ++---- .../components/InputButtons/AttachButton.tsx | 17 +++---- .../components/InputButtons/index.tsx | 15 +----- .../MessageList/MessageFlashList.tsx | 20 ++++---- .../components/MessageList/MessageList.tsx | 20 ++++---- .../AttachmentPickerContext.tsx | 50 +++---------------- .../MessageInputContext.tsx | 17 +++---- .../hooks/useCreateMessageInputContext.ts | 4 +- package/src/hooks/useAttachmentPickerState.ts | 13 +++++ .../state-store/attachment-picker-store.ts | 25 ++++++++++ 13 files changed, 111 insertions(+), 139 deletions(-) create mode 100644 package/src/hooks/useAttachmentPickerState.ts create mode 100644 package/src/state-store/attachment-picker-store.ts diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 1b44451058..9ec42e0c3e 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -17,8 +17,10 @@ import { useAttachmentPickerContext } from '../../contexts/attachmentPickerConte import { MessageInputContextValue } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; +import { useAttachmentPickerState } from '../../hooks/useAttachmentPickerState'; import { useScreenDimensions } from '../../hooks/useScreenDimensions'; import { NativeHandlers } from '../../native'; +import { SelectedPickerType } from '../../state-store/attachment-picker-store'; import type { File } from '../../types/types'; import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet'; import { BottomSheetFlatList } from '../BottomSheetCompatibility/BottomSheetFlatList'; @@ -84,13 +86,9 @@ export const AttachmentPicker = React.forwardRef( colors: { white }, }, } = useTheme(); - const { - closePicker, - selectedPicker, - setSelectedPicker, - topInset, - numberOfAttachmentPickerImageColumns, - } = useAttachmentPickerContext(); + const { closePicker, attachmentPickerStore, topInset, numberOfAttachmentPickerImageColumns } = + useAttachmentPickerContext(); + const { selectedPicker } = useAttachmentPickerState(); const { vh: screenVh } = useScreenDimensions(); const fullScreenHeight = screenVh(100); @@ -178,8 +176,8 @@ export const AttachmentPicker = React.forwardRef( useEffect(() => { const backAction = () => { - if (selectedPicker) { - setSelectedPicker(undefined); + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); return true; } @@ -190,12 +188,12 @@ export const AttachmentPicker = React.forwardRef( const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); return () => backHandler.remove(); - }, [selectedPicker, closePicker, setSelectedPicker]); + }, [attachmentPickerStore, closePicker]); useEffect(() => { const onKeyboardOpenHandler = () => { - if (selectedPicker) { - setSelectedPicker(undefined); + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { + attachmentPickerStore.setSelectedPicker(undefined); } closePicker(); }; @@ -212,11 +210,11 @@ export const AttachmentPicker = React.forwardRef( return () => { keyboardSubscription?.remove(); }; - }, [closePicker, selectedPicker, setSelectedPicker]); + }, [attachmentPickerStore, closePicker]); useEffect(() => { if (currentIndex < 0) { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); if (!loadingPhotosRef.current) { endCursorRef.current = undefined; hasNextPageRef.current = true; @@ -224,7 +222,7 @@ export const AttachmentPicker = React.forwardRef( setPhotoError(false); } } - }, [currentIndex, setSelectedPicker]); + }, [currentIndex, attachmentPickerStore]); useEffect(() => { if ( @@ -271,6 +269,9 @@ export const AttachmentPicker = React.forwardRef( ); const animatedIndex = useSharedValue(currentIndex); + const setSelectedPickerWithDebounce = useStableCallback((value: SelectedPickerType) => + attachmentPickerStore.setSelectedPicker(value, true), + ); // On some occasions, onAnimate does not fire whenever we pan to close the // bottom sheet, likely due to physics giving the pan enough momentum for @@ -280,7 +281,7 @@ export const AttachmentPicker = React.forwardRef( () => animatedIndex.value, (currentIndex, previousIndex) => { if (currentIndex !== previousIndex && currentIndex === -1) { - runOnJS(setSelectedPicker)(undefined, true); + runOnJS(setSelectedPickerWithDebounce)(undefined); } }, ); diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx index 008da8c640..094bb21b63 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx @@ -52,11 +52,11 @@ export const AttachmentPickerError = (props: AttachmentPickerErrorProps) => { } = useTheme(); const { t } = useTranslationContext(); - const { closePicker, setSelectedPicker } = useAttachmentPickerContext(); + const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const openSettings = async () => { try { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); await Linking.openSettings(); } catch (error) { diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx index f2bceb7e61..55121b1b64 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx @@ -7,6 +7,7 @@ import { useMessageInputContext } from '../../../contexts/messageInputContext/Me import { useMessagesContext } from '../../../contexts/messagesContext/MessagesContext'; import { useOwnCapabilitiesContext } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useAttachmentPickerState } from '../../../hooks/useAttachmentPickerState'; const styles = StyleSheet.create({ container: { @@ -20,7 +21,8 @@ const styles = StyleSheet.create({ }); export const AttachmentPickerSelectionBar = () => { - const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); + const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); + const { selectedPicker } = useAttachmentPickerState(); const { attachmentSelectionBarHeight, @@ -49,33 +51,33 @@ export const AttachmentPickerSelectionBar = () => { const setImagePicker = () => { if (selectedPicker === 'images') { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); } else { - setSelectedPicker('images'); + attachmentPickerStore.setSelectedPicker('images'); } }; const openFilePicker = () => { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); pickFile(); }; const openPollCreationModal = () => { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); openPollCreationDialog?.({ sendMessage }); }; const onCameraPickerPress = () => { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); takeAndUploadImage(Platform.OS === 'android' ? 'image' : 'mixed'); }; const onVideoRecorderPickerPress = () => { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); takeAndUploadImage('video'); }; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 6e688fa28f..b151564922 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -57,6 +57,7 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; +import { useAttachmentPickerState } from '../../hooks/useAttachmentPickerState'; import { useKeyboardVisibility } from '../../hooks/useKeyboardVisibility'; import { useStateStore } from '../../hooks/useStateStore'; import { AudioRecorderManagerState } from '../../state-store/audio-recorder-manager'; @@ -143,7 +144,7 @@ const useStyles = () => { type MessageInputPropsWithContext = Pick< AttachmentPickerContextValue, - 'bottomInset' | 'disableAttachmentPicker' | 'selectedPicker' + 'bottomInset' | 'disableAttachmentPicker' > & Pick & Pick & @@ -224,7 +225,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { attachmentPickerBottomSheetHeight, attachmentSelectionBarHeight, bottomInset, - selectedPicker, additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, @@ -260,6 +260,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { } = props; const styles = useStyles(); + const { selectedPicker } = useAttachmentPickerState(); const messageComposer = useMessageComposer(); const { clearEditingState } = useMessageComposerAPIContext(); const onDismissEditMessage = () => { @@ -597,7 +598,6 @@ const areEqual = ( isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, openPollCreationDialog: prevOpenPollCreationDialog, - selectedPicker: prevSelectedPicker, showPollCreationDialog: prevShowPollCreationDialog, t: prevT, threadList: prevThreadList, @@ -618,7 +618,6 @@ const areEqual = ( isOnline: nextIsOnline, hasAttachments: nextHasAttachments, openPollCreationDialog: nextOpenPollCreationDialog, - selectedPicker: nextSelectedPicker, showPollCreationDialog: nextShowPollCreationDialog, t: nextT, threadList: nextThreadList, @@ -699,11 +698,6 @@ const areEqual = ( return false; } - const selectedPickerEqual = prevSelectedPicker === nextSelectedPicker; - if (!selectedPickerEqual) { - return false; - } - const micLockedEqual = prevMicLocked === nextMicLocked; if (!micLockedEqual) { return false; @@ -794,8 +788,7 @@ export const MessageInput = (props: MessageInputProps) => { uploadNewFile, VideoRecorderSelectorIcon, } = useMessageInputContext(); - const { bottomInset, bottomSheetRef, disableAttachmentPicker, selectedPicker } = - useAttachmentPickerContext(); + const { bottomInset, bottomSheetRef, disableAttachmentPicker } = useAttachmentPickerContext(); const messageComposer = useMessageComposer(); const editing = !!messageComposer.editedMessage; const { clearEditingState } = useMessageComposerAPIContext(); @@ -869,7 +862,6 @@ export const MessageInput = (props: MessageInputProps) => { messageInputHeightStore, openPollCreationDialog, Reply, - selectedPicker, SendButton, sendMessage, SendMessageDisallowedIndicator, diff --git a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx index cce437b1e1..1011a423ef 100644 --- a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx +++ b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx @@ -9,6 +9,7 @@ import { MessageInputContextValue, useMessageInputContext, } from '../../../../contexts/messageInputContext/MessageInputContext'; +import { useAttachmentPickerState } from '../../../../hooks/useAttachmentPickerState'; import { NewPlus } from '../../../../icons/NewPlus'; import { Button } from '../../../ui/'; import { NativeAttachmentPicker } from '../NativeAttachmentPicker'; @@ -17,7 +18,7 @@ type AttachButtonPropsWithContext = Pick< MessageInputContextValue, 'handleAttachButtonPress' | 'toggleAttachmentPicker' > & - Pick & { + Pick & { disabled?: boolean; /** Function that opens attachment options bottom sheet */ handleOnPress?: ((event: GestureResponderEvent) => void) & (() => void); @@ -31,9 +32,9 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { disabled = false, handleAttachButtonPress, handleOnPress, - selectedPicker, toggleAttachmentPicker, } = props; + const { selectedPicker } = useAttachmentPickerState(); const onAttachButtonLayout = (event: LayoutChangeEvent) => { const layout = event.nativeEvent.layout; @@ -102,19 +103,14 @@ const areEqual = ( prevProps: AttachButtonPropsWithContext, nextProps: AttachButtonPropsWithContext, ) => { - const { handleOnPress: prevHandleOnPress, selectedPicker: prevSelectedPicker } = prevProps; - const { handleOnPress: nextHandleOnPress, selectedPicker: nextSelectedPicker } = nextProps; + const { handleOnPress: prevHandleOnPress } = prevProps; + const { handleOnPress: nextHandleOnPress } = nextProps; const handleOnPressEqual = prevHandleOnPress === nextHandleOnPress; if (!handleOnPressEqual) { return false; } - const selectedPickerEqual = prevSelectedPicker === nextSelectedPicker; - if (!selectedPickerEqual) { - return false; - } - return true; }; @@ -129,7 +125,7 @@ export type AttachButtonProps = Partial; * UI Component for attach button in MessageInput component. */ export const AttachButton = (props: AttachButtonProps) => { - const { disableAttachmentPicker, selectedPicker } = useAttachmentPickerContext(); + const { disableAttachmentPicker } = useAttachmentPickerContext(); const { handleAttachButtonPress, toggleAttachmentPicker } = useMessageInputContext(); return ( @@ -137,7 +133,6 @@ export const AttachButton = (props: AttachButtonProps) => { {...{ disableAttachmentPicker, handleAttachButtonPress, - selectedPicker, toggleAttachmentPicker, }} {...props} diff --git a/package/src/components/MessageInput/components/InputButtons/index.tsx b/package/src/components/MessageInput/components/InputButtons/index.tsx index 9cf9a4a321..be8ef50724 100644 --- a/package/src/components/MessageInput/components/InputButtons/index.tsx +++ b/package/src/components/MessageInput/components/InputButtons/index.tsx @@ -3,11 +3,7 @@ import { StyleSheet } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; -import { - AttachmentPickerContextValue, - OwnCapabilitiesContextValue, - useAttachmentPickerContext, -} from '../../../../contexts'; +import { OwnCapabilitiesContextValue } from '../../../../contexts'; import { MessageInputContextValue, useMessageInputContext, @@ -26,7 +22,6 @@ export type InputButtonsWithContextProps = Pick< | 'hasImagePicker' | 'toggleAttachmentPicker' > & - Pick & Pick; export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => { @@ -71,14 +66,12 @@ const areEqual = ( hasCameraPicker: prevHasCameraPicker, hasFilePicker: prevHasFilePicker, hasImagePicker: prevHasImagePicker, - selectedPicker: prevSelectedPicker, } = prevProps; const { hasCameraPicker: nextHasCameraPicker, hasFilePicker: nextHasFilePicker, hasImagePicker: nextHasImagePicker, - selectedPicker: nextSelectedPicker, } = nextProps; if (prevHasCameraPicker !== nextHasCameraPicker) { @@ -93,10 +86,6 @@ const areEqual = ( return false; } - if (prevSelectedPicker !== nextSelectedPicker) { - return false; - } - return true; }; @@ -114,7 +103,6 @@ export const InputButtons = (props: InputButtonsProps) => { hasImagePicker, toggleAttachmentPicker, } = useMessageInputContext(); - const { selectedPicker } = useAttachmentPickerContext(); const { uploadFile } = useOwnCapabilitiesContext(); return ( @@ -125,7 +113,6 @@ export const InputButtons = (props: InputButtonsProps) => { hasCommands, hasFilePicker, hasImagePicker, - selectedPicker, toggleAttachmentPicker, uploadFile, }} diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index b245ff7006..6b540e7e54 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -104,7 +104,7 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ type MessageFlashListPropsWithContext = Pick< AttachmentPickerContextValue, - 'closePicker' | 'selectedPicker' | 'setSelectedPicker' + 'closePicker' | 'attachmentPickerStore' > & Pick & Pick< @@ -262,6 +262,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; const { + attachmentPickerStore, additionalFlashListProps, channel, channelUnreadStateStore, @@ -295,10 +296,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onThreadSelect, reloadChannel, ScrollToBottomButton, - selectedPicker, setChannelUnreadState, setFlatListRef, - setSelectedPicker, setTargetedMessage, StickyHeader, targetedMessage, @@ -667,7 +666,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => !viewableItems.length || !readEvents || lastReadMessageVisible || - selectedPicker === 'images' + attachmentPickerStore.state.getLatestValue().selectedPicker === 'images' ) { setIsUnreadNotificationOpen(false); return; @@ -938,19 +937,19 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }); const dismissImagePicker = useStableCallback(() => { - if (selectedPicker) { - setSelectedPicker(undefined); + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); } }); const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = useStableCallback((event) => { - !hasMoved && selectedPicker && setHasMoved(true); + !hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(true); onUserScrollEvent(event); }); const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = useStableCallback((event) => { - hasMoved && selectedPicker && setHasMoved(false); + hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(false); onUserScrollEvent(event); }); @@ -1116,7 +1115,7 @@ export type MessageFlashListProps = Partial; * Please feel free to report any issues or suggestions. */ export const MessageFlashList = (props: MessageFlashListProps) => { - const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); + const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, channelUnreadStateStore, @@ -1165,6 +1164,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { return ( { reloadChannel, ScrollToBottomButton, scrollToFirstUnreadThreshold, - selectedPicker, setChannelUnreadState, - setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, StickyHeader, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index b614133134..755a1134cb 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -135,7 +135,7 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe type MessageListPropsWithContext = Pick< AttachmentPickerContextValue, - 'closePicker' | 'selectedPicker' | 'setSelectedPicker' + 'closePicker' | 'attachmentPickerStore' > & Pick & Pick< @@ -265,6 +265,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ? InlineLoadingMoreRecentThreadIndicator : InlineLoadingMoreRecentIndicator; const { + attachmentPickerStore, additionalFlatListProps, channel, channelUnreadStateStore, @@ -299,10 +300,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { readEvents, reloadChannel, ScrollToBottomButton, - selectedPicker, setChannelUnreadState, setFlatListRef, - setSelectedPicker, setTargetedMessage, StickyHeader, targetedMessage, @@ -481,7 +480,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { !viewableItems.length || !readEvents || lastReadMessageVisible || - selectedPicker === 'images' + attachmentPickerStore.state.getLatestValue().selectedPicker === 'images' ) { setIsUnreadNotificationOpen(false); return; @@ -1036,19 +1035,19 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); const dismissImagePicker = useStableCallback(() => { - if (selectedPicker) { - setSelectedPicker(undefined); + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); } }); const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = useStableCallback((event) => { - !hasMoved && selectedPicker && setHasMoved(true); + !hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(true); onUserScrollEvent(event); }); const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = useStableCallback((event) => { - hasMoved && selectedPicker && setHasMoved(false); + hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(false); onUserScrollEvent(event); }); @@ -1218,7 +1217,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { export type MessageListProps = Partial; export const MessageList = (props: MessageListProps) => { - const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); + const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, channelUnreadStateStore, @@ -1266,6 +1265,7 @@ export const MessageList = (props: MessageListProps) => { return ( { reloadChannel, ScrollToBottomButton, scrollToFirstUnreadThreshold, - selectedPicker, setChannelUnreadState, - setSelectedPicker, setTargetedMessage, shouldShowUnreadUnderlay, StickyHeader, diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index 6d13a89062..7655330f7b 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -1,8 +1,8 @@ -import React, { PropsWithChildren, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { PropsWithChildren, useContext, useMemo, useState } from 'react'; import BottomSheet from '@gorhom/bottom-sheet'; -import { useStableCallback } from '../../hooks'; +import { AttachmentPickerStore } from '../../state-store/attachment-picker-store'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -24,15 +24,8 @@ export type AttachmentPickerContextValue = { bottomSheetRef: React.RefObject; closePicker: () => void; openPicker: () => void; - setBottomInset: React.Dispatch>; - setSelectedPicker: ( - valueOrUpdater: React.SetStateAction<'images' | undefined>, - debounceClose?: boolean, - ) => void; - setTopInset: React.Dispatch>; topInset: number; - selectedPicker?: 'images'; disableAttachmentPicker?: boolean; /** * Custom UI component to render overlay component, that shows up on top of [selected @@ -42,6 +35,7 @@ export type AttachmentPickerContextValue = { * [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) */ ImageOverlaySelectedComponent: React.ComponentType; + attachmentPickerStore: AttachmentPickerStore; numberOfAttachmentPickerImageColumns?: number; }; @@ -56,45 +50,17 @@ export const AttachmentPickerProvider = ({ value?: Pick & Partial>; }>) => { - const bottomInsetValue = value?.bottomInset; - const topInsetValue = value?.topInset; - - const [bottomInset, setBottomInset] = useState(bottomInsetValue ?? 0); - const [selectedPicker, setSelectedPickerT] = useState<'images'>(); - const [topInset, setTopInset] = useState(topInsetValue ?? 0); - - const lastChangedRef = useRef(-1); - - const setSelectedPicker = useStableCallback( - (value: React.SetStateAction<'images' | undefined>, debounceClose?: boolean) => { - const now = Date.now(); - if (debounceClose && !value && now - lastChangedRef.current < 500) { - return; - } - lastChangedRef.current = now; - setSelectedPickerT(value); - }, - ); - - useEffect(() => { - setBottomInset(bottomInsetValue ?? 0); - }, [bottomInsetValue]); - - useEffect(() => { - setTopInset(topInsetValue ?? 0); - }, [topInsetValue]); + const { bottomInset = 0, topInset = 0, ...rest } = value ?? {}; + const [attachmentPickerStore] = useState(() => new AttachmentPickerStore()); const combinedValue = useMemo( () => ({ - selectedPicker, - setBottomInset, - setSelectedPicker, - setTopInset, - ...value, bottomInset, topInset, + attachmentPickerStore, + ...rest, }), - [bottomInset, selectedPicker, setSelectedPicker, topInset, value], + [bottomInset, topInset, attachmentPickerStore, rest], ); return ( diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 43d7c8c2e8..5ea0aa0fbf 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -84,7 +84,6 @@ export type LocalMessageInputContext = { */ pickAndUploadImageFromNativePicker: () => Promise; pickFile: () => Promise; - selectedPicker?: 'images'; sendMessage: () => Promise; /** * Ref callback to set reference on input box @@ -401,8 +400,7 @@ export const MessageInputProvider = ({ }: PropsWithChildren<{ value: InputMessageInputContextValue; }>) => { - const { closePicker, openPicker, selectedPicker, setSelectedPicker } = - useAttachmentPickerContext(); + const { closePicker, openPicker, attachmentPickerStore } = useAttachmentPickerContext(); const { client } = useChatContext(); const channelCapabilities = useOwnCapabilitiesContext(); const [audioRecorderManager] = useState(new AudioRecorderManager()); @@ -546,28 +544,28 @@ export const MessageInputProvider = ({ */ const openAttachmentPicker = useCallback(() => { dismissKeyboard(); - setSelectedPicker('images'); + attachmentPickerStore.setSelectedPicker('images'); openPicker(); - }, [openPicker, setSelectedPicker]); + }, [openPicker, attachmentPickerStore]); /** * Function to close the attachment picker if the MediaLibrary is installed. */ const closeAttachmentPicker = useCallback(() => { - setSelectedPicker(undefined); + attachmentPickerStore.setSelectedPicker(undefined); closePicker(); - }, [closePicker, setSelectedPicker]); + }, [closePicker, attachmentPickerStore]); /** * Function to toggle the attachment picker if the MediaLibrary is installed. */ const toggleAttachmentPicker = useCallback(() => { - if (selectedPicker) { + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { closeAttachmentPicker(); } else { openAttachmentPicker(); } - }, [closeAttachmentPicker, openAttachmentPicker, selectedPicker]); + }, [closeAttachmentPicker, openAttachmentPicker, attachmentPickerStore]); const sendMessage = useStableCallback(async () => { if (inputBoxRef.current) { @@ -683,7 +681,6 @@ export const MessageInputProvider = ({ ...value, closePollCreationDialog, openPollCreationDialog, - selectedPicker, sendMessage, // overriding the originally passed in sendMessage showPollCreationDialog, audioRecorderManager, diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 2d393941ff..c079416a7a 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -53,7 +53,6 @@ export const useCreateMessageInputContext = ({ openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - selectedPicker, SendButton, sendMessage, SendMessageDisallowedIndicator, @@ -128,7 +127,6 @@ export const useCreateMessageInputContext = ({ openPollCreationDialog, pickAndUploadImageFromNativePicker, pickFile, - selectedPicker, SendButton, sendMessage, SendMessageDisallowedIndicator, @@ -150,7 +148,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [threadId, showPollCreationDialog, selectedPicker], + [threadId, showPollCreationDialog], ); return messageInputContext; diff --git a/package/src/hooks/useAttachmentPickerState.ts b/package/src/hooks/useAttachmentPickerState.ts new file mode 100644 index 0000000000..1dee9f01c3 --- /dev/null +++ b/package/src/hooks/useAttachmentPickerState.ts @@ -0,0 +1,13 @@ +import { useStateStore } from './useStateStore'; + +import { useAttachmentPickerContext } from '../contexts'; +import { AttachmentPickerState } from '../state-store/attachment-picker-store'; + +const selector = (nextState: AttachmentPickerState) => ({ + selectedPicker: nextState.selectedPicker, +}); + +export const useAttachmentPickerState = () => { + const { attachmentPickerStore } = useAttachmentPickerContext(); + return useStateStore(attachmentPickerStore.state, selector); +}; diff --git a/package/src/state-store/attachment-picker-store.ts b/package/src/state-store/attachment-picker-store.ts new file mode 100644 index 0000000000..222c6149e4 --- /dev/null +++ b/package/src/state-store/attachment-picker-store.ts @@ -0,0 +1,25 @@ +import { StateStore } from 'stream-chat'; + +export type SelectedPickerType = 'images' | undefined; + +export type AttachmentPickerState = { + selectedPicker: SelectedPickerType; +}; + +const INITIAL_STATE: AttachmentPickerState = { + selectedPicker: undefined, +}; + +export class AttachmentPickerStore { + public state = new StateStore(INITIAL_STATE); + private lastChanged: number = -1; + + setSelectedPicker(value: SelectedPickerType, debounceClose?: boolean) { + const now = Date.now(); + if (debounceClose && !value && now - this.lastChanged < 500) { + return; + } + this.lastChanged = now; + this.state.partialNext({ selectedPicker: value }); + } +} From 6587b04822012f584ca1e8df5ea564252b8fe73f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 6 Feb 2026 13:53:47 +0100 Subject: [PATCH 04/33] fix: failing tests --- .../__snapshots__/MessageStatus.test.js.snap | 23 +++++-- .../__tests__/MessageInput.test.js | 32 +++++---- .../SendMessageDisallowedIndicator.test.js | 20 +++--- .../__snapshots__/AttachButton.test.js.snap | 69 ++++++++++++++----- .../__snapshots__/SendButton.test.js.snap | 46 +++++++++---- .../__snapshots__/Thread.test.js.snap | 23 +++++-- 6 files changed, 150 insertions(+), 63 deletions(-) diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap index f771d43d78..1e1fd6f7ff 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -123,11 +123,16 @@ exports[`MessageStatus should render message status with read by container 1`] = diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 8cea3bef40..4f752a1df0 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -4,11 +4,14 @@ import { Alert } from 'react-native'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; + import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon'; @@ -20,19 +23,22 @@ import { MessageInput } from '../MessageInput'; jest.spyOn(Alert, 'alert'); jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementation( - jest.fn(() => ({ - AttachmentPickerSelectionBar, - CameraSelectorIcon, - closePicker: jest.fn(), - CreatePollIcon, - FileSelectorIcon, - ImageSelectorIcon, - openPicker: jest.fn(), - selectedPicker: 'images', - setBottomInset: jest.fn(), - setSelectedPicker: jest.fn(), - setTopInset: jest.fn(), - })), + jest.fn(() => { + const attachmentPickerStore = new AttachmentPickerStore(); + attachmentPickerStore.setSelectedPicker('images'); + return { + AttachmentPickerSelectionBar, + CameraSelectorIcon, + closePicker: jest.fn(), + CreatePollIcon, + FileSelectorIcon, + ImageSelectorIcon, + openPicker: jest.fn(), + setBottomInset: jest.fn(), + setTopInset: jest.fn(), + attachmentPickerStore, + }; + }), ); const renderComponent = ({ channelProps, client, props }) => { diff --git a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js index c7613cd31e..3d8b742f8d 100644 --- a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js +++ b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js @@ -16,20 +16,24 @@ import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/at import { generateMessage } from '../../../mock-builders/generator/message'; +import { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; import { MessageInput } from '../MessageInput'; jest.spyOn(Alert, 'alert'); jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementation( - jest.fn(() => ({ - closePicker: jest.fn(), - openPicker: jest.fn(), - selectedPicker: 'images', - setBottomInset: jest.fn(), - setSelectedPicker: jest.fn(), - setTopInset: jest.fn(), - })), + jest.fn(() => { + const attachmentPickerStore = new AttachmentPickerStore(); + attachmentPickerStore.setSelectedPicker('images'); + return { + closePicker: jest.fn(), + openPicker: jest.fn(), + setBottomInset: jest.fn(), + setTopInset: jest.fn(), + attachmentPickerStore, + }; + }), ); const renderComponent = ({ channelProps, client, props }) => { diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap index 4733e48b26..2d0e540093 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -162,11 +162,16 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli @@ -515,11 +526,16 @@ exports[`AttachButton should render a enabled AttachButton 1`] = ` @@ -868,11 +890,16 @@ exports[`AttachButton should render an disabled AttachButton 1`] = ` diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap index ce5c623954..9d2f9f27c4 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -159,11 +159,16 @@ exports[`SendButton should render a SendButton 1`] = ` @@ -509,11 +520,16 @@ exports[`SendButton should render a disabled SendButton 1`] = ` diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 68709acee3..46b248fa5d 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -2439,11 +2439,16 @@ exports[`Thread should match thread snapshot 1`] = ` /> From 079abb5aa37a00a116fac7879b492d23d82d16e3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 6 Feb 2026 14:24:22 +0100 Subject: [PATCH 05/33] fix: resolve conflicts --- .../components/MessageInput/MessageInput.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 9c6d3ed926..e7ff7b45e1 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -456,31 +456,31 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { )} - - - - {!disableAttachmentPicker && selectedPicker ? ( - + - ) : null} + {!disableAttachmentPicker && selectedPicker ? ( + + + + ) : null} {showPollCreationDialog ? ( From 2a9b89e1ed06ac62b4790458c42b9b635eb86a64 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 6 Feb 2026 15:24:13 +0100 Subject: [PATCH 06/33] chore: move picker down to message input --- .../AttachmentPicker/AttachmentPicker.tsx | 89 +++++++++---------- package/src/components/Channel/Channel.tsx | 55 +++++------- .../components/MessageInput/MessageInput.tsx | 55 ++---------- .../AttachmentPickerContext.tsx | 32 ++++++- 4 files changed, 107 insertions(+), 124 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 9ec42e0c3e..7a4656f3e1 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,7 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BackHandler, EmitterSubscription, Keyboard, Platform, StyleSheet } from 'react-native'; -import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import Animated, { + LinearTransition, + runOnJS, + SlideInDown, + SlideOutDown, + useAnimatedReaction, + useSharedValue, +} from 'react-native-reanimated'; import BottomSheetOriginal from '@gorhom/bottom-sheet'; import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; @@ -9,12 +16,10 @@ import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; -import type { AttachmentPickerErrorProps } from './components/AttachmentPickerError'; - import { renderAttachmentPickerItem } from './components/AttachmentPickerItem'; import { useAttachmentPickerContext } from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; -import { MessageInputContextValue } from '../../contexts/messageInputContext/MessageInputContext'; +import { useMessageInputContext } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { useAttachmentPickerState } from '../../hooks/useAttachmentPickerState'; @@ -34,41 +39,26 @@ const styles = StyleSheet.create({ }, }); -export type AttachmentPickerProps = Pick< - MessageInputContextValue, - | 'AttachmentPickerBottomSheetHandle' - | 'attachmentPickerBottomSheetHandleHeight' - | 'attachmentSelectionBarHeight' - | 'attachmentPickerBottomSheetHeight' -> & { - /** - * Custom UI component to render error component while opening attachment picker. - * - * **Default** - * [AttachmentPickerError](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx) - */ - AttachmentPickerError: React.ComponentType; - /** - * Custom UI component to render error image for attachment picker - * - * **Default** - * [AttachmentPickerErrorImage](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerErrorImage.tsx) - */ - AttachmentPickerErrorImage: React.ComponentType; - /** - * Custom UI Component to render select more photos for selected gallery access in iOS. - */ - AttachmentPickerIOSSelectMorePhotos: React.ComponentType; - attachmentPickerErrorButtonText?: string; - attachmentPickerErrorText?: string; - numberOfAttachmentImagesToLoadPerCall?: number; -}; - const keyExtractor = (item: File) => item.uri; export const AttachmentPicker = React.forwardRef( - (props: AttachmentPickerProps, ref: React.ForwardedRef) => { + (_, ref: React.ForwardedRef) => { + const { + theme: { + semantics, + attachmentPicker: { bottomSheetContentContainer }, + messageInput: { attachmentSelectionBar }, + colors: { white }, + }, + } = useTheme(); const { + closePicker, + attachmentPickerStore, + topInset, + bottomInset, + disableAttachmentPicker, + numberOfAttachmentPickerImageColumns, + AttachmentPickerSelectionBar, AttachmentPickerBottomSheetHandle, attachmentPickerBottomSheetHandleHeight, attachmentPickerBottomSheetHeight, @@ -78,16 +68,8 @@ export const AttachmentPicker = React.forwardRef( attachmentPickerErrorText, AttachmentPickerIOSSelectMorePhotos, numberOfAttachmentImagesToLoadPerCall, - } = props; - - const { - theme: { - attachmentPicker: { bottomSheetContentContainer }, - colors: { white }, - }, - } = useTheme(); - const { closePicker, attachmentPickerStore, topInset, numberOfAttachmentPickerImageColumns } = - useAttachmentPickerContext(); + } = useAttachmentPickerContext(); + const { attachmentSelectionBarHeight } = useMessageInputContext(); const { selectedPicker } = useAttachmentPickerState(); const { vh: screenVh } = useScreenDimensions(); @@ -288,6 +270,23 @@ export const AttachmentPicker = React.forwardRef( return ( <> + {!disableAttachmentPicker && selectedPicker ? ( + + + + ) : null} & > & Partial< Pick< - AttachmentPickerProps, + AttachmentPickerContextValue, | 'AttachmentPickerError' | 'AttachmentPickerErrorImage' | 'AttachmentPickerIOSSelectMorePhotos' @@ -1737,33 +1736,6 @@ const ChannelWithContext = (props: PropsWithChildren) = } }); - const attachmentPickerProps = useMemo( - () => ({ - AttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerError, - attachmentPickerErrorButtonText, - AttachmentPickerErrorImage, - attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, - attachmentSelectionBarHeight, - numberOfAttachmentImagesToLoadPerCall, - }), - [ - AttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerError, - attachmentPickerErrorButtonText, - AttachmentPickerErrorImage, - attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, - attachmentSelectionBarHeight, - numberOfAttachmentImagesToLoadPerCall, - ], - ); - const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef)); const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef)); @@ -1776,7 +1748,18 @@ const ChannelWithContext = (props: PropsWithChildren) = openPicker: handleOpenPicker, topInset, ImageOverlaySelectedComponent, + AttachmentPickerSelectionBar, numberOfAttachmentPickerImageColumns, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerError, + attachmentPickerErrorButtonText, + AttachmentPickerErrorImage, + attachmentPickerErrorText, + AttachmentPickerIOSSelectMorePhotos, + attachmentSelectionBarHeight, + numberOfAttachmentImagesToLoadPerCall, }), [ bottomInset, @@ -1786,7 +1769,18 @@ const ChannelWithContext = (props: PropsWithChildren) = handleOpenPicker, topInset, ImageOverlaySelectedComponent, + AttachmentPickerSelectionBar, numberOfAttachmentPickerImageColumns, + AttachmentPickerBottomSheetHandle, + attachmentPickerBottomSheetHandleHeight, + attachmentPickerBottomSheetHeight, + AttachmentPickerError, + attachmentPickerErrorButtonText, + AttachmentPickerErrorImage, + attachmentPickerErrorText, + AttachmentPickerIOSSelectMorePhotos, + attachmentSelectionBarHeight, + numberOfAttachmentImagesToLoadPerCall, ], ); @@ -2090,9 +2084,6 @@ const ChannelWithContext = (props: PropsWithChildren) = {children} - {!disableAttachmentPicker && ( - - )} diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index e7ff7b45e1..b5e09307a7 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -8,8 +8,6 @@ import Animated, { FadeOut, interpolate, LinearTransition, - SlideInDown, - SlideOutDown, useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; @@ -27,11 +25,12 @@ import { MessageInputTrailingView } from './MessageInputTrailingView'; import { audioRecorderSelector } from './utils/audioRecorderSelectors'; -import { ChatContextValue, useChatContext, useOwnCapabilitiesContext } from '../../contexts'; import { - AttachmentPickerContextValue, + ChatContextValue, useAttachmentPickerContext, -} from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; + useChatContext, + useOwnCapabilitiesContext, +} from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -62,6 +61,7 @@ import { useStateStore } from '../../hooks/useStateStore'; import { AudioRecorderManagerState } from '../../state-store/audio-recorder-manager'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; +import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import { AutoCompleteInput } from '../AutoCompleteInput/AutoCompleteInput'; import { CreatePoll } from '../Poll/CreatePollContent'; import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; @@ -140,11 +140,7 @@ const useStyles = () => { }, [semantics]); }; -type MessageInputPropsWithContext = Pick< - AttachmentPickerContextValue, - 'bottomInset' | 'disableAttachmentPicker' -> & - Pick & +type MessageInputPropsWithContext = Pick & Pick & Pick< MessageInputContextValue, @@ -155,9 +151,6 @@ type MessageInputPropsWithContext = Pick< | 'asyncMessagesMinimumPressDuration' | 'asyncMessagesSlideToCancelDistance' | 'asyncMessagesMultiSendEnabled' - | 'attachmentPickerBottomSheetHeight' - | 'AttachmentPickerSelectionBar' - | 'attachmentSelectionBarHeight' | 'AttachmentUploadPreviewList' | 'AudioRecorder' | 'AudioRecordingInProgress' @@ -208,10 +201,6 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { - AttachmentPickerSelectionBar, - attachmentPickerBottomSheetHeight, - attachmentSelectionBarHeight, - bottomInset, additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, @@ -223,7 +212,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { closeAttachmentPicker, closePollCreationDialog, CreatePollContent, - disableAttachmentPicker, editing, messageInputFloating, messageInputHeightStore, @@ -244,6 +232,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const styles = useStyles(); const { selectedPicker } = useAttachmentPickerState(); + const { bottomSheetRef } = useAttachmentPickerContext(); const messageComposer = useMessageComposer(); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); @@ -252,7 +241,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { theme: { semantics, messageInput: { - attachmentSelectionBar, container, floatingWrapper, focusedInputBoxContainer, @@ -464,23 +452,8 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { > - {!disableAttachmentPicker && selectedPicker ? ( - - - - ) : null} + + {showPollCreationDialog ? ( @@ -665,9 +638,6 @@ export const MessageInput = (props: MessageInputProps) => { asyncMessagesSlideToCancelDistance, AttachmentPickerBottomSheetHandle, attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerSelectionBar, - attachmentSelectionBarHeight, AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, @@ -700,7 +670,6 @@ export const MessageInput = (props: MessageInputProps) => { uploadNewFile, VideoRecorderSelectorIcon, } = useMessageInputContext(); - const { bottomInset, bottomSheetRef, disableAttachmentPicker } = useAttachmentPickerContext(); const messageComposer = useMessageComposer(); const editing = !!messageComposer.editedMessage; const { clearEditingState } = useMessageComposerAPIContext(); @@ -737,9 +706,6 @@ export const MessageInput = (props: MessageInputProps) => { asyncMessagesSlideToCancelDistance, AttachmentPickerBottomSheetHandle, attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerSelectionBar, - attachmentSelectionBarHeight, AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, @@ -748,8 +714,6 @@ export const MessageInput = (props: MessageInputProps) => { AudioRecordingPreview, AudioRecordingWaveform, AutoCompleteSuggestionList, - bottomInset, - bottomSheetRef, CameraSelectorIcon, channel, clearEditingState, @@ -758,7 +722,6 @@ export const MessageInput = (props: MessageInputProps) => { compressImageQuality, CreatePollContent, CreatePollIcon, - disableAttachmentPicker, editing, FileSelectorIcon, ImageSelectorIcon, diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index 7655330f7b..19c6459af7 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -2,7 +2,9 @@ import React, { PropsWithChildren, useContext, useMemo, useState } from 'react'; import BottomSheet from '@gorhom/bottom-sheet'; +import { AttachmentPickerErrorProps } from '../../components'; import { AttachmentPickerStore } from '../../state-store/attachment-picker-store'; +import { MessageInputContextValue } from '../messageInputContext/MessageInputContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -12,7 +14,31 @@ export type AttachmentPickerIconProps = { selectedPicker?: 'images'; }; -export type AttachmentPickerContextValue = { +export type AttachmentPickerContextValue = Pick< + MessageInputContextValue, + | 'AttachmentPickerBottomSheetHandle' + | 'attachmentPickerBottomSheetHandleHeight' + | 'attachmentSelectionBarHeight' + | 'attachmentPickerBottomSheetHeight' +> & { + /** + * Custom UI component to render error component while opening attachment picker. + * + * **Default** + * [AttachmentPickerError](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx) + */ + AttachmentPickerError: React.ComponentType; + /** + * Custom UI component to render error image for attachment picker + * + * **Default** + * [AttachmentPickerErrorImage](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/AttachmentPickerErrorImage.tsx) + */ + AttachmentPickerErrorImage: React.ComponentType; + /** + * Custom UI Component to render select more photos for selected gallery access in iOS. + */ + AttachmentPickerIOSSelectMorePhotos: React.ComponentType; /** * `bottomInset` determine the height of the `AttachmentPicker` and the underlying shift to the `MessageList` when it is opened. * This can also be set via the `setBottomInset` function provided by the `useAttachmentPickerContext` hook. @@ -35,8 +61,12 @@ export type AttachmentPickerContextValue = { * [ImageOverlaySelectedComponent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/ImageOverlaySelectedComponent.tsx) */ ImageOverlaySelectedComponent: React.ComponentType; + AttachmentPickerSelectionBar: React.ComponentType; attachmentPickerStore: AttachmentPickerStore; numberOfAttachmentPickerImageColumns?: number; + attachmentPickerErrorButtonText?: string; + attachmentPickerErrorText?: string; + numberOfAttachmentImagesToLoadPerCall?: number; }; export const AttachmentPickerContext = React.createContext( From 6f6ba6ed4a47b480e63f5fa06f171487c8e4e7ae Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 7 Feb 2026 00:40:06 +0100 Subject: [PATCH 07/33] fix: bottom sheet issues on android and tests --- .../AttachmentPicker/AttachmentPicker.tsx | 506 ++++---- package/src/components/Channel/Channel.tsx | 8 +- .../__snapshots__/MessageStatus.test.js.snap | 378 +++++- .../components/MessageInput/MessageInput.tsx | 17 +- .../__tests__/MessageInput.test.js | 4 +- .../SendMessageDisallowedIndicator.test.js | 10 + .../__snapshots__/AttachButton.test.js.snap | 1134 ++++++++++++++++- .../__snapshots__/SendButton.test.js.snap | 756 ++++++++++- .../__snapshots__/Thread.test.js.snap | 1044 ++++++++++----- .../MessageInputContext.tsx | 16 +- 10 files changed, 3197 insertions(+), 676 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 7a4656f3e1..3f9338e228 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,17 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { BackHandler, EmitterSubscription, Keyboard, Platform, StyleSheet } from 'react-native'; - -import Animated, { - LinearTransition, - runOnJS, - SlideInDown, - SlideOutDown, - useAnimatedReaction, - useSharedValue, -} from 'react-native-reanimated'; - -import BottomSheetOriginal from '@gorhom/bottom-sheet'; -import type { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + BackHandler, + EmitterSubscription, + Keyboard, + Platform, + StyleSheet, + View, +} from 'react-native'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -25,7 +20,6 @@ import { useStableCallback } from '../../hooks'; import { useAttachmentPickerState } from '../../hooks/useAttachmentPickerState'; import { useScreenDimensions } from '../../hooks/useScreenDimensions'; import { NativeHandlers } from '../../native'; -import { SelectedPickerType } from '../../state-store/attachment-picker-store'; import type { File } from '../../types/types'; import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet'; import { BottomSheetFlatList } from '../BottomSheetCompatibility/BottomSheetFlatList'; @@ -41,290 +35,252 @@ const styles = StyleSheet.create({ const keyExtractor = (item: File) => item.uri; -export const AttachmentPicker = React.forwardRef( - (_, ref: React.ForwardedRef) => { - const { - theme: { - semantics, - attachmentPicker: { bottomSheetContentContainer }, - messageInput: { attachmentSelectionBar }, - colors: { white }, - }, - } = useTheme(); - const { - closePicker, - attachmentPickerStore, - topInset, - bottomInset, - disableAttachmentPicker, - numberOfAttachmentPickerImageColumns, - AttachmentPickerSelectionBar, - AttachmentPickerBottomSheetHandle, - attachmentPickerBottomSheetHandleHeight, - attachmentPickerBottomSheetHeight, - AttachmentPickerError, - attachmentPickerErrorButtonText, - AttachmentPickerErrorImage, - attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, - numberOfAttachmentImagesToLoadPerCall, - } = useAttachmentPickerContext(); - const { attachmentSelectionBarHeight } = useMessageInputContext(); - const { selectedPicker } = useAttachmentPickerState(); - const { vh: screenVh } = useScreenDimensions(); - - const fullScreenHeight = screenVh(100); - - const [currentIndex, setCurrentIndexInternal] = useState(-1); - const currentIndexRef = useRef(currentIndex); - const setCurrentIndex = useStableCallback((_: number, toIndex: number) => { - setCurrentIndexInternal(toIndex); - currentIndexRef.current = toIndex; - }); - const endCursorRef = useRef(undefined); - const [photoError, setPhotoError] = useState(false); - const [iOSLimited, setIosLimited] = useState(false); - const hasNextPageRef = useRef(true); - const loadingPhotosRef = useRef(false); - const [photos, setPhotos] = useState([]); - const attemptedToLoadPhotosOnOpenRef = useRef(false); +export const AttachmentPicker = () => { + const { + theme: { + semantics, + attachmentPicker: { bottomSheetContentContainer }, + messageInput: { attachmentSelectionBar }, + colors: { white }, + }, + } = useTheme(); + const { + closePicker, + attachmentPickerStore, + topInset, + disableAttachmentPicker, + numberOfAttachmentPickerImageColumns, + AttachmentPickerSelectionBar, + attachmentPickerBottomSheetHeight, + AttachmentPickerError, + attachmentPickerErrorButtonText, + AttachmentPickerErrorImage, + attachmentPickerErrorText, + AttachmentPickerIOSSelectMorePhotos, + numberOfAttachmentImagesToLoadPerCall, + bottomSheetRef: ref, + } = useAttachmentPickerContext(); + const { attachmentSelectionBarHeight } = useMessageInputContext(); + const { selectedPicker } = useAttachmentPickerState(); + const { vh: screenVh } = useScreenDimensions(); + + const fullScreenHeight = screenVh(100); + + const [currentIndex, setCurrentIndexInternal] = useState(-1); + const currentIndexRef = useRef(currentIndex); + const setCurrentIndex = useStableCallback((_: number, toIndex: number) => { + setCurrentIndexInternal(toIndex); + currentIndexRef.current = toIndex; + }); + const endCursorRef = useRef(undefined); + const [photoError, setPhotoError] = useState(false); + const [iOSLimited, setIosLimited] = useState(false); + const hasNextPageRef = useRef(true); + const loadingPhotosRef = useRef(false); + const [photos, setPhotos] = useState([]); + const attemptedToLoadPhotosOnOpenRef = useRef(false); + + const getMorePhotos = useStableCallback(async () => { + if ( + hasNextPageRef.current && + !loadingPhotosRef.current && + currentIndex > -1 && + selectedPicker === 'images' + ) { + setPhotoError(false); + loadingPhotosRef.current = true; + const endCursor = endCursorRef.current; + try { + if (!NativeHandlers.getPhotos) { + setPhotos([]); + setIosLimited(false); + return; + } - const getMorePhotos = useStableCallback(async () => { - if ( - hasNextPageRef.current && - !loadingPhotosRef.current && - currentIndex > -1 && - selectedPicker === 'images' - ) { - setPhotoError(false); - loadingPhotosRef.current = true; - const endCursor = endCursorRef.current; - try { - if (!NativeHandlers.getPhotos) { - setPhotos([]); - setIosLimited(false); - return; + const results = await NativeHandlers.getPhotos({ + after: endCursor, + first: numberOfAttachmentImagesToLoadPerCall ?? 25, + }); + + endCursorRef.current = results.endCursor; + // skip updating if the sheet closed in the meantime, to avoid + // confusing the bottom sheet internals + setPhotos((prevPhotos) => { + if (endCursor) { + return [...prevPhotos, ...results.assets]; } - const results = await NativeHandlers.getPhotos({ - after: endCursor, - first: numberOfAttachmentImagesToLoadPerCall ?? 25, - }); - - endCursorRef.current = results.endCursor; - // skip updating if the sheet closed in the meantime, to avoid - // confusing the bottom sheet internals - setPhotos((prevPhotos) => { - if (endCursor) { - return [...prevPhotos, ...results.assets]; - } - - for (let i = 0; i < results.assets.length; i++) { - if (results.assets[i].uri !== prevPhotos[i]?.uri) { - return results.assets; - } + for (let i = 0; i < results.assets.length; i++) { + if (results.assets[i].uri !== prevPhotos[i]?.uri) { + return results.assets; } + } - return prevPhotos.slice(0, results.assets.length); - }); - setIosLimited(results.iOSLimited); - hasNextPageRef.current = !!results.hasNextPage; - } catch (error) { - setPhotoError(true); - } - loadingPhotosRef.current = false; + return prevPhotos.slice(0, results.assets.length); + }); + setIosLimited(results.iOSLimited); + hasNextPageRef.current = !!results.hasNextPage; + } catch (error) { + setPhotoError(true); } + loadingPhotosRef.current = false; + } + }); + + useEffect(() => { + if (selectedPicker !== 'images') { + return; + } + + if (!NativeHandlers.oniOS14GalleryLibrarySelectionChange) { + return; + } + // ios 14 library selection change event is fired when user reselects the images that are permitted to be + // readable by the app + const { unsubscribe } = NativeHandlers.oniOS14GalleryLibrarySelectionChange(() => { + // we reset the cursor and has next page to true to facilitate fetching of the first page of photos again + hasNextPageRef.current = true; + endCursorRef.current = undefined; + // fetch the first page of photos again + getMorePhotos(); }); + return unsubscribe; + }, [getMorePhotos, selectedPicker]); - useEffect(() => { - if (selectedPicker !== 'images') { - return; - } - - if (!NativeHandlers.oniOS14GalleryLibrarySelectionChange) { - return; + useEffect(() => { + const backAction = () => { + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { + attachmentPickerStore.setSelectedPicker(undefined); + closePicker(); + return true; } - // ios 14 library selection change event is fired when user reselects the images that are permitted to be - // readable by the app - const { unsubscribe } = NativeHandlers.oniOS14GalleryLibrarySelectionChange(() => { - // we reset the cursor and has next page to true to facilitate fetching of the first page of photos again - hasNextPageRef.current = true; - endCursorRef.current = undefined; - // fetch the first page of photos again - getMorePhotos(); - }); - return unsubscribe; - }, [getMorePhotos, selectedPicker]); - useEffect(() => { - const backAction = () => { - if (attachmentPickerStore.state.getLatestValue().selectedPicker) { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); - return true; - } + return false; + }; - return false; - }; + const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); - const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); + return () => backHandler.remove(); + }, [attachmentPickerStore, closePicker]); - return () => backHandler.remove(); - }, [attachmentPickerStore, closePicker]); - - useEffect(() => { - const onKeyboardOpenHandler = () => { - if (attachmentPickerStore.state.getLatestValue().selectedPicker) { - attachmentPickerStore.setSelectedPicker(undefined); - } - closePicker(); - }; - let keyboardSubscription: EmitterSubscription | null = null; - if (KeyboardControllerPackage?.KeyboardEvents) { - keyboardSubscription = KeyboardControllerPackage.KeyboardEvents.addListener( - 'keyboardWillShow', - onKeyboardOpenHandler, - ); - } else { - const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler); - } - return () => { - keyboardSubscription?.remove(); - }; - }, [attachmentPickerStore, closePicker]); - - useEffect(() => { - if (currentIndex < 0) { + useEffect(() => { + const onKeyboardOpenHandler = () => { + if (attachmentPickerStore.state.getLatestValue().selectedPicker) { attachmentPickerStore.setSelectedPicker(undefined); - if (!loadingPhotosRef.current) { - endCursorRef.current = undefined; - hasNextPageRef.current = true; - attemptedToLoadPhotosOnOpenRef.current = false; - setPhotoError(false); - } } - }, [currentIndex, attachmentPickerStore]); - - useEffect(() => { - if ( - !attemptedToLoadPhotosOnOpenRef.current && - selectedPicker === 'images' && - endCursorRef.current === undefined && - currentIndex > -1 && - !loadingPhotosRef.current - ) { - getMorePhotos(); - // we do this only once on open for avoiding to request permissions in rationale dialog again and again on - // Android - attemptedToLoadPhotosOnOpenRef.current = true; + closePicker(); + }; + let keyboardSubscription: EmitterSubscription | null = null; + if (KeyboardControllerPackage?.KeyboardEvents) { + keyboardSubscription = KeyboardControllerPackage.KeyboardEvents.addListener( + 'keyboardWillShow', + onKeyboardOpenHandler, + ); + } else { + const keyboardShowEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + keyboardSubscription = Keyboard.addListener(keyboardShowEvent, onKeyboardOpenHandler); + } + return () => { + keyboardSubscription?.remove(); + }; + }, [attachmentPickerStore, closePicker]); + + useEffect(() => { + if (currentIndex < 0) { + attachmentPickerStore.setSelectedPicker(undefined); + if (!loadingPhotosRef.current) { + endCursorRef.current = undefined; + hasNextPageRef.current = true; + attemptedToLoadPhotosOnOpenRef.current = false; + setPhotoError(false); } - }, [currentIndex, selectedPicker, getMorePhotos]); - - const handleHeight = attachmentPickerBottomSheetHandleHeight; - - const initialSnapPoint = attachmentPickerBottomSheetHeight; - - const finalSnapPoint = fullScreenHeight - topInset; - - /** - * Snap points changing cause a rerender of the position, - * this is an issue if you are calling close on the bottom sheet. - */ - const snapPoints = useMemo( - () => [initialSnapPoint, finalSnapPoint], - [initialSnapPoint, finalSnapPoint], - ); - - const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; - - const MemoizedAttachmentPickerBottomSheetHandle = useCallback( - (props: BottomSheetHandleProps) => - /** - * using `null` here instead of `style={{ opacity: photoError ? 0 : 1 }}` - * as opacity is not an allowed style - */ - !photoError && AttachmentPickerBottomSheetHandle ? ( - - ) : null, - [AttachmentPickerBottomSheetHandle, photoError], - ); - - const animatedIndex = useSharedValue(currentIndex); - const setSelectedPickerWithDebounce = useStableCallback((value: SelectedPickerType) => - attachmentPickerStore.setSelectedPicker(value, true), - ); - - // On some occasions, onAnimate does not fire whenever we pan to close the - // bottom sheet, likely due to physics giving the pan enough momentum for - // automatic animation to not be needed. To cover those cases, we react to - // the animatedIndex shared value to make sure we do proper cleanup. - useAnimatedReaction( - () => animatedIndex.value, - (currentIndex, previousIndex) => { - if (currentIndex !== previousIndex && currentIndex === -1) { - runOnJS(setSelectedPickerWithDebounce)(undefined); - } - }, - ); - - return ( - <> - {!disableAttachmentPicker && selectedPicker ? ( - { + if ( + !attemptedToLoadPhotosOnOpenRef.current && + selectedPicker === 'images' && + endCursorRef.current === undefined && + currentIndex > -1 && + !loadingPhotosRef.current + ) { + getMorePhotos(); + // we do this only once on open for avoiding to request permissions in rationale dialog again and again on + // Android + attemptedToLoadPhotosOnOpenRef.current = true; + } + }, [currentIndex, selectedPicker, getMorePhotos]); + + const initialSnapPoint = attachmentPickerBottomSheetHeight; + + const finalSnapPoint = fullScreenHeight - topInset; + + /** + * Snap points changing cause a rerender of the position, + * this is an issue if you are calling close on the bottom sheet. + */ + const snapPoints = useMemo( + () => [initialSnapPoint, finalSnapPoint], + [initialSnapPoint, finalSnapPoint], + ); + + const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; + + return ( + <> + + {!disableAttachmentPicker ? ( + - + ) : null} - - {iOSLimited && } - - - {selectedPicker === 'images' && photoError && ( - - )} - - ); - }, -); + {iOSLimited && } + + + {selectedPicker === 'images' && photoError && ( + + )} + + ); +}; + +const NoOp = () => null; AttachmentPicker.displayName = 'AttachmentPicker'; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 0c92157738..aa6403c73b 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -88,7 +88,7 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { TypingProvider } from '../../contexts/typingContext/TypingContext'; -import { useStableCallback, useViewport } from '../../hooks'; +import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; @@ -135,6 +135,7 @@ import { ImageLoadingFailedIndicator as ImageLoadingFailedIndicatorDefault } fro import { ImageLoadingIndicator as ImageLoadingIndicatorDefault } from '../Attachment/ImageLoadingIndicator'; import { ImageReloadIndicator as ImageReloadIndicatorDefault } from '../Attachment/ImageReloadIndicator'; import { VideoThumbnail as VideoThumbnailDefault } from '../Attachment/VideoThumbnail'; +import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import { AttachmentPickerBottomSheetHandle as DefaultAttachmentPickerBottomSheetHandle } from '../AttachmentPicker/components/AttachmentPickerBottomSheetHandle'; import { AttachmentPickerError as DefaultAttachmentPickerError } from '../AttachmentPicker/components/AttachmentPickerError'; import { AttachmentPickerErrorImage as DefaultAttachmentPickerErrorImage } from '../AttachmentPicker/components/AttachmentPickerErrorImage'; @@ -554,8 +555,6 @@ export type ChannelPropsWithContext = Pick & >; const ChannelWithContext = (props: PropsWithChildren) => { - const { vh } = useViewport(); - const { additionalKeyboardAvoidingViewProps, additionalPressableProps, @@ -571,7 +570,7 @@ const ChannelWithContext = (props: PropsWithChildren) = AttachmentActions = AttachmentActionsDefault, AttachmentPickerBottomSheetHandle = DefaultAttachmentPickerBottomSheetHandle, attachmentPickerBottomSheetHandleHeight = 20, - attachmentPickerBottomSheetHeight = vh(45), + attachmentPickerBottomSheetHeight = 333, AttachmentPickerSelectionBar = DefaultAttachmentPickerSelectionBar, attachmentSelectionBarHeight = 52, AudioAttachment = AudioAttachmentDefault, @@ -2084,6 +2083,7 @@ const ChannelWithContext = (props: PropsWithChildren) = {children} + {!disableAttachmentPicker ? : null} diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap index 1e1fd6f7ff..96b63bcfc3 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -123,23 +123,385 @@ exports[`MessageStatus should render message status with read by container 1`] = + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { const styles = useStyles(); const { selectedPicker } = useAttachmentPickerState(); - const { bottomSheetRef } = useAttachmentPickerContext(); + const { attachmentPickerBottomSheetHeight, bottomInset } = useAttachmentPickerContext(); const messageComposer = useMessageComposer(); const { height } = useStateStore(messageInputHeightStore.store, messageInputHeightStoreSelector); @@ -349,7 +348,15 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { return ( - <> + {/* TODO V9: Think of a better way to do this without so much re-layouting. */} + { - - {showPollCreationDialog ? ( { ) : null} - + ); }; diff --git a/package/src/components/MessageInput/__tests__/MessageInput.test.js b/package/src/components/MessageInput/__tests__/MessageInput.test.js index 4f752a1df0..6db7eb0897 100644 --- a/package/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/package/src/components/MessageInput/__tests__/MessageInput.test.js @@ -4,8 +4,6 @@ import { Alert } from 'react-native'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import { StateStore } from 'stream-chat'; - import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -29,10 +27,10 @@ jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementati return { AttachmentPickerSelectionBar, CameraSelectorIcon, - closePicker: jest.fn(), CreatePollIcon, FileSelectorIcon, ImageSelectorIcon, + closePicker: jest.fn(), openPicker: jest.fn(), setBottomInset: jest.fn(), setTopInset: jest.fn(), diff --git a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js index 3d8b742f8d..44f534b620 100644 --- a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js +++ b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.js @@ -17,8 +17,13 @@ import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/at import { generateMessage } from '../../../mock-builders/generator/message'; import { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; +import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; +import { CameraSelectorIcon } from '../../AttachmentPicker/components/CameraSelectorIcon'; +import { FileSelectorIcon } from '../../AttachmentPicker/components/FileSelectorIcon'; +import { ImageSelectorIcon } from '../../AttachmentPicker/components/ImageSelectorIcon'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; +import { CreatePollIcon } from '../../Poll'; import { MessageInput } from '../MessageInput'; jest.spyOn(Alert, 'alert'); @@ -27,6 +32,11 @@ jest.spyOn(AttachmentPickerUtils, 'useAttachmentPickerContext').mockImplementati const attachmentPickerStore = new AttachmentPickerStore(); attachmentPickerStore.setSelectedPicker('images'); return { + AttachmentPickerSelectionBar, + CameraSelectorIcon, + CreatePollIcon, + FileSelectorIcon, + ImageSelectorIcon, closePicker: jest.fn(), openPicker: jest.fn(), setBottomInset: jest.fn(), diff --git a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap index 5d9005acf6..992f084031 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -162,23 +162,385 @@ exports[`AttachButton should call handleAttachButtonPress when the button is cli + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + > + + + + - - - - + - - - - - + > + + + + @@ -2323,19 +2331,160 @@ exports[`Thread should match thread snapshot 1`] = ` + + + + + + Also send to channel + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + - Also send to channel - + + + + - - - { dismissKeyboard(); - attachmentPickerStore.setSelectedPicker('images'); - openPicker(); + const run = () => { + attachmentPickerStore.setSelectedPicker('images'); + openPicker(); + }; + + if (Platform.OS === 'android') { + setTimeout(() => { + run(); + }, 100); + } else { + run(); + } }, [openPicker, attachmentPickerStore]); /** From d80229946a92a51d7898e4ac8bf8354a19dbc10a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 7 Feb 2026 00:56:48 +0100 Subject: [PATCH 08/33] fix: android scrollable flatlist --- .../components/AttachmentPicker/AttachmentPicker.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 3f9338e228..956006fcbc 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -18,7 +18,6 @@ import { useMessageInputContext } from '../../contexts/messageInputContext/Messa import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { useAttachmentPickerState } from '../../hooks/useAttachmentPickerState'; -import { useScreenDimensions } from '../../hooks/useScreenDimensions'; import { NativeHandlers } from '../../native'; import type { File } from '../../types/types'; import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet'; @@ -47,7 +46,6 @@ export const AttachmentPicker = () => { const { closePicker, attachmentPickerStore, - topInset, disableAttachmentPicker, numberOfAttachmentPickerImageColumns, AttachmentPickerSelectionBar, @@ -62,9 +60,6 @@ export const AttachmentPicker = () => { } = useAttachmentPickerContext(); const { attachmentSelectionBarHeight } = useMessageInputContext(); const { selectedPicker } = useAttachmentPickerState(); - const { vh: screenVh } = useScreenDimensions(); - - const fullScreenHeight = screenVh(100); const [currentIndex, setCurrentIndexInternal] = useState(-1); const currentIndexRef = useRef(currentIndex); @@ -214,16 +209,11 @@ export const AttachmentPicker = () => { const initialSnapPoint = attachmentPickerBottomSheetHeight; - const finalSnapPoint = fullScreenHeight - topInset; - /** * Snap points changing cause a rerender of the position, * this is an issue if you are calling close on the bottom sheet. */ - const snapPoints = useMemo( - () => [initialSnapPoint, finalSnapPoint], - [initialSnapPoint, finalSnapPoint], - ); + const snapPoints = useMemo(() => [initialSnapPoint], [initialSnapPoint]); const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; From 932427bf980f4dbfa5294ae26194397fa1b52f64 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 7 Feb 2026 02:59:37 +0100 Subject: [PATCH 09/33] feat: implement ios limited view --- .../AttachmentPicker/AttachmentPicker.tsx | 85 ++++++++++-------- .../AttachmentPickerIOSSelectMorePhotos.tsx | 37 -------- .../components/AttachmentPickerItem.tsx | 88 +++++++++++++++---- .../AttachmentPickerSelectionBar.tsx | 23 ++--- .../components/CameraSelectorIcon.tsx | 7 +- .../components/FileSelectorIcon.tsx | 7 +- .../components/ImageSelectorIcon.tsx | 7 +- .../components/VideoRecorderSelectorIcon.tsx | 13 ++- .../Poll/components/CreatePollIcon.tsx | 14 ++- .../AttachmentPickerContext.tsx | 7 +- .../MessageInputContext.tsx | 2 +- .../state-store/attachment-picker-store.ts | 8 +- 12 files changed, 168 insertions(+), 130 deletions(-) delete mode 100644 package/src/components/AttachmentPicker/components/AttachmentPickerIOSSelectMorePhotos.tsx diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 956006fcbc..2ffef10989 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -32,7 +32,16 @@ const styles = StyleSheet.create({ }, }); -const keyExtractor = (item: File) => item.uri; +export const IOS_LIMITED_DEEPLINK = '@getstream/ios-limited-button' as const; + +export type IosLimitedItemType = { uri: typeof IOS_LIMITED_DEEPLINK }; + +export type PhotoContentItemType = File | IosLimitedItemType; + +export const isIosLimited = (item: PhotoContentItemType): item is IosLimitedItemType => + 'uri' in item && item.uri === '@getstream/ios-limited-button'; + +const keyExtractor = (item: PhotoContentItemType) => item.uri; export const AttachmentPicker = () => { const { @@ -46,7 +55,6 @@ export const AttachmentPicker = () => { const { closePicker, attachmentPickerStore, - disableAttachmentPicker, numberOfAttachmentPickerImageColumns, AttachmentPickerSelectionBar, attachmentPickerBottomSheetHeight, @@ -54,7 +62,6 @@ export const AttachmentPicker = () => { attachmentPickerErrorButtonText, AttachmentPickerErrorImage, attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, numberOfAttachmentImagesToLoadPerCall, bottomSheetRef: ref, } = useAttachmentPickerContext(); @@ -69,10 +76,9 @@ export const AttachmentPicker = () => { }); const endCursorRef = useRef(undefined); const [photoError, setPhotoError] = useState(false); - const [iOSLimited, setIosLimited] = useState(false); const hasNextPageRef = useRef(true); const loadingPhotosRef = useRef(false); - const [photos, setPhotos] = useState([]); + const [photos, setPhotos] = useState>([]); const attemptedToLoadPhotosOnOpenRef = useRef(false); const getMorePhotos = useStableCallback(async () => { @@ -88,7 +94,6 @@ export const AttachmentPicker = () => { try { if (!NativeHandlers.getPhotos) { setPhotos([]); - setIosLimited(false); return; } @@ -105,15 +110,20 @@ export const AttachmentPicker = () => { return [...prevPhotos, ...results.assets]; } + let assets: PhotoContentItemType[] = results.assets; + + if (results.iOSLimited) { + assets = [{ uri: IOS_LIMITED_DEEPLINK }, ...assets]; + } + for (let i = 0; i < results.assets.length; i++) { - if (results.assets[i].uri !== prevPhotos[i]?.uri) { - return results.assets; + if (assets[i].uri !== prevPhotos[i]?.uri) { + return assets; } } - return prevPhotos.slice(0, results.assets.length); + return prevPhotos.slice(0, assets.length); }); - setIosLimited(results.iOSLimited); hasNextPageRef.current = !!results.hasNextPage; } catch (error) { setPhotoError(true); @@ -230,34 +240,35 @@ export const AttachmentPicker = () => { ref={ref} snapPoints={snapPoints} > - {!disableAttachmentPicker ? ( - - - - ) : null} - {iOSLimited && } - + > + + + {selectedPicker === 'images' ? ( + <> + + + ) : null} {selectedPicker === 'images' && photoError && ( { - const { t } = useTranslationContext(); - const { - theme: { - colors: { accent_blue, white }, - }, - } = useTheme(); - - if (!NativeHandlers.iOS14RefreshGallerySelection) { - return null; - } - - return ( - - {t('Select More Photos')} - - ); -}; - -const styles = StyleSheet.create({ - container: {}, - text: { - fontSize: 16, - paddingVertical: 10, - textAlign: 'center', - }, -}); diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx index 4041eef07e..dce8eb0fe9 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerItem.tsx @@ -12,9 +12,13 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { useViewport } from '../../../hooks/useViewport'; import { Recorder } from '../../../icons'; +import { NewPlus } from '../../../icons/NewPlus'; +import { NativeHandlers } from '../../../native'; +import { primitives } from '../../../theme'; import type { File } from '../../../types/types'; import { getDurationLabelFromDuration } from '../../../utils/utils'; import { BottomSheetTouchableOpacity } from '../../BottomSheetCompatibility/BottomSheetTouchableOpacity'; +import { isIosLimited, PhotoContentItemType } from '../AttachmentPicker'; type AttachmentPickerItemType = { asset: File; @@ -42,6 +46,7 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => { colors: { overlay, white }, }, } = useTheme(); + const styles = useStyles(); const { duration: videoDuration, thumb_url, uri } = asset; @@ -107,6 +112,7 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { colors: { overlay }, }, } = useTheme(); + const styles = useStyles(); const { vw } = useViewport(); const { uploadNewFile } = useMessageInputContext(); const messageComposer = useMessageComposer(); @@ -162,7 +168,32 @@ const AttachmentImage = (props: AttachmentPickerItemType) => { ); }; -export const renderAttachmentPickerItem = ({ item }: { item: File }) => { +const AttachmentIosLimited = () => { + const { numberOfAttachmentPickerImageColumns } = useAttachmentPickerContext(); + const { vw } = useViewport(); + const size = vw(100) / (numberOfAttachmentPickerImageColumns || 3) - 2; + const styles = useStyles(); + return ( + + + Add more + + ); +}; + +export const renderAttachmentPickerItem = ({ item }: { item: PhotoContentItemType }) => { + if (isIosLimited(item)) { + return ; + } /** * Expo Media Library - Result of asset type * Native Android - Gives mime type(Eg: image/jpeg, video/mp4, etc.) @@ -178,21 +209,40 @@ export const renderAttachmentPickerItem = ({ item }: { item: File }) => { return ; }; -const styles = StyleSheet.create({ - durationText: { - fontWeight: 'bold', - }, - overlay: { - alignItems: 'flex-end', - flex: 1, - }, - videoView: { - bottom: 5, - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 5, - position: 'absolute', - width: '100%', - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return StyleSheet.create({ + durationText: { + fontWeight: 'bold', + }, + overlay: { + alignItems: 'flex-end', + flex: 1, + }, + videoView: { + bottom: 5, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 5, + position: 'absolute', + width: '100%', + }, + iosLimitedContainer: { + margin: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: semantics.backgroundCoreSurfaceSubtle, + }, + iosLimitedIcon: { + color: semantics.textTertiary, + }, + iosLimitedText: { + fontWeight: primitives.typographyFontWeightSemiBold, + fontSize: primitives.typographyFontSizeSm, + color: semantics.textTertiary, + }, + }); +}; diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx index 55121b1b64..56744ed11a 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerSelectionBar.tsx @@ -21,7 +21,7 @@ const styles = StyleSheet.create({ }); export const AttachmentPickerSelectionBar = () => { - const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); + const { attachmentPickerStore } = useAttachmentPickerContext(); const { selectedPicker } = useAttachmentPickerState(); const { @@ -50,35 +50,26 @@ export const AttachmentPickerSelectionBar = () => { } = useTheme(); const setImagePicker = () => { - if (selectedPicker === 'images') { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); - } else { - attachmentPickerStore.setSelectedPicker('images'); - } + attachmentPickerStore.setSelectedPicker('images'); }; const openFilePicker = () => { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); + attachmentPickerStore.setSelectedPicker('files'); pickFile(); }; const openPollCreationModal = () => { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); + attachmentPickerStore.setSelectedPicker('polls'); openPollCreationDialog?.({ sendMessage }); }; const onCameraPickerPress = () => { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); + attachmentPickerStore.setSelectedPicker('camera-photo'); takeAndUploadImage(Platform.OS === 'android' ? 'image' : 'mixed'); }; const onVideoRecorderPickerPress = () => { - attachmentPickerStore.setSelectedPicker(undefined); - closePicker(); + attachmentPickerStore.setSelectedPicker('camera-video'); takeAndUploadImage('video'); }; @@ -135,7 +126,7 @@ export const AttachmentPickerSelectionBar = () => { testID='create-poll-touchable' > - + ) : null} diff --git a/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx b/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx index 6361cbac5e..8a932b38bb 100644 --- a/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx +++ b/package/src/components/AttachmentPicker/components/CameraSelectorIcon.tsx @@ -1,14 +1,15 @@ import React from 'react'; +import { AttachmentPickerIconProps } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { Camera } from '../../../icons'; -export const CameraSelectorIcon = () => { +export const CameraSelectorIcon = ({ selectedPicker }: AttachmentPickerIconProps) => { const { theme: { - colors: { grey }, + colors: { accent_blue, grey }, }, } = useTheme(); - return ; + return ; }; diff --git a/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx b/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx index 1627088c7a..f16ea3d620 100644 --- a/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx +++ b/package/src/components/AttachmentPicker/components/FileSelectorIcon.tsx @@ -1,14 +1,15 @@ import React from 'react'; +import { AttachmentPickerIconProps } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { Folder } from '../../../icons'; -export const FileSelectorIcon = () => { +export const FileSelectorIcon = ({ selectedPicker }: AttachmentPickerIconProps) => { const { theme: { - colors: { grey }, + colors: { accent_blue, grey }, }, } = useTheme(); - return ; + return ; }; diff --git a/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx b/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx index bc9cce8603..2be37264e5 100644 --- a/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx +++ b/package/src/components/AttachmentPicker/components/ImageSelectorIcon.tsx @@ -1,13 +1,10 @@ import React from 'react'; +import { AttachmentPickerIconProps } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { Picture } from '../../../icons'; -type Props = { - selectedPicker?: 'images'; -}; - -export const ImageSelectorIcon = ({ selectedPicker }: Props) => { +export const ImageSelectorIcon = ({ selectedPicker }: AttachmentPickerIconProps) => { const { theme: { colors: { accent_blue, grey }, diff --git a/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx b/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx index d1e675f854..59489d6ec6 100644 --- a/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx +++ b/package/src/components/AttachmentPicker/components/VideoRecorderSelectorIcon.tsx @@ -1,14 +1,21 @@ import React from 'react'; +import { AttachmentPickerIconProps } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { Recorder } from '../../../icons'; -export const VideoRecorderSelectorIcon = () => { +export const VideoRecorderSelectorIcon = ({ selectedPicker }: AttachmentPickerIconProps) => { const { theme: { - colors: { grey }, + colors: { accent_blue, grey }, }, } = useTheme(); - return ; + return ( + + ); }; diff --git a/package/src/components/Poll/components/CreatePollIcon.tsx b/package/src/components/Poll/components/CreatePollIcon.tsx index 40b1a23f98..4948efa3f2 100644 --- a/package/src/components/Poll/components/CreatePollIcon.tsx +++ b/package/src/components/Poll/components/CreatePollIcon.tsx @@ -1,14 +1,22 @@ import React from 'react'; +import { AttachmentPickerIconProps } from '../../../contexts'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { PollThumbnail } from '../../../icons'; -export const CreatePollIcon = () => { +export const CreatePollIcon = ({ selectedPicker }: AttachmentPickerIconProps) => { const { theme: { - colors: { grey }, + colors: { accent_blue, grey }, }, } = useTheme(); - return ; + return ( + + ); }; diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index 19c6459af7..f32b79b499 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -3,7 +3,10 @@ import React, { PropsWithChildren, useContext, useMemo, useState } from 'react'; import BottomSheet from '@gorhom/bottom-sheet'; import { AttachmentPickerErrorProps } from '../../components'; -import { AttachmentPickerStore } from '../../state-store/attachment-picker-store'; +import { + AttachmentPickerStore, + SelectedPickerType, +} from '../../state-store/attachment-picker-store'; import { MessageInputContextValue } from '../messageInputContext/MessageInputContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -11,7 +14,7 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type AttachmentPickerIconProps = { numberOfImageUploads?: number; - selectedPicker?: 'images'; + selectedPicker: SelectedPickerType; }; export type AttachmentPickerContextValue = Pick< diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index db8081543f..38f82a4c55 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -221,7 +221,7 @@ export type InputMessageInputContextValue = { * **Default: ** * [CreatePollIcon](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/AttachmentPicker/components/CreatePollIcon.tsx) */ - CreatePollIcon: React.ComponentType; + CreatePollIcon: React.ComponentType; /** * Custom UI component for [file selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) * diff --git a/package/src/state-store/attachment-picker-store.ts b/package/src/state-store/attachment-picker-store.ts index 222c6149e4..bef133a4cb 100644 --- a/package/src/state-store/attachment-picker-store.ts +++ b/package/src/state-store/attachment-picker-store.ts @@ -1,6 +1,12 @@ import { StateStore } from 'stream-chat'; -export type SelectedPickerType = 'images' | undefined; +export type SelectedPickerType = + | 'images' + | 'files' + | 'camera-photo' + | 'camera-video' + | 'polls' + | undefined; export type AttachmentPickerState = { selectedPicker: SelectedPickerType; From 063ce6fe00018b44699dd5eefad36ff7f38918a7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 7 Feb 2026 03:15:07 +0100 Subject: [PATCH 10/33] fix: unmounting issues --- .../AttachmentPicker/AttachmentPicker.tsx | 53 ++++++++++++------- package/src/components/Channel/Channel.tsx | 4 -- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 2ffef10989..a7cbe1ca57 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -6,6 +6,7 @@ import { Platform, StyleSheet, View, + Text, } from 'react-native'; import dayjs from 'dayjs'; @@ -227,6 +228,12 @@ export const AttachmentPicker = () => { const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; + // TODO V9: Think of a better way to do this. This is just a temporary fix. + const lastSelectedPickerRef = useRef(selectedPicker); + if (selectedPicker) { + lastSelectedPickerRef.current = selectedPicker; + } + return ( <> { > - {selectedPicker === 'images' ? ( - <> - - - ) : null} + {lastSelectedPickerRef.current === 'images' ? ( + + ) : ( + // TODO V9: Remove these inline styles + + {lastSelectedPickerRef.current} + + )} {selectedPicker === 'images' && photoError && ( ) = AutoCompleteSuggestionList = AutoCompleteSuggestionListDefault, AttachmentPickerError = DefaultAttachmentPickerError, AttachmentPickerErrorImage = DefaultAttachmentPickerErrorImage, - AttachmentPickerIOSSelectMorePhotos = DefaultAttachmentPickerIOSSelectMorePhotos, AttachmentUploadPreviewList = AttachmentUploadPreviewDefault, ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, attachmentPickerErrorButtonText, @@ -1756,7 +1754,6 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorButtonText, AttachmentPickerErrorImage, attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, attachmentSelectionBarHeight, numberOfAttachmentImagesToLoadPerCall, }), @@ -1777,7 +1774,6 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerErrorButtonText, AttachmentPickerErrorImage, attachmentPickerErrorText, - AttachmentPickerIOSSelectMorePhotos, attachmentSelectionBarHeight, numberOfAttachmentImagesToLoadPerCall, ], From 40733a3a5f133e11040196d80d30cfd5e79926aa Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 9 Feb 2026 09:20:28 +0100 Subject: [PATCH 11/33] fix: move selection bar to handle component --- .../AttachmentPicker/AttachmentPicker.tsx | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index a7cbe1ca57..126ec4de70 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BackHandler, EmitterSubscription, @@ -9,6 +9,7 @@ import { Text, } from 'react-native'; +import { useBottomSheetSpringConfigs } from '@gorhom/bottom-sheet'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; @@ -44,6 +45,15 @@ export const isIosLimited = (item: PhotoContentItemType): item is IosLimitedItem const keyExtractor = (item: PhotoContentItemType) => item.uri; +const SPRING_CONFIG = { + damping: 80, + overshootClamping: true, + restDisplacementThreshold: 0.1, + restSpeedThreshold: 0.1, + stiffness: 500, + duration: 200, +}; + export const AttachmentPicker = () => { const { theme: { @@ -228,36 +238,50 @@ export const AttachmentPicker = () => { const numberOfColumns = numberOfAttachmentPickerImageColumns ?? 3; + const Handle = useCallback( + () => ( + + + + ), + [ + AttachmentPickerSelectionBar, + attachmentSelectionBar, + attachmentSelectionBarHeight, + semantics.composerBg, + ], + ); + // TODO V9: Think of a better way to do this. This is just a temporary fix. const lastSelectedPickerRef = useRef(selectedPicker); if (selectedPicker) { lastSelectedPickerRef.current = selectedPicker; } + const animationConfigs = useBottomSheetSpringConfigs(SPRING_CONFIG); + return ( <> - - - {lastSelectedPickerRef.current === 'images' ? ( { ); }; -const NoOp = () => null; - AttachmentPicker.displayName = 'AttachmentPicker'; From ca2d8d68dc12edda7c8cd97a262392f1f46b16d7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 9 Feb 2026 10:07:31 +0100 Subject: [PATCH 12/33] chore: animate attach button --- .../components/InputButtons/AttachButton.tsx | 2 +- .../components/InputButtons/index.tsx | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx index 1011a423ef..af0afc26bb 100644 --- a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx +++ b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx @@ -85,7 +85,7 @@ const AttachButtonWithContext = (props: AttachButtonPropsWithContext) => { LeadingIcon={NewPlus} onLayout={onAttachButtonLayout} onPress={onPressHandler} - selected={selectedPicker === 'images'} + selected={selectedPicker !== undefined} disabled={disabled} testID='attach-button' /> diff --git a/package/src/components/MessageInput/components/InputButtons/index.tsx b/package/src/components/MessageInput/components/InputButtons/index.tsx index be8ef50724..dcc5b8eb9b 100644 --- a/package/src/components/MessageInput/components/InputButtons/index.tsx +++ b/package/src/components/MessageInput/components/InputButtons/index.tsx @@ -1,7 +1,13 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { StyleSheet } from 'react-native'; -import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + ZoomIn, + ZoomOut, +} from 'react-native-reanimated'; import { OwnCapabilitiesContextValue } from '../../../../contexts'; import { @@ -10,6 +16,7 @@ import { } from '../../../../contexts/messageInputContext/MessageInputContext'; import { useOwnCapabilitiesContext } from '../../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useAttachmentPickerState } from '../../../../hooks/useAttachmentPickerState'; export type InputButtonsProps = Partial; @@ -33,6 +40,8 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => hasImagePicker, uploadFile: ownCapabilitiesUploadFile, } = props; + const { selectedPicker } = useAttachmentPickerState(); + const rotation = useSharedValue(0); const { theme: { @@ -40,6 +49,14 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => }, } = useTheme(); + useEffect(() => { + rotation.value = withTiming(selectedPicker !== undefined ? 45 : 0, { duration: 200 }); + }, [selectedPicker, rotation]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + const hasAttachmentUploadCapabilities = (hasCameraPicker || hasFilePicker || hasImagePicker) && ownCapabilitiesUploadFile; @@ -51,7 +68,7 @@ export const InputButtonsWithContext = (props: InputButtonsWithContextProps) => From cac207436bd76a1226b9f5b01183c78b85709f91 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 10 Feb 2026 13:44:21 +0100 Subject: [PATCH 13/33] feat: implement the new attachment selection picker --- .../AttachmentPickerSelectionBar.tsx | 45 +++- .../SampleApp/src/icons/ShareLocationIcon.tsx | 13 +- .../AttachmentPicker/AttachmentPicker.tsx | 73 +++--- .../components/AttachmentPickerError.tsx | 81 ++++--- .../components/AttachmentPickerErrorImage.tsx | 6 +- .../AttachmentPickerSelectionBar.tsx | 123 ++-------- .../components/AttachmentTypePickerButton.tsx | 162 +++++++++++++ package/src/components/Channel/Channel.tsx | 2 +- .../components/InputButtons/AttachButton.tsx | 61 +++-- .../components/NativeAttachmentPicker.tsx | 223 +----------------- .../MessageList/MessageFlashList.tsx | 4 +- package/src/components/index.ts | 1 + package/src/components/ui/Button/Button.tsx | 4 +- .../MessageInputContext.tsx | 14 +- .../messagesContext/MessagesContext.tsx | 2 +- package/src/hooks/index.ts | 1 + package/src/icons/Camera.tsx | 16 +- package/src/icons/CommandsIcon.tsx | 16 ++ package/src/icons/FilePickerIcon.tsx | 16 ++ package/src/icons/Picture.tsx | 32 ++- package/src/icons/PollThumbnail.tsx | 12 +- package/src/icons/index.ts | 2 + .../state-store/attachment-picker-store.ts | 2 + 23 files changed, 419 insertions(+), 492 deletions(-) create mode 100644 package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx create mode 100644 package/src/icons/CommandsIcon.tsx create mode 100644 package/src/icons/FilePickerIcon.tsx diff --git a/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx b/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx index 0b858bcb1c..1991e09ff8 100644 --- a/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx +++ b/examples/SampleApp/src/components/AttachmentPickerSelectionBar.tsx @@ -1,35 +1,54 @@ -import { useState } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; -import { AttachmentPickerSelectionBar, useMessageInputContext } from 'stream-chat-react-native'; +import React, { useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { + AttachmentTypePickerButton, + useAttachmentPickerState, + CameraPickerButton, + CommandsPickerButton, + FilePickerButton, + MediaPickerButton, + PollPickerButton, + useAttachmentPickerContext, + useStableCallback, +} from 'stream-chat-react-native'; import { ShareLocationIcon } from '../icons/ShareLocationIcon'; import { LiveLocationCreateModal } from './LocationSharing/CreateLocationModal'; export const CustomAttachmentPickerSelectionBar = () => { const [modalVisible, setModalVisible] = useState(false); - const { closeAttachmentPicker } = useMessageInputContext(); + const { attachmentPickerStore } = useAttachmentPickerContext(); + const { selectedPicker } = useAttachmentPickerState(); const onRequestClose = () => { setModalVisible(false); - closeAttachmentPicker(); }; - const onOpenModal = () => { + const onOpenModal = useStableCallback(() => { + attachmentPickerStore.setSelectedPicker('location'); setModalVisible(true); - }; + }); return ( - - - - - + + + + + + + {modalVisible ? ( + + ) : null} ); }; const styles = StyleSheet.create({ - selectionBar: { flexDirection: 'row', alignItems: 'center' }, + selectionBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 6 }, liveLocationButton: { paddingLeft: 4, }, diff --git a/examples/SampleApp/src/icons/ShareLocationIcon.tsx b/examples/SampleApp/src/icons/ShareLocationIcon.tsx index 79c1e13584..70ac6c99eb 100644 --- a/examples/SampleApp/src/icons/ShareLocationIcon.tsx +++ b/examples/SampleApp/src/icons/ShareLocationIcon.tsx @@ -1,24 +1,19 @@ import Svg, { Path } from 'react-native-svg'; -import { useTheme } from 'stream-chat-react-native'; +import { ColorValue } from 'react-native'; // Icon for "Share Location" button, next to input box. -export const ShareLocationIcon = () => { - const { - theme: { - colors: { grey }, - }, - } = useTheme(); +export const ShareLocationIcon = ({ stroke }: { stroke: ColorValue }) => { return ( ); diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 126ec4de70..26ac40c2a7 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -269,20 +269,29 @@ export const AttachmentPicker = () => { const animationConfigs = useBottomSheetSpringConfigs(SPRING_CONFIG); return ( - <> - - {lastSelectedPickerRef.current === 'images' ? ( + + {lastSelectedPickerRef.current === 'images' ? ( + photoError ? ( + + ) : ( { testID={'attachment-picker-list'} updateCellsBatchingPeriod={16} /> - ) : ( - // TODO V9: Remove these inline styles - - {lastSelectedPickerRef.current} - - )} - - {selectedPicker === 'images' && photoError && ( - + ) + ) : ( + // TODO V9: Remove these inline styles + + {lastSelectedPickerRef.current} + )} - + ); }; diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx index 094bb21b63..1cd0da87ad 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentPickerError.tsx @@ -1,33 +1,37 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Linking, StyleSheet, Text, View } from 'react-native'; import { useAttachmentPickerContext } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui'; -const styles = StyleSheet.create({ - errorButtonText: { - fontSize: 14, - fontWeight: '600', - marginHorizontal: 24, - marginTop: 16, - textAlign: 'center', - }, - errorContainer: { - alignItems: 'center', - bottom: 0, - justifyContent: 'center', - left: 0, - position: 'absolute', - right: 0, - }, - errorText: { - fontSize: 14, - marginHorizontal: 24, - marginTop: 16, - textAlign: 'center', - }, -}); +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + errorContainer: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: semantics.backgroundElevationElevation1, + paddingHorizontal: primitives.spacing2xl, + paddingBottom: primitives.spacing3xl, + }, + errorText: { + fontSize: 14, + marginTop: 8, + marginHorizontal: 24, + textAlign: 'center', + }, + }), + [semantics.backgroundElevationElevation1], + ); +}; export type AttachmentPickerErrorProps = { AttachmentPickerErrorImage: React.ComponentType; @@ -43,11 +47,12 @@ export const AttachmentPickerError = (props: AttachmentPickerErrorProps) => { AttachmentPickerErrorImage, attachmentPickerErrorText, } = props; + const styles = useStyles(); const { theme: { - attachmentPicker: { errorButtonText, errorContainer, errorText }, - colors: { accent_blue, grey, white_smoke }, + attachmentPicker: { errorContainer, errorText }, + colors: { grey }, }, } = useTheme(); const { t } = useTranslationContext(); @@ -69,24 +74,24 @@ export const AttachmentPickerError = (props: AttachmentPickerErrorProps) => { style={[ styles.errorContainer, { - backgroundColor: white_smoke, height: attachmentPickerBottomSheetHeight, }, errorContainer, ]} > - - - {attachmentPickerErrorText || - t('Please enable access to your photos and videos so you can share them.')} - - + + + {attachmentPickerErrorText || t('You have not granted access to the photo library.')} + + +