From 697c90e76b9daebaa9accc1bab3b7ff15b404db1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 21 Dec 2025 13:12:13 +0100 Subject: [PATCH 01/30] feat: implement portal-style overlay --- examples/SampleApp/ios/Podfile.lock | 65 ++++++- examples/SampleApp/package.json | 1 + examples/SampleApp/yarn.lock | 5 + package/package.json | 1 + package/src/components/Message/Message.tsx | 112 +++++++++-- .../Message/MessageSimple/MessageContent.tsx | 9 +- .../Message/MessageSimple/MessageSimple.tsx | 183 +++++++++--------- .../components/MessageList/MessageList.tsx | 8 + .../components/MessageMenu/MessageMenu.tsx | 149 +++++++------- .../UIComponents/BottomSheetModal.tsx | 31 ++- .../messagesContext/MessagesContext.tsx | 4 +- .../overlayContext/OverlayProvider.tsx | 31 +-- package/yarn.lock | 5 + 13 files changed, 383 insertions(+), 221 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 171a4084a1..33acb26f3f 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3165,6 +3165,65 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - Teleport (0.5.3): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Teleport/common (= 0.5.3) + - Yoga + - Teleport/common (0.5.3): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - Yoga (0.0.0) DEPENDENCIES: @@ -3269,6 +3328,7 @@ DEPENDENCIES: - RNWorklets (from `../node_modules/react-native-worklets`) - SocketRocket (~> 0.7.1) - stream-chat-react-native (from `../node_modules/stream-chat-react-native`) + - Teleport (from `../node_modules/react-native-teleport`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -3490,6 +3550,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-worklets" stream-chat-react-native: :path: "../node_modules/stream-chat-react-native" + Teleport: + :path: "../node_modules/react-native-teleport" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -3522,7 +3584,7 @@ SPEC CHECKSUMS: op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f @@ -3612,6 +3674,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a + Teleport: 98d5a14f7f1a7b47b0c0541b00c697a59ac2682d Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 5da8d63f2a..62b854790b 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -55,6 +55,7 @@ "react-native-screens": "^4.11.1", "react-native-share": "^12.0.11", "react-native-svg": "^15.12.0", + "react-native-teleport": "^0.5.3", "react-native-video": "^6.16.1", "react-native-worklets": "^0.4.1", "stream-chat-react-native": "link:../../package/native-package", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 5d203517a1..3eb7eedb6a 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7729,6 +7729,11 @@ react-native-svg@^15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +react-native-teleport@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.3.tgz#c29fb09f4f0faf1d6d6aa479b1a0792f3a9373b6" + integrity sha512-aWAui0yH0UqqC3Z3wnY3VLXZw30ST4Ikdx9ZzF7YyeVJdhfcDO/JjQb2D3uHnbarttLQojdblQLYoQzeAO07sg== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" diff --git a/package/package.json b/package/package.json index 0f4028fa85..d9ec947f2d 100644 --- a/package/package.json +++ b/package/package.json @@ -157,6 +157,7 @@ "react-native-reanimated": "3.18.0", "react-native-safe-area-context": "^5.6.1", "react-native-svg": "15.12.0", + "react-native-teleport": "^0.5.3", "react-test-renderer": "19.1.0", "rimraf": "^6.0.1", "typescript": "5.8.3", diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 1949d3d6bb..6fc7b52014 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,5 +1,17 @@ -import React, { useMemo, useState } from 'react'; -import { GestureResponderEvent, Keyboard, StyleProp, View, ViewStyle } from 'react-native'; +import React, { useMemo, useRef, useState } from 'react'; +import { + findNodeHandle, + GestureResponderEvent, + Keyboard, + Pressable, + StyleProp, + TouchableWithoutFeedback, + UIManager, + View, + ViewStyle, +} from 'react-native'; + +import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -25,6 +37,7 @@ import { useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -60,6 +73,15 @@ export type TouchableEmitter = export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; +function measureInWindow(node: any): Promise<{ x: number; y: number; w: number; h: number }> { + return new Promise((resolve, reject) => { + const handle = findNodeHandle(node); + if (!handle) return reject(new Error('No native handle')); + + UIManager.measureInWindow(handle, (x, y, w, h) => resolve({ x, y, w, h })); + }); +} + export type TextMentionTouchableHandlerPayload = { emitter: 'textMention'; additionalInfo?: TextMentionTouchableHandlerAdditionalInfo; @@ -219,7 +241,12 @@ export type MessagePropsWithContext = Pick< * each individual Message component. */ const MessageWithContext = (props: MessagePropsWithContext) => { - const [messageOverlayVisible, setMessageOverlayVisible] = useState(false); + const [messageOverlayVisible, setMessageOverlayVisible] = useState<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); const [isErrorInMessage, setIsErrorInMessage] = useState(false); const [showMessageReactions, setShowMessageReactions] = useState(true); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); @@ -293,7 +320,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const { client } = chatContext; const { theme: { - colors: { targetedMessageBackground, bg_gradient_start }, + colors: { targetedMessageBackground, bg_gradient_start, overlay }, messageSimple: { targetedMessageContainer, unreadUnderlayColor = bg_gradient_start, wrapper }, screenPadding, }, @@ -301,13 +328,17 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => { await dismissKeyboard(); + const layout = await measureInWindow(messageWrapperRef.current); setShowMessageReactions(showMessageReactions); - setMessageOverlayVisible(true); + setMessageOverlayVisible(layout); setSelectedReaction(selectedReaction); }; + const { setNativeScrollability } = useMessageListItemContext(); + const dismissOverlay = () => { - setMessageOverlayVisible(false); + setNativeScrollability(true); + setMessageOverlayVisible(null); }; const actionsEnabled = @@ -620,7 +651,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => { unpinMessage: handleTogglePinMessage, }; + const messageWrapperRef = useRef(null); + const onLongPress = () => { + setNativeScrollability(false); if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { return; } @@ -759,20 +793,64 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - - {isBounceDialogOpen ? ( - - ) : null} {messageOverlayVisible ? ( - ) : null} + + + {messageOverlayVisible ? ( + + + + ) : null} + + + + + + {isBounceDialogOpen ? ( + + ) : null} + {/*{messageOverlayVisible ? (*/} + {/**/} + {/*) : null}*/} diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 425ebb4d85..8b42344c01 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { AnimatableNumericValue, ColorValue, @@ -121,6 +121,7 @@ export type MessageContentPropsWithContext = Pick< * Child of MessageSimple that displays a message's content */ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { + const [longPressFired, setLongPressFired] = useState(false); const { additionalPressableProps, alignment, @@ -243,6 +244,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { { + setLongPressFired(true); if (onLongPress) { onLongPress({ emitter: 'messageContent', @@ -266,7 +268,10 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }); } }} - style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }, container]} + onPressOut={() => { + setLongPressFired(false); + }} + style={({ pressed }) => [{ opacity: pressed && !longPressFired ? 0.5 : 1 }, container]} {...additionalPressableProps} > diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 472d4c47be..516c84e800 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { forwardRef, useMemo, useState } from 'react'; import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; @@ -84,7 +84,7 @@ export type MessageSimplePropsWithContext = Pick< shouldRenderSwipeableWrapper: boolean; }; -const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { +const MessageSimpleWithContext = forwardRef((props, ref) => { const [messageContentWidth, setMessageContentWidth] = useState(0); const { width } = Dimensions.get('screen'); const { @@ -200,100 +200,104 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { }); return ( - - {alignment === 'left' ? : null} - {isMessageTypeDeleted ? ( - - ) : ( - + + + {alignment === 'left' ? : null} + {isMessageTypeDeleted ? ( + + ) : ( - {MessageHeader && ( - + {MessageHeader && ( + + )} + {message.pinned ? : null} + + {enableSwipeToReply ? ( + + ) : ( + )} - {message.pinned ? : null} + {reactionListPosition === 'bottom' && ReactionListBottom ? ( + + ) : null} + + - {enableSwipeToReply ? ( - - ) : ( - - )} - {reactionListPosition === 'bottom' && ReactionListBottom ? : null} - - - - )} + )} + ); -}; +}); const areEqual = ( prevProps: MessageSimplePropsWithContext, @@ -431,7 +435,7 @@ export type MessageSimpleProps = Partial; * * Message UI component */ -export const MessageSimple = (props: MessageSimpleProps) => { +export const MessageSimple = forwardRef((props, ref) => { const { alignment, channel, @@ -506,9 +510,10 @@ export const MessageSimple = (props: MessageSimpleProps) => { shouldRenderSwipeableWrapper, showMessageStatus, }} + ref={ref} {...props} /> ); -}; +}); MessageSimple.displayName = 'MessageSimple{messageSimple{container}}'; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index a86fd62e47..c5611e1c35 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -768,6 +768,12 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetedMessage]); + const setNativeScrollability = useStableCallback((value: boolean) => { + if (flatListRef.current) { + flatListRef.current.setNativeProps({ scrollEnabled: value }); + } + }); + const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, @@ -775,6 +781,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, }), [ goToMessage, @@ -782,6 +789,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, ], ); diff --git a/package/src/components/MessageMenu/MessageMenu.tsx b/package/src/components/MessageMenu/MessageMenu.tsx index f8201ac560..2ac2c4c8c1 100644 --- a/package/src/components/MessageMenu/MessageMenu.tsx +++ b/package/src/components/MessageMenu/MessageMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { useWindowDimensions } from 'react-native'; @@ -15,46 +15,55 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { BottomSheetModal } from '../UIComponents/BottomSheetModal'; -export type MessageMenuProps = Partial< - Pick< - MessagesContextValue, - | 'MessageActionList' - | 'MessageActionListItem' - | 'MessageReactionPicker' - | 'MessageUserReactions' - | 'MessageUserReactionsAvatar' - | 'MessageUserReactionsItem' - > -> & - Partial> & { - /** - * Function to close the message actions bottom sheet - * @returns void - */ - dismissOverlay: () => void; - /** - * An array of message actions to render - */ - messageActions: MessageActionType[]; - /** - * Boolean to determine if there are message actions - */ - showMessageReactions: boolean; - /** - * Boolean to determine if the overlay is visible. - */ - visible: boolean; - /** - * Function to handle reaction on press - * @param reactionType - * @returns - */ - handleReaction?: (reactionType: string) => Promise; - /** - * The selected reaction - */ - selectedReaction?: string; - }; +export type MessageMenuProps = PropsWithChildren< + Partial< + Pick< + MessagesContextValue, + | 'MessageActionList' + | 'MessageActionListItem' + | 'MessageReactionPicker' + | 'MessageUserReactions' + | 'MessageUserReactionsAvatar' + | 'MessageUserReactionsItem' + > + > & + Partial> & { + /** + * Function to close the message actions bottom sheet + * @returns void + */ + dismissOverlay: () => void; + /** + * An array of message actions to render + */ + messageActions: MessageActionType[]; + /** + * Boolean to determine if there are message actions + */ + showMessageReactions: boolean; + /** + * Boolean to determine if the overlay is visible. + */ + visible: boolean; + /** + * Function to handle reaction on press + * @param reactionType + * @returns + */ + handleReaction?: (reactionType: string) => Promise; + /** + * The selected reaction + */ + selectedReaction?: string; + + layout: { + x: number; + y: number; + w: number; + h: number; + }; + } +>; export const MessageMenu = (props: MessageMenuProps) => { const { @@ -71,25 +80,27 @@ export const MessageMenu = (props: MessageMenuProps) => { selectedReaction, showMessageReactions, visible, + layout, + children, } = props; const { height } = useWindowDimensions(); - const { - MessageActionList: contextMessageActionList, - MessageActionListItem: contextMessageActionListItem, - MessageReactionPicker: contextMessageReactionPicker, - MessageUserReactions: contextMessageUserReactions, - MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, - MessageUserReactionsItem: contextMessageUserReactionsItem, - } = useMessagesContext(); - const { message: contextMessage } = useMessageContext(); - const MessageActionList = propMessageActionList ?? contextMessageActionList; - const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem; - const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker; - const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions; - const MessageUserReactionsAvatar = - propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; - const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; - const message = propMessage ?? contextMessage; + // const { + // MessageActionList: contextMessageActionList, + // MessageActionListItem: contextMessageActionListItem, + // MessageReactionPicker: contextMessageReactionPicker, + // MessageUserReactions: contextMessageUserReactions, + // MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, + // MessageUserReactionsItem: contextMessageUserReactionsItem, + // } = useMessagesContext(); + // const { message: contextMessage } = useMessageContext(); + // const MessageActionList = propMessageActionList ?? contextMessageActionList; + // const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem; + // const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker; + // const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions; + // const MessageUserReactionsAvatar = + // propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; + // const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; + // const message = propMessage ?? contextMessage; const { theme: { messageMenu: { @@ -110,27 +121,7 @@ export const MessageMenu = (props: MessageMenuProps) => { onClose={dismissOverlay} visible={visible} > - {showMessageReactions ? ( - - ) : ( - <> - reaction.type) || []} - /> - - - )} + {children} ); }; diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 4176eb86da..71bdfe4888 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useMemo } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { Animated, Keyboard, @@ -19,6 +19,8 @@ import { import { runOnJS } from 'react-native-reanimated'; +import { PortalHost } from 'react-native-teleport'; + import { useTheme } from '../../contexts/themeContext/ThemeContext'; export type BottomSheetModalProps = { @@ -89,6 +91,14 @@ export const BottomSheetModal = (props: PropsWithChildren // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [realVisible, setRealVisible] = useState(visible); + + useEffect(() => { + if (visible) { + setRealVisible(visible); + } + }, [visible]); + const keyboardDidShow = (event: KeyboardEvent) => { Animated.timing(translateY, { duration: 250, @@ -124,29 +134,14 @@ export const BottomSheetModal = (props: PropsWithChildren return ( - + - - - {children} - + {/**/} diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index f0158ef0a5..cb98317733 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import { PressableProps, ViewProps } from 'react-native'; +import { PressableProps, View, ViewProps } from 'react-native'; import type { Attachment, @@ -273,7 +273,7 @@ export type MessagesContextValue = Pick; + MessageSimple: React.ComponentType }>; /** * UI component for MessageStatus (delivered/read) * Defaults to: [MessageStatus](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/MessageSimple/MessageStatus.tsx) diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index e4397032fb..1879ea5ce2 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,9 +1,11 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; -import { BackHandler } from 'react-native'; +import { BackHandler, StyleSheet } from 'react-native'; import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; +import { PortalHost, PortalProvider } from 'react-native-teleport'; + import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; @@ -93,18 +95,21 @@ export const OverlayProvider = (props: PropsWithChildren) - {children} - {overlay === 'gallery' && ( - - )} + + {children} + {overlay === 'gallery' && ( + + )} + + diff --git a/package/yarn.lock b/package/yarn.lock index 100f186f7e..e3a0d726ef 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -7762,6 +7762,11 @@ react-native-svg@15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +react-native-teleport@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.3.tgz#c29fb09f4f0faf1d6d6aa479b1a0792f3a9373b6" + integrity sha512-aWAui0yH0UqqC3Z3wnY3VLXZw30ST4Ikdx9ZzF7YyeVJdhfcDO/JjQb2D3uHnbarttLQojdblQLYoQzeAO07sg== + react-native-url-polyfill@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" From 4fc3c6916ae9b50a4776c4a19626ffca86127686 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 22 Dec 2025 11:59:02 +0100 Subject: [PATCH 02/30] feat: reimplement portal handling to account for pressability --- package/src/components/Message/Message.tsx | 141 ++++++--- .../Message/MessageOverlayBundle.tsx | 225 ++++++++++++++ .../Message/MessageSimple/MessageContent.tsx | 14 +- .../overlayContext/OverlayProvider.tsx | 292 +++++++++++++++++- 4 files changed, 615 insertions(+), 57 deletions(-) create mode 100644 package/src/components/Message/MessageOverlayBundle.tsx diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 6fc7b52014..6c20036e62 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,16 +1,20 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { + Alert, findNodeHandle, GestureResponderEvent, Keyboard, + LayoutAnimation, Pressable, StyleProp, + StyleSheet, TouchableWithoutFeedback, UIManager, View, ViewStyle, } from 'react-native'; +import Animated, { Easing, FadeIn, FadeOut, useSharedValue } from 'react-native-reanimated'; import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -21,8 +25,10 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { MessageOverlayBundle } from './MessageOverlayBundle'; import { messageActions as defaultMessageActions } from './utils/messageActions'; +import { closeOverlay, openOverlay, useOverlayController } from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -241,12 +247,12 @@ export type MessagePropsWithContext = Pick< * each individual Message component. */ const MessageWithContext = (props: MessagePropsWithContext) => { - const [messageOverlayVisible, setMessageOverlayVisible] = useState<{ - x: number; - y: number; - w: number; - h: number; - } | null>(null); + // const [messageOverlayVisible, setMessageOverlayVisible] = useState<{ + // x: number; + // y: number; + // w: number; + // h: number; + // } | null>(null); const [isErrorInMessage, setIsErrorInMessage] = useState(false); const [showMessageReactions, setShowMessageReactions] = useState(true); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); @@ -326,19 +332,28 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }, } = useTheme(); + const topH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const bottomH = useSharedValue({ w: 0, h: 0, x: 0, y: 0 }); + const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => { await dismissKeyboard(); - const layout = await measureInWindow(messageWrapperRef.current); - setShowMessageReactions(showMessageReactions); - setMessageOverlayVisible(layout); - setSelectedReaction(selectedReaction); + try { + const layout = await measureInWindow(messageWrapperRef.current); + setShowMessageReactions(showMessageReactions); + // LayoutAnimation.configureNext(LayoutAnimation.Presets.spring); + openOverlay(message.id, { state: { isMyMessage, rect: layout }, topH, bottomH }); + setSelectedReaction(selectedReaction); + } catch (e) { + console.error(e); + } }; const { setNativeScrollability } = useMessageListItemContext(); const dismissOverlay = () => { - setNativeScrollability(true); - setMessageOverlayVisible(null); + closeOverlay(); }; const actionsEnabled = @@ -762,6 +777,16 @@ const MessageWithContext = (props: MessagePropsWithContext) => { videos: attachments.videos, }); + const { state, id, closing } = useOverlayController(); + + const active = id === message.id; + + useEffect(() => { + if (!active && setNativeScrollability) { + setNativeScrollability(true); + } + }, [setNativeScrollability, active]); + if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; } @@ -770,6 +795,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return ; } + console.log('ACTIVE: ', active); + return ( { ]} testID='message-wrapper' > - {messageOverlayVisible ? ( + {active ? ( ) : null} - - - {messageOverlayVisible ? ( - - - - ) : null} - + {active && !closing ? ( + { + const { x, y, width: w, height: h } = e.nativeEvent.layout; + console.log('BLA', x, y, w, h); + topH.value = { x, y, w, h }; + }} + onPress={() => Alert.alert('HIT TOP')} + > + + + ) : null} + + + {/* The message itself: NOT animated; it just rides along as a child */} + + + {/* Actions below; height measured */} + {active && !closing ? ( + { + const { x, y, width: w, height: h } = e.nativeEvent.layout; + // eslint-disable-next-line sort-keys + bottomH.value = { x, y, w, h }; + }} + style={{ + position: 'absolute', + top: state.rect.h, + ...(isMyMessage ? { right: state.rect.x } : { left: state.rect.x }), + }} > - - - + Alert.alert('HIT BOTTOM')}> + + + + ) : null} {isBounceDialogOpen ? ( @@ -1075,3 +1109,10 @@ export const Message = (props: MessageProps) => { /> ); }; + +const ReactionList = () => ; +const MessageActions = () => ( + +); + +const ANIMATED_DURATION = 250; diff --git a/package/src/components/Message/MessageOverlayBundle.tsx b/package/src/components/Message/MessageOverlayBundle.tsx new file mode 100644 index 0000000000..5515d745e1 --- /dev/null +++ b/package/src/components/Message/MessageOverlayBundle.tsx @@ -0,0 +1,225 @@ +import React, { useMemo } from 'react'; +import { LayoutAnimation, Pressable, useWindowDimensions, View } from 'react-native'; +import Animated, { + Easing, + FadeIn, + FadeOut, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, + withTiming, + ZoomIn, + ZoomOut, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const clamp = (v: number, min: number, max: number) => { + 'worklet'; + return Math.max(min, Math.min(v, max)); +}; + +export function MessageOverlayBundle({ + active, + rect, + isMyMessage, + dismiss, + overlayColor, + children, // MessageSimple +}: { + active: boolean; + rect: { x: number; y: number; w: number; h: number } | null; + isMyMessage: boolean; + dismiss: () => void; + overlayColor: string; + children: React.ReactNode; +}) { + const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + + const topH = useSharedValue(0); + const bottomH = useSharedValue(0); + const progress = useSharedValue(0); + + React.useEffect(() => { + progress.value = withTiming(active ? 1 : 0, { + duration: 1250, + easing: Easing.out(Easing.cubic), + }); + }, [active, progress]); + + const padding = 8; + const minY = insets.top + padding; + const maxY = screenH - insets.bottom - padding; + + const shiftY = useDerivedValue(() => { + if (!active || !rect) return 0; + + const anchorY = rect.y; + const msgH = rect.h; + + const minTop = minY + topH.value; + const maxTop = maxY - (msgH + bottomH.value); + + // you said: assume it can fit + const solvedTop = clamp(anchorY, minTop, maxTop); + return solvedTop - anchorY; + }); + + const shiftYStatic = useMemo(() => { + if (!active || !rect) return 0; + + const anchorY = rect.y; + const msgH = rect.h; + + const minTop = minY + topH.value; + const maxTop = maxY - (msgH + bottomH.value); + + // you said: assume it can fit + const solvedTop = clamp(anchorY, minTop, maxTop); + return solvedTop - anchorY; + }, [active, bottomH.value, maxY, minY, rect, topH.value]); + + const [shift, setShift] = React.useState(0); + + React.useEffect(() => { + if (!active || !rect) { + setShift(0); + return; + } + + const anchorY = rect.y; + const msgH = rect.h; + + const minTop = minY + topH.value; + const maxTop = maxY - (msgH + bottomH.value); + + // you said: assume it can fit + const solvedTop = clamp(anchorY, minTop, maxTop); + const nextShift = solvedTop - anchorY; + + // animate the layout change + LayoutAnimation.configureNext({ + duration: 220, + update: { + type: LayoutAnimation.Types.spring, + springDamping: 0.85, + }, + }); + + setShift(nextShift); + }, [active, bottomH.value, maxY, minY, rect, topH.value]); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + })); + + const wrapperStyle = useAnimatedStyle(() => { + // if (!rect) return {}; + // animate only the container shift + return { + transform: [ + { + translateY: withTiming(active ? shiftY.value : -shiftY.value, { + duration: 150, + }), + }, + ], + }; + }); + + // if (!rect) { + // // inactive: render message inline (portal hostName undefined makes it inline) + // // IMPORTANT: still return children so MessageSimple exists and is stable. + // return <>{children}; + // } + + console.log('SHIFT: ', shift); + + return ( + <> + {/* When inactive, this is opacity 0 and basically inert */} + {active ? ( + + ) : null} + + {/* click outside only when active */} + {active ? ( + + ) : null} + + {/* The moving container (only one, only for active message) */} + + {/* Reactions positioned above the message; height measured */} + {active ? ( + { + topH.value = e.nativeEvent.layout.height; + }} + style={{ position: 'absolute', left: 0, right: 0, bottom: rect.h }} + > + + + ) : null} + + {/* The message itself: NOT animated; it just rides along as a child */} + {children} + + {/* Actions below; height measured */} + {active ? ( + { + bottomH.value = e.nativeEvent.layout.height; + }} + style={{ position: 'absolute', left: 0, right: 0, top: rect.h }} + > + + + ) : null} + + + ); +} + +const ReactionList = () => ; +const MessageActions = () => ( + +); + +const ANIMATED_DURATION = 1000; diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 8b42344c01..120c6301be 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -16,6 +16,7 @@ import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -240,6 +241,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { return bordersFromTheme; }; + const { setNativeScrollability } = useMessageListItemContext(); + return ( { }); } }} - onPressOut={() => { - setLongPressFired(false); - }} style={({ pressed }) => [{ opacity: pressed && !longPressFired ? 0.5 : 1 }, container]} {...additionalPressableProps} + onPressOut={(event) => { + setLongPressFired(false); + setNativeScrollability(true); + + if (additionalPressableProps?.onPressOut) { + additionalPressableProps.onPressOut(event); + } + }} > {hasThreadReplies && !threadList && !noBorder && ( diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 1879ea5ce2..f5c643cbda 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,15 +1,40 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; -import { BackHandler, StyleSheet } from 'react-native'; +import { + BackHandler, + Pressable, + StyleSheet, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native'; + +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, + clamp, + Easing, + FadeIn, + FadeOut, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; -import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Portal, PortalHost, PortalProvider } from 'react-native-teleport'; -import { PortalHost, PortalProvider } from 'react-native-teleport'; +import { StateStore } from 'stream-chat'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; +import { useStateStore } from '../../hooks'; import { useStreami18n } from '../../hooks/useStreami18n'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; @@ -108,7 +133,7 @@ export const OverlayProvider = (props: PropsWithChildren) overlayOpacity={overlayOpacity} /> )} - + @@ -116,3 +141,262 @@ export const OverlayProvider = (props: PropsWithChildren) ); }; + +type OverlayState = { + state: + | { + isMyMessage: boolean; + rect: { w: number; h: number; x: number; y: number }; + } + | undefined; + topH: Animated.SharedValue | undefined; + bottomH: Animated.SharedValue | undefined; + id: string | undefined; + closing: boolean; +}; + +const DefaultState = { + state: undefined, + topH: undefined, + bottomH: undefined, + id: undefined, + closing: false, +}; + +export const openOverlay = (id, { state, topH, bottomH }: Partial) => + overlayStore.partialNext({ state, topH, bottomH, id, closing: false }); + +export const closeOverlay = () => { + console.log('CLOSING INVOKED?!r'); + requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); +}; + +const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); + +export const overlayStore = new StateStore(DefaultState); + +const selector = (nextState: OverlayState) => ({ + state: nextState.state, + topH: nextState.topH, + bottomH: nextState.bottomH, + id: nextState.id, + closing: nextState.closing, +}); + +export const useOverlayController = () => { + return useStateStore(overlayStore, selector); +}; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +function OverlayHostLayer() { + const { state, topH, bottomH, id, closing } = useOverlayController(); + const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + + const isActive = !!id; + + const { rect, isMyMessage } = state ?? {}; + + const padding = 8; + const minY = insets.top + padding; + const maxY = screenH - insets.bottom - padding; + + const backdrop = useSharedValue(0); + + useAnimatedReaction( + () => (isActive && !closing ? 1 : 0), + (next, prev) => { + if (next === prev) return; + + cancelAnimation(backdrop); + backdrop.value = withTiming(next, { + duration: next === 1 ? 160 : 140, + }); + }, + [isActive, closing], + ); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: backdrop.value, + })); + + const shiftY = useDerivedValue(() => { + if (!rect || !topH?.value) return 0; + + const anchorY = rect.y; + const msgH = rect.h; + + const minTop = minY + topH.value.h; + const maxTop = maxY - (msgH + bottomH.value.h); + + // you said: assume it can fit + const solvedTop = clamp(anchorY, minTop, maxTop); + return solvedTop - anchorY; + }); + + const hostStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }, (finished) => { + if (finished && closing) { + runOnJS(finalizeCloseOverlay)(); + } + }), + }, + ], + }; + }, [isActive, closing]); + const topItemStyle = useAnimatedStyle(() => { + if (!topH?.value || !rect || closing) return { height: 0, opacity: 0 }; + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + console.log('TESTH: ', topH.value); + return { + top: rect.y - topH.value.h + target, + width: topH.value.w, + height: topH.value.h, + opacity: 1, + // transform: [ + // { + // translateY: withTiming(target, { duration: 150 }), + // }, + // ], + }; + }, [rect, closing, topH]); + + console.log('SHIFTVAL: ', isActive, shiftY.value, rect, isMyMessage); + + return ( + <> + {isActive ? ( + + ) : null} + {/*{isActive && !closing ? (*/} + {/* */} + {/* */} + {/* */} + {/*) : null}*/} + {/**/} + {/* /!* 1) BACKDROP: full-screen, captures outside taps, blocks underlying app *!/*/} + {/* {isActive && !closing ? (*/} + {/* */} + {/* ) : null}*/} + {/**/} + {isActive && !closing ? ( + // + // + + ) : // + // + null} + {/**/} + {/* */} + {/**/} + {/**/} + {/* */} + {/**/} + {/*{isActive && !closing ? (*/} + {/* */} + {/**/} + {/* */} + {/*) : null}*/} + {/*{isActive && !closing && rect ? (*/} + {/* */} + {/*) : null}*/} + + + + + + + + ); +} + +type Rect = { x: number; y: number; w: number; h: number }; + +export function OutsideTapRectDetector({ + enabled, + rect, + onOutsideTap, + children, +}: { + enabled: boolean; + rect: Rect; + onOutsideTap: () => void; + children: React.ReactNode; +}) { + const nativeTap = Gesture.Native(); + const gesture = React.useMemo(() => { + return Gesture.Tap() + .enabled(enabled) + .maxDistance(12) + .requireExternalGestureToFail(nativeTap) + .onEnd((_e, success) => { + if (success) runOnJS(onOutsideTap)(); + }); + }, [nativeTap, enabled, onOutsideTap]); + + return {children}; +} + +const ANIMATED_DURATION = 150; From 8996013cd1161e2ef2c474c62395b8d3cac58d56 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 22 Dec 2025 13:52:47 +0100 Subject: [PATCH 03/30] chore: initial minor cleanup --- package/src/components/Message/Message.tsx | 67 ++---- .../MessageListItemContext.tsx | 1 + .../overlayContext/OverlayProvider.tsx | 193 +++++++----------- 3 files changed, 92 insertions(+), 169 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 6c20036e62..f0fedd0340 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -4,17 +4,14 @@ import { findNodeHandle, GestureResponderEvent, Keyboard, - LayoutAnimation, Pressable, StyleProp, - StyleSheet, - TouchableWithoutFeedback, UIManager, View, ViewStyle, } from 'react-native'; -import Animated, { Easing, FadeIn, FadeOut, useSharedValue } from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -25,7 +22,6 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; -import { MessageOverlayBundle } from './MessageOverlayBundle'; import { messageActions as defaultMessageActions } from './utils/messageActions'; import { closeOverlay, openOverlay, useOverlayController } from '../../contexts'; @@ -84,7 +80,7 @@ function measureInWindow(node: any): Promise<{ x: number; y: number; w: number; const handle = findNodeHandle(node); if (!handle) return reject(new Error('No native handle')); - UIManager.measureInWindow(handle, (x, y, w, h) => resolve({ x, y, w, h })); + UIManager.measureInWindow(handle, (x, y, w, h) => resolve({ h, w, x, y })); }); } @@ -326,7 +322,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const { client } = chatContext; const { theme: { - colors: { targetedMessageBackground, bg_gradient_start, overlay }, + colors: { targetedMessageBackground, bg_gradient_start }, messageSimple: { targetedMessageContainer, unreadUnderlayColor = bg_gradient_start, wrapper }, screenPadding, }, @@ -335,15 +331,16 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const topH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( undefined, ); - const bottomH = useSharedValue({ w: 0, h: 0, x: 0, y: 0 }); + const bottomH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => { await dismissKeyboard(); try { const layout = await measureInWindow(messageWrapperRef.current); setShowMessageReactions(showMessageReactions); - // LayoutAnimation.configureNext(LayoutAnimation.Presets.spring); - openOverlay(message.id, { state: { isMyMessage, rect: layout }, topH, bottomH }); + openOverlay(message.id, { bottomH, state: { isMyMessage, rect: layout }, topH }); setSelectedReaction(selectedReaction); } catch (e) { console.error(e); @@ -820,11 +817,11 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - {active ? ( + {active && state ? ( ) : null} @@ -833,8 +830,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { { const { x, y, width: w, height: h } = e.nativeEvent.layout; - console.log('BLA', x, y, w, h); - topH.value = { x, y, w, h }; + topH.value = { h, w, x, y }; }} onPress={() => Alert.alert('HIT TOP')} > @@ -844,47 +840,26 @@ const MessageWithContext = (props: MessagePropsWithContext) => { - {/* The message itself: NOT animated; it just rides along as a child */} - - {/* Actions below; height measured */} + + {active && !closing ? ( - { const { x, y, width: w, height: h } = e.nativeEvent.layout; - // eslint-disable-next-line sort-keys - bottomH.value = { x, y, w, h }; - }} - style={{ - position: 'absolute', - top: state.rect.h, - ...(isMyMessage ? { right: state.rect.x } : { left: state.rect.x }), + bottomH.value = { h, w, x, y }; }} + onPress={() => Alert.alert('HIT BOTTOM')} > - Alert.alert('HIT BOTTOM')}> - - - + + ) : null} {isBounceDialogOpen ? ( ) : null} - {/*{messageOverlayVisible ? (*/} - {/**/} - {/*) : null}*/} @@ -1110,9 +1085,7 @@ export const Message = (props: MessageProps) => { ); }; -const ReactionList = () => ; +const ReactionList = () => ; const MessageActions = () => ( - + ); - -const ANIMATED_DURATION = 250; diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx index 90fb5d4872..71245fbf15 100644 --- a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -33,6 +33,7 @@ export type MessageListItemContextValue = { * @param message A message object to open the thread upon. */ onThreadSelect: MessageListProps['onThreadSelect']; + setNativeScrollability: (value: boolean) => void; }; export const MessageListItemContext = createContext( diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index f5c643cbda..60225c8587 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,15 +1,7 @@ import React, { PropsWithChildren, useEffect, useState } from 'react'; -import { - BackHandler, - Pressable, - StyleSheet, - TouchableWithoutFeedback, - useWindowDimensions, - View, -} from 'react-native'; - -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { BackHandler, Pressable, StyleSheet, useWindowDimensions } from 'react-native'; + import Animated, { cancelAnimation, clamp, @@ -21,12 +13,11 @@ import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, - withSpring, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Portal, PortalHost, PortalProvider } from 'react-native-teleport'; +import { PortalHost, PortalProvider } from 'react-native-teleport'; import { StateStore } from 'stream-chat'; @@ -156,18 +147,17 @@ type OverlayState = { }; const DefaultState = { - state: undefined, - topH: undefined, bottomH: undefined, - id: undefined, closing: false, + id: undefined, + state: undefined, + topH: undefined, }; -export const openOverlay = (id, { state, topH, bottomH }: Partial) => - overlayStore.partialNext({ state, topH, bottomH, id, closing: false }); +export const openOverlay = (id: string, { state, topH, bottomH }: Partial) => + overlayStore.partialNext({ bottomH, closing: false, id, state, topH }); export const closeOverlay = () => { - console.log('CLOSING INVOKED?!r'); requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); }; @@ -176,19 +166,17 @@ const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); export const overlayStore = new StateStore(DefaultState); const selector = (nextState: OverlayState) => ({ - state: nextState.state, - topH: nextState.topH, bottomH: nextState.bottomH, - id: nextState.id, closing: nextState.closing, + id: nextState.id, + state: nextState.state, + topH: nextState.topH, }); export const useOverlayController = () => { return useStateStore(overlayStore, selector); }; -const AnimatedPressable = Animated.createAnimatedComponent(Pressable); - function OverlayHostLayer() { const { state, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); @@ -222,7 +210,7 @@ function OverlayHostLayer() { })); const shiftY = useDerivedValue(() => { - if (!rect || !topH?.value) return 0; + if (!rect || !topH?.value || !bottomH?.value) return 0; const anchorY = rect.y; const msgH = rect.h; @@ -230,7 +218,6 @@ function OverlayHostLayer() { const minTop = minY + topH.value.h; const maxTop = maxY - (msgH + bottomH.value.h); - // you said: assume it can fit const solvedTop = clamp(anchorY, minTop, maxTop); return solvedTop - anchorY; }); @@ -250,24 +237,50 @@ function OverlayHostLayer() { ], }; }, [isActive, closing]); + const topItemStyle = useAnimatedStyle(() => { if (!topH?.value || !rect || closing) return { height: 0, opacity: 0 }; - const target = isActive ? (closing ? 0 : shiftY.value) : 0; - console.log('TESTH: ', topH.value); return { - top: rect.y - topH.value.h + target, - width: topH.value.w, height: topH.value.h, opacity: 1, - // transform: [ - // { - // translateY: withTiming(target, { duration: 150 }), - // }, - // ], + top: rect.y - topH.value.h, + width: topH.value.w, }; }, [rect, closing, topH]); - console.log('SHIFTVAL: ', isActive, shiftY.value, rect, isMyMessage); + const topItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }), + }, + ], + }; + }); + + const bottomItemStyle = useAnimatedStyle(() => { + if (!bottomH?.value || !rect || closing) return { height: 0, opacity: 0 }; + return { + height: bottomH.value.h, + opacity: 1, + top: rect.y + rect.h, + width: bottomH.value.w, + }; + }, [rect, closing, bottomH]); + + const bottomItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }), + }, + ], + }; + }); return ( <> @@ -277,76 +290,21 @@ function OverlayHostLayer() { style={[StyleSheet.absoluteFillObject, { backgroundColor: '#000000CC' }, backdropStyle]} /> ) : null} - {/*{isActive && !closing ? (*/} - {/* */} - {/* */} - {/* */} - {/*) : null}*/} - {/**/} - {/* /!* 1) BACKDROP: full-screen, captures outside taps, blocks underlying app *!/*/} - {/* {isActive && !closing ? (*/} - {/* */} - {/* ) : null}*/} - {/**/} {isActive && !closing ? ( - // - // - - ) : // - // - null} - {/**/} - {/* */} - {/**/} - {/**/} - {/* */} - {/**/} - {/*{isActive && !closing ? (*/} - {/* */} - {/**/} - {/* */} - {/*) : null}*/} - {/*{isActive && !closing && rect ? (*/} - {/* */} - {/*) : null}*/} + + ) : null} @@ -354,12 +312,12 @@ function OverlayHostLayer() { + + + ); } -type Rect = { x: number; y: number; w: number; h: number }; - -export function OutsideTapRectDetector({ - enabled, - rect, - onOutsideTap, - children, -}: { - enabled: boolean; - rect: Rect; - onOutsideTap: () => void; - children: React.ReactNode; -}) { - const nativeTap = Gesture.Native(); - const gesture = React.useMemo(() => { - return Gesture.Tap() - .enabled(enabled) - .maxDistance(12) - .requireExternalGestureToFail(nativeTap) - .onEnd((_e, success) => { - if (success) runOnJS(onOutsideTap)(); - }); - }, [nativeTap, enabled, onOutsideTap]); - - return {children}; -} +type Rect = { x: number; y: number; w: number; h: number } | undefined; const ANIMATED_DURATION = 150; From de5a167a4f691901639938f61f5c8d7a1e65c64b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 22 Dec 2025 19:50:29 +0100 Subject: [PATCH 04/30] feat: fix variety bugs and introduce actual action/reaction ui --- examples/SampleApp/App.tsx | 108 +++++++++--------- package/src/components/Message/Message.tsx | 60 +++++----- .../Message/MessageSimple/MessageSimple.tsx | 1 + .../MessageMenu/MessageActionList.tsx | 13 ++- .../MessageMenu/MessageActionListItem.tsx | 9 +- .../MessageMenu/MessageReactionPicker.tsx | 5 +- .../overlayContext/OverlayProvider.tsx | 37 +++++- 7 files changed, 140 insertions(+), 93 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 771d8db352..61d162acbf 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -94,6 +94,7 @@ notifee.onBackgroundEvent(async ({ detail, type }) => { const Drawer = createDrawerNavigator(); const Stack = createNativeStackNavigator(); const UserSelectorStack = createNativeStackNavigator(); + const App = () => { const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient(); const [messageListImplementation, setMessageListImplementation] = useState< @@ -107,6 +108,7 @@ const App = () => { >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); + const streami18n = new Streami18n(); useEffect(() => { const messaging = getMessaging(); @@ -209,39 +211,41 @@ const App = () => { backgroundColor: streamChatTheme.colors?.white_snow || '#FCFCFC', }} > - - - + + - {isConnecting && !chatClient ? ( - - ) : chatClient ? ( - - ) : ( - - )} - - - + + {isConnecting && !chatClient ? ( + + ) : chatClient ? ( + + ) : ( + + )} + + + + ); }; @@ -265,31 +269,27 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated; const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; -}> = ({ chatClient }) => { - const streamChatTheme = useStreamChatTheme(); - const streami18n = new Streami18n(); - + i18nInstance: Streami18n; +}> = ({ chatClient, i18nInstance }) => { return ( - - - - - - - - - - - - + + + + + + + + + + ); }; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index f0fedd0340..dd1d1a5b2a 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,10 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, findNodeHandle, GestureResponderEvent, Keyboard, - Pressable, StyleProp, UIManager, View, @@ -75,14 +73,15 @@ export type TouchableEmitter = export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; -function measureInWindow(node: any): Promise<{ x: number; y: number; w: number; h: number }> { +// TODO: Take care of forwards compatibility with new measuring API (0.81+) +const measureInWindow = (node: any): Promise<{ x: number; y: number; w: number; h: number }> => { return new Promise((resolve, reject) => { const handle = findNodeHandle(node); if (!handle) return reject(new Error('No native handle')); UIManager.measureInWindow(handle, (x, y, w, h) => resolve({ h, w, x, y })); }); -} +}; export type TextMentionTouchableHandlerPayload = { emitter: 'textMention'; @@ -216,6 +215,13 @@ export type MessagePropsWithContext = Pick< | 'supportedReactions' | 'updateMessage' | 'PollContent' + // TODO: remove this comment later, using it as a pragma mark + | 'MessageUserReactions' + | 'MessageUserReactionsAvatar' + | 'MessageUserReactionsItem' + | 'MessageReactionPicker' + | 'MessageActionList' + | 'MessageActionListItem' > & Pick & Pick & { @@ -243,12 +249,6 @@ export type MessagePropsWithContext = Pick< * each individual Message component. */ const MessageWithContext = (props: MessagePropsWithContext) => { - // const [messageOverlayVisible, setMessageOverlayVisible] = useState<{ - // x: number; - // y: number; - // w: number; - // h: number; - // } | null>(null); const [isErrorInMessage, setIsErrorInMessage] = useState(false); const [showMessageReactions, setShowMessageReactions] = useState(true); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); @@ -288,7 +288,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { MessageBlocked, MessageBounce, messageContentOrder: messageContentOrderProp, - MessageMenu, messagesContext, MessageSimple, onLongPressMessage: onLongPressMessageProp, @@ -312,6 +311,12 @@ const MessageWithContext = (props: MessagePropsWithContext) => { updateMessage, readBy, setQuotedMessage, + // MessageUserReactions, + // MessageUserReactionsAvatar, + // MessageUserReactionsItem, + MessageReactionPicker, + MessageActionList, + MessageActionListItem, } = props; const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( @@ -792,8 +797,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return ; } - console.log('ACTIVE: ', active); - return ( { ]} testID='message-wrapper' > - {active && state ? ( + {active && state?.rect ? ( { ) : null} {active && !closing ? ( - { const { x, y, width: w, height: h } = e.nativeEvent.layout; topH.value = { h, w, x, y }; }} - onPress={() => Alert.alert('HIT TOP')} > - - + reaction.type) || []} + /> + ) : null} {active && !closing ? ( - { const { x, y, width: w, height: h } = e.nativeEvent.layout; bottomH.value = { h, w, x, y }; }} - onPress={() => Alert.alert('HIT BOTTOM')} > - - + + ) : null} {isBounceDialogOpen ? ( @@ -1084,8 +1093,3 @@ export const Message = (props: MessageProps) => { /> ); }; - -const ReactionList = () => ; -const MessageActions = () => ( - -); diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 516c84e800..15d38a5ae7 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -3,6 +3,7 @@ import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; +import { updateOverlayState } from '../../../contexts'; import { MessageContextValue, useMessageContext, diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 6fc69abfe2..cff47b7cb1 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -24,6 +24,7 @@ export const MessageActionList = (props: MessageActionListProps) => { const { MessageActionListItem, messageActions } = props; const { theme: { + colors: { white }, messageMenu: { actionList: { container, contentContainer }, }, @@ -37,8 +38,12 @@ export const MessageActionList = (props: MessageActionListProps) => { return ( {messageActions?.map((messageAction, index) => ( { }; const styles = StyleSheet.create({ - container: {}, + // TODO: Preliminary height fix, think about this more thoroughly + container: { marginTop: 16, maxHeight: 200 }, contentContainer: { + flexGrow: 1, paddingHorizontal: 16, }, }); diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 47f8c26cf5..86eda2753e 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; +import { closeOverlay, scheduleActionOnClose } from '../../contexts'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; export type ActionType = | 'banUser' @@ -62,8 +64,13 @@ export const MessageActionListItem = (props: MessageActionListItemProps) => { }, } = useTheme(); + const onActionPress = useStableCallback(() => { + closeOverlay(); + scheduleActionOnClose(() => action()); + }); + return ( - [{ opacity: pressed ? 0.5 : 1 }]}> + [{ opacity: pressed ? 0.5 : 1 }]}> { const onSelectReaction = (type: string) => { NativeHandlers.triggerHaptic('impactLight'); + dismissOverlay(); if (handleReaction) { - handleReaction(type); + scheduleActionOnClose(() => handleReaction(type)); } - dismissOverlay(); }; if (!own_capabilities.sendReaction) { diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 60225c8587..16bf8ed25c 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -136,8 +136,8 @@ export const OverlayProvider = (props: PropsWithChildren) type OverlayState = { state: | { - isMyMessage: boolean; - rect: { w: number; h: number; x: number; y: number }; + isMyMessage?: boolean; + rect?: { w: number; h: number; x: number; y: number }; } | undefined; topH: Animated.SharedValue | undefined; @@ -161,10 +161,34 @@ export const closeOverlay = () => { requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); }; +let actionQueue: Array<() => void | Promise> = []; + +export const scheduleActionOnClose = (action: () => void | Promise) => { + const { id } = overlayStore.getLatestValue(); + if (id) { + actionQueue.push(action); + return; + } + action(); +}; + +const s = (nextState: OverlayState) => ({ active: !!nextState.id }); + const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); export const overlayStore = new StateStore(DefaultState); +overlayStore.subscribeWithSelector(s, ({ active }) => { + if (!active) { + // flush the queue + for (const action of actionQueue) { + action(); + } + + actionQueue = []; + } +}); + const selector = (nextState: OverlayState) => ({ bottomH: nextState.bottomH, closing: nextState.closing, @@ -177,7 +201,7 @@ export const useOverlayController = () => { return useStateStore(overlayStore, selector); }; -function OverlayHostLayer() { +const OverlayHostLayer = () => { const { state, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); const { height: screenH } = useWindowDimensions(); @@ -262,6 +286,7 @@ function OverlayHostLayer() { const bottomItemStyle = useAnimatedStyle(() => { if (!bottomH?.value || !rect || closing) return { height: 0, opacity: 0 }; + console.log('wtf is going on: ', bottomH.value, rect); return { height: bottomH.value.h, opacity: 1, @@ -290,9 +315,11 @@ function OverlayHostLayer() { style={[StyleSheet.absoluteFillObject, { backgroundColor: '#000000CC' }, backdropStyle]} /> ) : null} + {isActive && !closing ? ( - + ) : null} + ); -} +}; type Rect = { x: number; y: number; w: number; h: number } | undefined; From e405be948324901d17aff0ab3e57b57863575c44 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Dec 2025 11:28:36 +0100 Subject: [PATCH 05/30] fix: await queued up actions --- package/src/contexts/overlayContext/OverlayProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 16bf8ed25c..7e8076db60 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -178,11 +178,11 @@ const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); export const overlayStore = new StateStore(DefaultState); -overlayStore.subscribeWithSelector(s, ({ active }) => { +overlayStore.subscribeWithSelector(s, async ({ active }) => { if (!active) { // flush the queue for (const action of actionQueue) { - action(); + await action(); } actionQueue = []; From ac0d92e68af39a9915a526b95bd9201d9c95c783 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Dec 2025 18:29:46 +0100 Subject: [PATCH 06/30] fix: freeze message once teleported --- package/src/components/Message/Message.tsx | 20 ++++++++++++++----- .../Message/MessageSimple/MessageSimple.tsx | 2 +- .../overlayContext/OverlayProvider.tsx | 17 +++++++--------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index dd1d1a5b2a..b8a4c66a64 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -684,6 +684,11 @@ const MessageWithContext = (props: MessagePropsWithContext) => { showMessageOverlay(); }; + const frozenMessage = useRef(message); + const { state, id, closing } = useOverlayController(); + + const active = id === message.id; + const messageContext = useCreateMessageContext({ actionsEnabled, alignment, @@ -703,7 +708,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, - message, + message: active ? frozenMessage.current : message, messageContentOrder, myMessageTheme: messagesContext.myMessageTheme, onLongPress: (payload) => { @@ -779,16 +784,21 @@ const MessageWithContext = (props: MessagePropsWithContext) => { videos: attachments.videos, }); - const { state, id, closing } = useOverlayController(); - - const active = id === message.id; + const prevActive = useRef(active); useEffect(() => { - if (!active && setNativeScrollability) { + if (!active && prevActive.current && setNativeScrollability) { setNativeScrollability(true); } + prevActive.current = active; }, [setNativeScrollability, active]); + useEffect(() => { + if (!active) { + frozenMessage.current = message; + } + }, [active, message]); + if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; } diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 15d38a5ae7..db9e5b4f7e 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -3,7 +3,6 @@ import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; -import { updateOverlayState } from '../../../contexts'; import { MessageContextValue, useMessageContext, @@ -203,6 +202,7 @@ const MessageSimpleWithContext = forwardRef return ( { const solvedTop = clamp(anchorY, minTop, maxTop); return solvedTop - anchorY; - }); + }, [rect]); const hostStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -280,6 +277,9 @@ const OverlayHostLayer = () => { { translateY: withTiming(target, { duration: 150 }), }, + { + scale: backdrop.value, + }, ], }; }); @@ -303,6 +303,9 @@ const OverlayHostLayer = () => { { translateY: withTiming(target, { duration: 150 }), }, + { + scale: backdrop.value, + }, ], }; }); @@ -321,8 +324,6 @@ const OverlayHostLayer = () => { ) : null} { { }; type Rect = { x: number; y: number; w: number; h: number } | undefined; - -const ANIMATED_DURATION = 150; From a7c7f818616430463b90899fec412b374cb58502 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Dec 2025 21:50:19 +0100 Subject: [PATCH 07/30] chore: bump rn teleport --- examples/SampleApp/ios/Podfile.lock | 10 +++++----- examples/SampleApp/package.json | 2 +- examples/SampleApp/yarn.lock | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 33acb26f3f..bc13cd1937 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3165,7 +3165,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Teleport (0.5.3): + - Teleport (0.5.4): - boost - DoubleConversion - fast_float @@ -3193,9 +3193,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - SocketRocket - - Teleport/common (= 0.5.3) + - Teleport/common (= 0.5.4) - Yoga - - Teleport/common (0.5.3): + - Teleport/common (0.5.4): - boost - DoubleConversion - fast_float @@ -3656,7 +3656,7 @@ SPEC CHECKSUMS: React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c + ReactCodegen: 07eaba6aed08b41813a9acf559012ecb4911b737 ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 @@ -3674,7 +3674,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a - Teleport: 98d5a14f7f1a7b47b0c0541b00c697a59ac2682d + Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 62b854790b..f866723345 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -55,7 +55,7 @@ "react-native-screens": "^4.11.1", "react-native-share": "^12.0.11", "react-native-svg": "^15.12.0", - "react-native-teleport": "^0.5.3", + "react-native-teleport": "^0.5.4", "react-native-video": "^6.16.1", "react-native-worklets": "^0.4.1", "stream-chat-react-native": "link:../../package/native-package", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 3eb7eedb6a..14220e1960 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7729,10 +7729,10 @@ react-native-svg@^15.12.0: css-tree "^1.1.3" warn-once "0.1.1" -react-native-teleport@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.3.tgz#c29fb09f4f0faf1d6d6aa479b1a0792f3a9373b6" - integrity sha512-aWAui0yH0UqqC3Z3wnY3VLXZw30ST4Ikdx9ZzF7YyeVJdhfcDO/JjQb2D3uHnbarttLQojdblQLYoQzeAO07sg== +react-native-teleport@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" + integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== react-native-url-polyfill@^2.0.0: version "2.0.0" From 7e8397705c12f8fa92f20787762cef61aac18c90 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Dec 2025 23:11:15 +0100 Subject: [PATCH 08/30] perf: optimizations --- package/src/components/MessageMenu/MessageActionList.tsx | 2 +- package/src/contexts/overlayContext/OverlayProvider.tsx | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index cff47b7cb1..6b6b2b0002 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -57,7 +57,7 @@ export const MessageActionList = (props: MessageActionListProps) => { const styles = StyleSheet.create({ // TODO: Preliminary height fix, think about this more thoroughly - container: { marginTop: 16, maxHeight: 200 }, + container: { marginTop: 16 }, contentContainer: { flexGrow: 1, paddingHorizontal: 16, diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 2fab931da5..5659ce53f0 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -241,7 +241,7 @@ const OverlayHostLayer = () => { const solvedTop = clamp(anchorY, minTop, maxTop); return solvedTop - anchorY; - }, [rect]); + }); const hostStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -260,10 +260,9 @@ const OverlayHostLayer = () => { }, [isActive, closing]); const topItemStyle = useAnimatedStyle(() => { - if (!topH?.value || !rect || closing) return { height: 0, opacity: 0 }; + if (!topH?.value || !rect || closing) return { height: 0 }; return { height: topH.value.h, - opacity: 1, top: rect.y - topH.value.h, width: topH.value.w, }; @@ -285,11 +284,9 @@ const OverlayHostLayer = () => { }); const bottomItemStyle = useAnimatedStyle(() => { - if (!bottomH?.value || !rect || closing) return { height: 0, opacity: 0 }; - console.log('wtf is going on: ', bottomH.value, rect); + if (!bottomH?.value || !rect || closing) return { height: 0 }; return { height: bottomH.value.h, - opacity: 1, top: rect.y + rect.h, width: bottomH.value.w, }; From e2fd4a06e21c26bc118ae156a8fda2ad2c0b74ed Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Dec 2025 01:30:20 +0100 Subject: [PATCH 09/30] fix: layout bugs with cut content --- package/src/components/Message/Message.tsx | 27 ++++++++++++++++--- .../overlayContext/OverlayProvider.tsx | 4 +-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index b8a4c66a64..2c581d873d 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -344,8 +344,18 @@ const MessageWithContext = (props: MessagePropsWithContext) => { await dismissKeyboard(); try { const layout = await measureInWindow(messageWrapperRef.current); + const rLayout = + layout.h > 300 + ? { + ...layout, + h: 300, + y: layout.y + layout.h - 300, + originalH: layout.h, + originalY: layout.y, + } + : { ...layout, originalH: layout.h, originalY: layout.y }; setShowMessageReactions(showMessageReactions); - openOverlay(message.id, { bottomH, state: { isMyMessage, rect: layout }, topH }); + openOverlay(message.id, { bottomH, state: { isMyMessage, rect: rLayout }, topH }); setSelectedReaction(selectedReaction); } catch (e) { console.error(e); @@ -833,11 +843,12 @@ const MessageWithContext = (props: MessagePropsWithContext) => { {active && state?.rect ? ( - ) : null} + ) : // + null} {active && !closing ? ( { hostName={active ? 'message-overlay' : undefined} style={active && state?.rect ? { width: state.rect.w } : undefined} > - + + + {active && !closing ? ( diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 5659ce53f0..011b9194de 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -244,7 +244,7 @@ const OverlayHostLayer = () => { }); const hostStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? 0 : shiftY.value) : 0; + const target = isActive ? (closing ? -rect.originalH + rect.h : shiftY.value) : 0; return { transform: [ @@ -257,7 +257,7 @@ const OverlayHostLayer = () => { }, ], }; - }, [isActive, closing]); + }, [isActive, closing, rect]); const topItemStyle = useAnimatedStyle(() => { if (!topH?.value || !rect || closing) return { height: 0 }; From 738975f5035a59b1236fab0560521e8e19ca55f2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Dec 2025 19:20:36 +0100 Subject: [PATCH 10/30] feat: add touch responder as well --- package/src/components/Message/Message.tsx | 20 +- .../Message/MessageSimple/MessageContent.tsx | 6 + .../Message/hooks/useCreateMessageContext.ts | 1 + .../overlayContext/OverlayProvider.tsx | 296 ++++++++++++------ 4 files changed, 208 insertions(+), 115 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 2c581d873d..9acdc0ef28 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -253,7 +253,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const [showMessageReactions, setShowMessageReactions] = useState(true); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); const [isEditedMessageOpen, setIsEditedMessageOpen] = useState(false); - const [selectedReaction, setSelectedReaction] = useState(undefined); + // const [selectedReaction, setSelectedReaction] = useState(undefined); const { channel, @@ -344,19 +344,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { await dismissKeyboard(); try { const layout = await measureInWindow(messageWrapperRef.current); - const rLayout = - layout.h > 300 - ? { - ...layout, - h: 300, - y: layout.y + layout.h - 300, - originalH: layout.h, - originalY: layout.y, - } - : { ...layout, originalH: layout.h, originalY: layout.y }; setShowMessageReactions(showMessageReactions); - openOverlay(message.id, { bottomH, state: { isMyMessage, rect: rLayout }, topH }); - setSelectedReaction(selectedReaction); + openOverlay(message.id, { bottomH, state: { isMyMessage, rect: layout }, topH }); + // setSelectedReaction(selectedReaction); } catch (e) { console.error(e); } @@ -782,7 +772,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } : null, otherAttachments: attachments.other, - preventPress, + preventPress: active ? true : preventPress, reactions, readBy, setIsEditedMessageOpen, @@ -843,7 +833,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { {active && state?.rect ? ( diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 120c6301be..bddd71b580 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -386,6 +386,7 @@ const areEqual = ( nextProps: MessageContentPropsWithContext, ) => { const { + preventPress: prevPreventPress, goToMessage: prevGoToMessage, groupStyles: prevGroupStyles, isAttachmentEqual, @@ -397,6 +398,7 @@ const areEqual = ( t: prevT, } = prevProps; const { + preventPress: nextPreventPress, goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isEditedMessageOpen: nextIsEditedMessageOpen, @@ -407,6 +409,10 @@ const areEqual = ( t: nextT, } = nextProps; + if (prevPreventPress !== nextPreventPress) { + return false; + } + const goToMessageChangedAndMatters = nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage; if (goToMessageChangedAndMatters) { diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index b44fa768e5..67b5ce394e 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -113,6 +113,7 @@ export const useCreateMessageContext = ({ showAvatar, showMessageStatus, threadList, + preventPress, ], ); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 011b9194de..149726f08f 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,15 +1,16 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; -import { BackHandler, Pressable, StyleSheet, useWindowDimensions } from 'react-native'; +import { BackHandler, Dimensions, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, clamp, runOnJS, - useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, + withDecay, withTiming, } from 'react-native-reanimated'; @@ -32,6 +33,8 @@ import { TranslationProvider, } from '../translationContext/TranslationContext'; +const { height: SCREEN_H } = Dimensions.get('screen'); + /** * - The highest level of these components is the `OverlayProvider`. The `OverlayProvider` allows users to interact with messages on long press above the underlying views, use the full screen image viewer, and use the `AttachmentPicker` as a keyboard-esk view. * Because these views must exist above all others `OverlayProvider` should wrap your navigation stack as well. Assuming [`React Navigation`](https://reactnavigation.org/) is being used, your highest level navigation stack should be wrapped in the provider: @@ -201,7 +204,7 @@ export const useOverlayController = () => { const OverlayHostLayer = () => { const { state, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); - const { height: screenH } = useWindowDimensions(); + const { height: screenH, width: screenW } = useWindowDimensions(); const isActive = !!id; @@ -213,18 +216,13 @@ const OverlayHostLayer = () => { const backdrop = useSharedValue(0); - useAnimatedReaction( - () => (isActive && !closing ? 1 : 0), - (next, prev) => { - if (next === prev) return; - - cancelAnimation(backdrop); - backdrop.value = withTiming(next, { - duration: next === 1 ? 160 : 140, - }); - }, - [isActive, closing], - ); + useEffect(() => { + const target = isActive && !closing ? 1 : 0; + cancelAnimation(backdrop); + backdrop.value = withTiming(target, { + duration: target === 1 ? 160 : 140, + }); + }, [isActive, closing, backdrop]); const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.value, @@ -241,32 +239,28 @@ const OverlayHostLayer = () => { const solvedTop = clamp(anchorY, minTop, maxTop); return solvedTop - anchorY; - }); + }, [rect]); - const hostStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? -rect.originalH + rect.h : shiftY.value) : 0; + const topItemLayout = useDerivedValue(() => { + if (!topH?.value || !rect || closing) return undefined; return { - transform: [ - { - translateY: withTiming(target, { duration: 150 }, (finished) => { - if (finished && closing) { - runOnJS(finalizeCloseOverlay)(); - } - }), - }, - ], + h: topH.value.h, + w: topH.value.w, + x: isMyMessage ? screenW - rect.x - topH.value.w : rect.x, + y: rect.y - topH.value.h, }; - }, [isActive, closing, rect]); + }, [rect, isMyMessage, closing]); const topItemStyle = useAnimatedStyle(() => { - if (!topH?.value || !rect || closing) return { height: 0 }; + if (!topItemLayout.value) return { height: 0 }; return { - height: topH.value.h, - top: rect.y - topH.value.h, - width: topH.value.w, + height: topItemLayout.value.h, + left: topItemLayout.value.x, + top: topItemLayout.value.y, + width: topItemLayout.value.w, }; - }, [rect, closing, topH]); + }, []); const topItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -281,16 +275,28 @@ const OverlayHostLayer = () => { }, ], }; - }); + }, [isActive, closing]); + + const bottomItemLayout = useDerivedValue(() => { + if (!bottomH?.value || !rect || closing) return undefined; + + return { + h: bottomH.value.h, + w: bottomH.value.w, + x: isMyMessage ? screenW - rect.x - bottomH.value.w : rect.x, + y: rect.y + rect.h, + }; + }, [rect, isMyMessage, closing]); const bottomItemStyle = useAnimatedStyle(() => { - if (!bottomH?.value || !rect || closing) return { height: 0 }; + if (!bottomItemLayout.value) return { height: 0 }; return { - height: bottomH.value.h, - top: rect.y + rect.h, - width: bottomH.value.w, + height: bottomItemLayout.value.h, + left: bottomItemLayout.value.x, + top: bottomItemLayout.value.y, + width: bottomItemLayout.value.w, }; - }, [rect, closing, bottomH]); + }, []); const bottomItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -305,68 +311,158 @@ const OverlayHostLayer = () => { }, ], }; - }); + }, [isActive, closing]); + + const viewportH = useSharedValue(SCREEN_H); + + const scrollY = useSharedValue(0); + + useEffect(() => { + if (isActive) { + scrollY.value = 0; + } + }, [isActive, scrollY]); + + const hostStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? -scrollY.value : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }, (finished) => { + if (finished && closing) { + runOnJS(finalizeCloseOverlay)(); + } + }), + }, + ], + }; + }, [isActive, closing]); + + const contentH = useDerivedValue( + () => + topH?.value && bottomH?.value && rect + ? Math.max( + screenH, + topH.value.h + rect.h + bottomH.value.h + insets.top + insets.bottom + 20, + ) + : 0, + [rect], + ); + const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); + const initialScrollOffset = useSharedValue(0); + + const pan = useMemo( + () => + Gesture.Pan() + .onBegin(() => { + cancelAnimation(scrollY); + initialScrollOffset.value = scrollY.value; + }) + .onUpdate((e) => { + scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); + }) + .onEnd((e) => { + scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); + }), + [initialScrollOffset, maxScroll, scrollY], + ); + + const blockedRect = useSharedValue<{ x: number; y: number; w: number; h: number } | undefined>( + undefined, + ); + + useEffect(() => { + blockedRect.value = rect; + }, [rect, blockedRect]); + + const tap = Gesture.Tap() + .onTouchesDown((e, state) => { + const t = e.allTouches[0]; + if (!t) return; + + const x = t.x; + const y = t.y; + + const rects = [bottomItemLayout, topItemLayout]; + for (let i = 0; i < rects.length; i++) { + const r = rects[i].value; + if ( + !r || + (x >= r.x && x <= r.x + r.w && y >= r.y + shiftY.value && y <= r.y + r.h + shiftY.value) + ) { + state.fail(); + return; + } + } + }) + .onEnd(() => { + runOnJS(closeOverlay)(); + }); + + const contentStyle = useAnimatedStyle(() => ({ + height: contentH.value, + transform: [{ translateY: scrollY.value }], + })); return ( - <> - {isActive ? ( - - ) : null} - - {isActive && !closing ? ( - - ) : null} - - - - - - - - - - - + + + {isActive ? ( + + ) : null} + + + + + + + + + + + + + + ); }; - type Rect = { x: number; y: number; w: number; h: number } | undefined; From 9f78df079bcfadffe21b68903f6dae39fc775bef Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Dec 2025 19:55:22 +0100 Subject: [PATCH 11/30] fix: move root view higher up --- examples/SampleApp/App.tsx | 104 ++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 61d162acbf..b71b8c6a67 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -211,41 +211,43 @@ const App = () => { backgroundColor: streamChatTheme.colors?.white_snow || '#FCFCFC', }} > - - - - + + + - {isConnecting && !chatClient ? ( - - ) : chatClient ? ( - - ) : ( - - )} - - - - + + {isConnecting && !chatClient ? ( + + ) : chatClient ? ( + + ) : ( + + )} + + + + + ); }; @@ -272,25 +274,23 @@ const DrawerNavigatorWrapper: React.FC<{ i18nInstance: Streami18n; }> = ({ chatClient, i18nInstance }) => { return ( - - - - - - - - - - - - + + + + + + + + + + ); }; From 1fa69068560d672595fe85f86aa83fc733672794 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Dec 2025 22:24:08 +0100 Subject: [PATCH 12/30] refactor: move state to shared values --- package/src/components/Message/Message.tsx | 59 ++++--- .../overlayContext/OverlayProvider.tsx | 148 ++++++------------ 2 files changed, 84 insertions(+), 123 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 9acdc0ef28..f1a79cc686 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -5,6 +5,7 @@ import { Keyboard, StyleProp, UIManager, + useWindowDimensions, View, ViewStyle, } from 'react-native'; @@ -339,14 +340,22 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const bottomH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( undefined, ); + const messageH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const [rect, setRect] = useState<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + const { width: screenW } = useWindowDimensions(); - const showMessageOverlay = async (showMessageReactions = false, selectedReaction?: string) => { + const showMessageOverlay = async (showMessageReactions = false) => { await dismissKeyboard(); try { const layout = await measureInWindow(messageWrapperRef.current); + setRect(layout); setShowMessageReactions(showMessageReactions); - openOverlay(message.id, { bottomH, state: { isMyMessage, rect: layout }, topH }); - // setSelectedReaction(selectedReaction); + messageH.value = layout; + openOverlay(message.id, { bottomH, messageH, topH }); } catch (e) { console.error(e); } @@ -685,8 +694,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }; const frozenMessage = useRef(message); - const { state, id, closing } = useOverlayController(); + const { id, closing } = useOverlayController(); + // TODO: refactor const active = id === message.id; const messageContext = useCreateMessageContext({ @@ -830,21 +840,27 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - {active && state?.rect ? ( + {active && rect ? ( ) : // null} - {active && !closing ? ( + {active && !closing && rect ? ( { - const { x, y, width: w, height: h } = e.nativeEvent.layout; - topH.value = { h, w, x, y }; + const { width: w, height: h } = e.nativeEvent.layout; + + topH.value = { + h, + w, + x: isMyMessage ? screenW - rect.x - w : rect.x, + y: rect.y - h, + }; }} > { - - - + - {active && !closing ? ( + {active && !closing && rect ? ( { - const { x, y, width: w, height: h } = e.nativeEvent.layout; - bottomH.value = { h, w, x, y }; + const { width: w, height: h } = e.nativeEvent.layout; + bottomH.value = { + h, + w, + x: isMyMessage ? screenW - rect.x - w : rect.x, + y: rect.y + rect.h, + }; }} > ) }; type OverlayState = { - state: - | { - isMyMessage?: boolean; - rect?: { w: number; h: number; x: number; y: number }; - } - | undefined; topH: Animated.SharedValue | undefined; bottomH: Animated.SharedValue | undefined; + messageH: Animated.SharedValue | undefined; id: string | undefined; closing: boolean; }; @@ -150,12 +145,12 @@ const DefaultState = { bottomH: undefined, closing: false, id: undefined, - state: undefined, topH: undefined, + messageH: undefined, }; -export const openOverlay = (id: string, { state, topH, bottomH }: Partial) => - overlayStore.partialNext({ bottomH, closing: false, id, state, topH }); +export const openOverlay = (id: string, { messageH, topH, bottomH }: Partial) => + overlayStore.partialNext({ bottomH, closing: false, id, messageH, topH }); export const closeOverlay = () => { requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); @@ -193,7 +188,7 @@ const selector = (nextState: OverlayState) => ({ bottomH: nextState.bottomH, closing: nextState.closing, id: nextState.id, - state: nextState.state, + messageH: nextState.messageH, topH: nextState.topH, }); @@ -202,14 +197,12 @@ export const useOverlayController = () => { }; const OverlayHostLayer = () => { - const { state, topH, bottomH, id, closing } = useOverlayController(); + const { messageH, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); - const { height: screenH, width: screenW } = useWindowDimensions(); + const { height: screenH } = useWindowDimensions(); const isActive = !!id; - const { rect, isMyMessage } = state ?? {}; - const padding = 8; const minY = insets.top + padding; const maxY = screenH - insets.bottom - padding; @@ -218,7 +211,6 @@ const OverlayHostLayer = () => { useEffect(() => { const target = isActive && !closing ? 1 : 0; - cancelAnimation(backdrop); backdrop.value = withTiming(target, { duration: target === 1 ? 160 : 140, }); @@ -229,38 +221,28 @@ const OverlayHostLayer = () => { })); const shiftY = useDerivedValue(() => { - if (!rect || !topH?.value || !bottomH?.value) return 0; + if (!messageH?.value || !topH?.value || !bottomH?.value) return 0; - const anchorY = rect.y; - const msgH = rect.h; + const anchorY = messageH.value.y; + const msgH = messageH.value.h; const minTop = minY + topH.value.h; const maxTop = maxY - (msgH + bottomH.value.h); const solvedTop = clamp(anchorY, minTop, maxTop); return solvedTop - anchorY; - }, [rect]); - - const topItemLayout = useDerivedValue(() => { - if (!topH?.value || !rect || closing) return undefined; - - return { - h: topH.value.h, - w: topH.value.w, - x: isMyMessage ? screenW - rect.x - topH.value.w : rect.x, - y: rect.y - topH.value.h, - }; - }, [rect, isMyMessage, closing]); + }); const topItemStyle = useAnimatedStyle(() => { - if (!topItemLayout.value) return { height: 0 }; + if (!topH?.value) return { height: 0 }; return { - height: topItemLayout.value.h, - left: topItemLayout.value.x, - top: topItemLayout.value.y, - width: topItemLayout.value.w, + height: topH.value.h, + left: topH.value.x, + position: 'absolute', + top: topH.value.y, + width: topH.value.w, }; - }, []); + }); const topItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -277,26 +259,16 @@ const OverlayHostLayer = () => { }; }, [isActive, closing]); - const bottomItemLayout = useDerivedValue(() => { - if (!bottomH?.value || !rect || closing) return undefined; - - return { - h: bottomH.value.h, - w: bottomH.value.w, - x: isMyMessage ? screenW - rect.x - bottomH.value.w : rect.x, - y: rect.y + rect.h, - }; - }, [rect, isMyMessage, closing]); - const bottomItemStyle = useAnimatedStyle(() => { - if (!bottomItemLayout.value) return { height: 0 }; + if (!bottomH?.value) return { height: 0 }; return { - height: bottomItemLayout.value.h, - left: bottomItemLayout.value.x, - top: bottomItemLayout.value.y, - width: bottomItemLayout.value.w, + height: bottomH.value.h, + left: bottomH.value.x, + position: 'absolute', + top: bottomH.value.y, + width: bottomH.value.w, }; - }, []); + }); const bottomItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; @@ -324,6 +296,17 @@ const OverlayHostLayer = () => { }, [isActive, scrollY]); const hostStyle = useAnimatedStyle(() => { + if (!messageH?.value) return { height: 0 }; + return { + height: messageH.value.h, + left: messageH.value.x, + position: 'absolute', + top: messageH.value.y, + width: messageH.value.w, + }; + }); + + const hostTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? -scrollY.value : shiftY.value) : 0; return { @@ -339,15 +322,13 @@ const OverlayHostLayer = () => { }; }, [isActive, closing]); - const contentH = useDerivedValue( - () => - topH?.value && bottomH?.value && rect - ? Math.max( - screenH, - topH.value.h + rect.h + bottomH.value.h + insets.top + insets.bottom + 20, - ) - : 0, - [rect], + const contentH = useDerivedValue(() => + topH?.value && bottomH?.value && messageH?.value + ? Math.max( + screenH, + topH.value.h + messageH.value.h + bottomH.value.h + insets.top + insets.bottom + 20, + ) + : 0, ); const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); const initialScrollOffset = useSharedValue(0); @@ -368,14 +349,6 @@ const OverlayHostLayer = () => { [initialScrollOffset, maxScroll, scrollY], ); - const blockedRect = useSharedValue<{ x: number; y: number; w: number; h: number } | undefined>( - undefined, - ); - - useEffect(() => { - blockedRect.value = rect; - }, [rect, blockedRect]); - const tap = Gesture.Tap() .onTouchesDown((e, state) => { const t = e.allTouches[0]; @@ -384,9 +357,9 @@ const OverlayHostLayer = () => { const x = t.x; const y = t.y; - const rects = [bottomItemLayout, topItemLayout]; + const rects = [bottomH, topH]; for (let i = 0; i < rects.length; i++) { - const r = rects[i].value; + const r = rects[i]?.value; if ( !r || (x >= r.x && x <= r.x + r.w && y >= r.y + shiftY.value && y <= r.y + r.h + shiftY.value) @@ -416,42 +389,17 @@ const OverlayHostLayer = () => { ) : null} - + - + Date: Wed, 24 Dec 2025 22:44:57 +0100 Subject: [PATCH 13/30] perf: properly use selected state values --- package/src/components/Message/Message.tsx | 7 +++---- .../contexts/overlayContext/OverlayProvider.tsx | 14 +++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index f1a79cc686..a132e1f4d7 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -23,7 +23,7 @@ import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; import { messageActions as defaultMessageActions } from './utils/messageActions'; -import { closeOverlay, openOverlay, useOverlayController } from '../../contexts'; +import { closeOverlay, openOverlay, useIsOverlayActive } from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -694,10 +694,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }; const frozenMessage = useRef(message); - const { id, closing } = useOverlayController(); + const { active, closing } = useIsOverlayActive(message.id); - // TODO: refactor - const active = id === message.id; + console.log('MSGID: ', message.id); const messageContext = useCreateMessageContext({ actionsEnabled, diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 7c3776d138..713b5e953d 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; import { BackHandler, Dimensions, StyleSheet, useWindowDimensions, View } from 'react-native'; @@ -196,6 +196,18 @@ export const useOverlayController = () => { return useStateStore(overlayStore, selector); }; +const noOpObject = { active: false, closing: false }; + +export const useIsOverlayActive = (messageId: string) => { + const messageOverlaySelector = useCallback( + (nextState: OverlayState) => + nextState.id === messageId ? { active: true, closing: nextState.closing } : noOpObject, + [messageId], + ); + + return useStateStore(overlayStore, messageOverlaySelector); +}; + const OverlayHostLayer = () => { const { messageH, topH, bottomH, id, closing } = useOverlayController(); const insets = useSafeAreaInsets(); From 5c3b3efbe95cbbe2a2bf68dabdccb2dccc2ab7f8 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Dec 2025 23:33:30 +0100 Subject: [PATCH 14/30] feat: add reaction list view --- package/src/components/Message/Message.tsx | 24 +++++++++----- .../MessageMenu/MessageUserReactions.tsx | 31 ++++++++++++------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index a132e1f4d7..6cc06c5e2d 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -312,9 +312,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { updateMessage, readBy, setQuotedMessage, - // MessageUserReactions, - // MessageUserReactionsAvatar, - // MessageUserReactionsItem, + MessageUserReactions, + MessageUserReactionsAvatar, + MessageUserReactionsItem, MessageReactionPicker, MessageActionList, MessageActionListItem, @@ -889,11 +889,19 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }; }} > - + {showMessageReactions ? ( + + ) : ( + + )} ) : null} diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index ee7dfe5da7..c1fc8de798 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -32,6 +32,7 @@ export type MessageUserReactionsProps = Partial< * The selected reaction */ selectedReaction?: string; + reactionFilterEnabled?: boolean; }; const sort: ReactionSortBase = { @@ -61,10 +62,11 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { reactions: propReactions, selectedReaction: propSelectedReaction, supportedReactions: propSupportedReactions, + reactionFilterEnabled = false, } = props; const reactionTypes = Object.keys(message?.reaction_groups ?? {}); const [selectedReaction, setSelectedReaction] = React.useState( - propSelectedReaction ?? reactionTypes[0], + propSelectedReaction ?? (reactionFilterEnabled ? reactionTypes[0] : undefined), ); const { MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, @@ -112,6 +114,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { const { theme: { + colors: { white }, messageMenu: { userReactions: { container, @@ -165,21 +168,27 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { accessibilityLabel='User Reactions on long press message' style={[styles.container, container]} > - - item.type} - renderItem={renderSelectorItem} - /> - + {reactionFilterEnabled ? ( + + item.type} + renderItem={renderSelectorItem} + /> + + ) : null} {!loading ? ( item.id} ListHeaderComponent={renderHeader} From bf5b65028a342082d7b1fc2b86ca27fbc5204074 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 25 Dec 2025 10:53:17 +0100 Subject: [PATCH 15/30] chore: style reactions list --- .../MessageMenu/MessageActionList.tsx | 1 - .../MessageMenu/MessageUserReactions.tsx | 42 +++++++++---------- .../MessageMenu/MessageUserReactionsItem.tsx | 3 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 6b6b2b0002..79dcf84f08 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -56,7 +56,6 @@ export const MessageActionList = (props: MessageActionListProps) => { }; const styles = StyleSheet.create({ - // TODO: Preliminary height fix, think about this more thoroughly container: { marginTop: 16 }, contentContainer: { flexGrow: 1, diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index c1fc8de798..ff3f62b0ab 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -54,6 +54,8 @@ const renderSelectorItem = ({ index, item }: { index: number; item: ReactionSele /> ); +const keyExtractor = (item: Reaction) => item.id; + export const MessageUserReactions = (props: MessageUserReactionsProps) => { const { message, @@ -152,11 +154,6 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { [MessageUserReactionsAvatar, MessageUserReactionsItem, supportedReactions], ); - const renderHeader = useCallback( - () => {t('Message Reactions')}, - [t, reactionsText], - ); - const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({ ...reaction, onSelectReaction, @@ -166,7 +163,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { return ( {reactionFilterEnabled ? ( @@ -181,21 +178,19 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { ) : null} {!loading ? ( - item.id} - ListHeaderComponent={renderHeader} - numColumns={4} - onEndReached={loadNextPage} - renderItem={renderItem} - /> + <> + {t('Message Reactions')} + + ) : null} ); @@ -203,7 +198,9 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { const styles = StyleSheet.create({ container: { - flex: 1, + borderRadius: 16, + marginTop: 16, + maxHeight: 256, }, contentContainer: { flexGrow: 1, @@ -215,6 +212,7 @@ const styles = StyleSheet.create({ }, flatListContainer: { justifyContent: 'center', + paddingHorizontal: 8, }, reactionSelectorContainer: { flexDirection: 'row', diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 3a3ca771c0..181f4cef1a 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -108,7 +108,8 @@ export const MessageUserReactionsItem = ({ const styles = StyleSheet.create({ avatarContainer: { - marginBottom: 8, + marginBottom: 24, + marginHorizontal: 8, }, avatarInnerContainer: { alignSelf: 'center', From aeebd6980a37d87a7dae65de24d37646f31c0428 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Dec 2025 01:16:54 +0100 Subject: [PATCH 16/30] fix: reaction overlay --- package/src/components/Message/Message.tsx | 50 ++++---- .../MessageMenu/MessageUserReactions.tsx | 114 ++++-------------- .../MessageMenu/hooks/useFetchReactions.ts | 41 ++++--- .../overlayContext/OverlayProvider.tsx | 36 +++--- 4 files changed, 88 insertions(+), 153 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 6cc06c5e2d..5851c64c72 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,10 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - findNodeHandle, GestureResponderEvent, Keyboard, StyleProp, - UIManager, useWindowDimensions, View, ViewStyle, @@ -74,13 +72,14 @@ export type TouchableEmitter = export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; -// TODO: Take care of forwards compatibility with new measuring API (0.81+) -const measureInWindow = (node: any): Promise<{ x: number; y: number; w: number; h: number }> => { +const measureInWindow = ( + node: React.RefObject, +): Promise<{ x: number; y: number; w: number; h: number }> => { return new Promise((resolve, reject) => { - const handle = findNodeHandle(node); + const handle = node.current; if (!handle) return reject(new Error('No native handle')); - UIManager.measureInWindow(handle, (x, y, w, h) => resolve({ h, w, x, y })); + handle.measureInWindow((x, y, w, h) => resolve({ h, w, x, y })); }); }; @@ -351,7 +350,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const showMessageOverlay = async (showMessageReactions = false) => { await dismissKeyboard(); try { - const layout = await measureInWindow(messageWrapperRef.current); + const layout = await measureInWindow(messageWrapperRef); setRect(layout); setShowMessageReactions(showMessageReactions); messageH.value = layout; @@ -694,9 +693,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }; const frozenMessage = useRef(message); - const { active, closing } = useIsOverlayActive(message.id); - - console.log('MSGID: ', message.id); + const { active: overlayActive } = useIsOverlayActive(message.id); const messageContext = useCreateMessageContext({ actionsEnabled, @@ -717,7 +714,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, - message: active ? frozenMessage.current : message, + message: overlayActive ? frozenMessage.current : message, messageContentOrder, myMessageTheme: messagesContext.myMessageTheme, onLongPress: (payload) => { @@ -781,7 +778,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } : null, otherAttachments: attachments.other, - preventPress: active ? true : preventPress, + preventPress: overlayActive ? true : preventPress, reactions, readBy, setIsEditedMessageOpen, @@ -793,20 +790,20 @@ const MessageWithContext = (props: MessagePropsWithContext) => { videos: attachments.videos, }); - const prevActive = useRef(active); + const prevActive = useRef(overlayActive); useEffect(() => { - if (!active && prevActive.current && setNativeScrollability) { + if (!overlayActive && prevActive.current && setNativeScrollability) { setNativeScrollability(true); } - prevActive.current = active; - }, [setNativeScrollability, active]); + prevActive.current = overlayActive; + }, [setNativeScrollability, overlayActive]); useEffect(() => { - if (!active) { + if (!overlayActive) { frozenMessage.current = message; } - }, [active, message]); + }, [overlayActive, message]); if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; @@ -839,17 +836,16 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - {active && rect ? ( + {overlayActive && rect ? ( - ) : // - null} - - {active && !closing && rect ? ( + ) : null} + + {overlayActive && rect ? ( { const { width: w, height: h } = e.nativeEvent.layout; @@ -871,13 +867,13 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ) : null} - - {active && !closing && rect ? ( + + {overlayActive && rect ? ( { const { width: w, height: h } = e.nativeEvent.layout; diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index ff3f62b0ab..c8e3c3c743 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Dimensions, StyleSheet, Text, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { ReactionSortBase } from 'stream-chat'; import { useFetchReactions } from './hooks/useFetchReactions'; -import { ReactionButton } from './ReactionButton'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { @@ -15,7 +14,6 @@ import { import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { Reaction } from '../../types/types'; -import { ReactionData } from '../../utils/utils'; export type MessageUserReactionsProps = Partial< Pick< @@ -39,21 +37,6 @@ const sort: ReactionSortBase = { created_at: -1, }; -export type ReactionSelectorItemType = ReactionData & { - onSelectReaction: (type: string) => void; - selectedReaction?: string; -}; - -const renderSelectorItem = ({ index, item }: { index: number; item: ReactionSelectorItemType }) => ( - -); - const keyExtractor = (item: Reaction) => item.id; export const MessageUserReactions = (props: MessageUserReactionsProps) => { @@ -62,14 +45,8 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { MessageUserReactionsAvatar: propMessageUserReactionsAvatar, MessageUserReactionsItem: propMessageUserReactionsItem, reactions: propReactions, - selectedReaction: propSelectedReaction, supportedReactions: propSupportedReactions, - reactionFilterEnabled = false, } = props; - const reactionTypes = Object.keys(message?.reaction_groups ?? {}); - const [selectedReaction, setSelectedReaction] = React.useState( - propSelectedReaction ?? (reactionFilterEnabled ? reactionTypes[0] : undefined), - ); const { MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, MessageUserReactionsItem: contextMessageUserReactionsItem, @@ -80,37 +57,13 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; - const onSelectReaction = (reactionType: string) => { - setSelectedReaction(reactionType); - }; - - useEffect(() => { - if (selectedReaction && reactionTypes.length > 0 && !reactionTypes.includes(selectedReaction)) { - setSelectedReaction(reactionTypes[0]); - } - }, [reactionTypes, selectedReaction]); - - const messageReactions = useMemo( - () => - reactionTypes.reduce((acc, reaction) => { - const reactionData = supportedReactions?.find( - (supportedReaction) => supportedReaction.type === reaction, - ); - if (reactionData) { - acc.push(reactionData); - } - return acc; - }, []), - [reactionTypes, supportedReactions], - ); - const { loading, loadNextPage, reactions: fetchedReactions, } = useFetchReactions({ message, - reactionType: selectedReaction, + reactionType: undefined, sort, }); @@ -118,14 +71,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { theme: { colors: { white }, messageMenu: { - userReactions: { - container, - contentContainer, - flatlistColumnContainer, - flatlistContainer, - reactionSelectorContainer, - reactionsText, - }, + userReactions: { container, flatlistColumnContainer, flatlistContainer, reactionsText }, }, }, } = useTheme(); @@ -154,46 +100,26 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { [MessageUserReactionsAvatar, MessageUserReactionsItem, supportedReactions], ); - const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({ - ...reaction, - onSelectReaction, - selectedReaction, - })); - - return ( + return !loading ? ( - {reactionFilterEnabled ? ( - - item.type} - renderItem={renderSelectorItem} - /> - - ) : null} - - {!loading ? ( - <> - {t('Message Reactions')} - - - ) : null} + <> + {t('Message Reactions')} + + - ); + ) : null; }; const styles = StyleSheet.create({ @@ -201,6 +127,7 @@ const styles = StyleSheet.create({ borderRadius: 16, marginTop: 16, maxHeight: 256, + width: Dimensions.get('window').width * 0.9, }, contentContainer: { flexGrow: 1, @@ -222,6 +149,7 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: 'bold', marginVertical: 16, + paddingHorizontal: 8, textAlign: 'center', }, }); diff --git a/package/src/components/MessageMenu/hooks/useFetchReactions.ts b/package/src/components/MessageMenu/hooks/useFetchReactions.ts index f67630a758..96683a101a 100644 --- a/package/src/components/MessageMenu/hooks/useFetchReactions.ts +++ b/package/src/components/MessageMenu/hooks/useFetchReactions.ts @@ -41,9 +41,19 @@ export const useFetchReactions = ({ if (response) { setNext(response.next); - setReactions((prevReactions) => - next ? [...prevReactions, ...response.reactions] : response.reactions, - ); + setReactions((prevReactions) => { + if ( + prevReactions.length !== response.reactions.length || + !prevReactions.every( + (r, index) => + r.user_id === response.reactions[index].user_id && + r.type === response.reactions[index].type, + ) + ) { + return next ? [...prevReactions, ...response.reactions] : response.reactions; + } + return prevReactions; + }); setLoading(false); } } catch (error) { @@ -82,7 +92,7 @@ export const useFetchReactions = ({ client.on('reaction.new', (event) => { const { reaction } = event; - if (reaction && reaction.type === reactionType) { + if (reaction && (reactionType ? reactionType === reaction.type : true)) { setReactions((prevReactions) => [reaction, ...prevReactions]); } }), @@ -92,14 +102,11 @@ export const useFetchReactions = ({ client.on('reaction.updated', (event) => { const { reaction } = event; - if (reaction) { - if (reaction.type === reactionType) { - setReactions((prevReactions) => [reaction, ...prevReactions]); - } else { - setReactions((prevReactions) => - prevReactions.filter((r) => r.user_id !== reaction.user_id), - ); - } + if (reaction && (reactionType ? reactionType === reaction.type : true)) { + setReactions((prevReactions) => [ + reaction, + ...prevReactions.filter((r) => r.user_id !== reaction.user_id), + ]); } }), ); @@ -108,10 +115,12 @@ export const useFetchReactions = ({ client.on('reaction.deleted', (event) => { const { reaction } = event; - if (reaction && reaction.type === reactionType) { - setReactions((prevReactions) => - prevReactions.filter((r) => r.user_id !== reaction.user_id), - ); + if (reaction && (reactionType ? reactionType === reaction.type : true)) { + setReactions((prevReactions) => { + return prevReactions.filter( + (r) => r.user_id !== reaction.user_id && r.type !== reaction.type, + ); + }); } }), ); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 713b5e953d..2a5c4ca433 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,6 +1,14 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; -import { BackHandler, Dimensions, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { + BackHandler, + Dimensions, + Platform, + StatusBar, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -145,8 +153,8 @@ const DefaultState = { bottomH: undefined, closing: false, id: undefined, - topH: undefined, messageH: undefined, + topH: undefined, }; export const openOverlay = (id: string, { messageH, topH, bottomH }: Partial) => @@ -213,18 +221,22 @@ const OverlayHostLayer = () => { const insets = useSafeAreaInsets(); const { height: screenH } = useWindowDimensions(); + // TODO: Think about this more thoroughly, this is just a patch fix for now. + const topInset = Platform.OS === 'ios' ? insets.top : (StatusBar.currentHeight ?? 0) * 2; + const bottomInset = Platform.OS === 'ios' ? insets.bottom : (StatusBar.currentHeight ?? 0) * 2; + const isActive = !!id; const padding = 8; - const minY = insets.top + padding; - const maxY = screenH - insets.bottom - padding; + const minY = topInset + padding; + const maxY = screenH - bottomInset - padding; const backdrop = useSharedValue(0); useEffect(() => { const target = isActive && !closing ? 1 : 0; backdrop.value = withTiming(target, { - duration: target === 1 ? 160 : 140, + duration: 150, }); }, [isActive, closing, backdrop]); @@ -338,7 +350,7 @@ const OverlayHostLayer = () => { topH?.value && bottomH?.value && messageH?.value ? Math.max( screenH, - topH.value.h + messageH.value.h + bottomH.value.h + insets.top + insets.bottom + 20, + topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, ) : 0, ); @@ -407,17 +419,7 @@ const OverlayHostLayer = () => { - + From cef32973c65aad4c2c296be93262040db1d6bf06 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Dec 2025 02:22:51 +0100 Subject: [PATCH 17/30] chore: styling --- .../MessageMenu/MessageActionList.tsx | 10 ++++++-- .../MessageMenu/MessageReactionPicker.tsx | 9 +++++++- .../components/MessageMenu/ReactionButton.tsx | 15 +++++------- .../overlayContext/OverlayProvider.tsx | 23 +++++++++++++++++-- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 79dcf84f08..14dbd05024 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -56,9 +56,15 @@ export const MessageActionList = (props: MessageActionListProps) => { }; const styles = StyleSheet.create({ - container: { marginTop: 16 }, + container: { + borderRadius: 16, + marginTop: 6, + }, contentContainer: { + borderRadius: 16, flexGrow: 1, - paddingHorizontal: 16, + minWidth: 250, + paddingHorizontal: 12, + paddingVertical: 4, }, }); diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index a4f7fcd444..5e1e03879b 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -52,6 +52,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); const { theme: { + colors: { white }, messageMenu: { reactionPicker: { container, contentContainer }, }, @@ -86,7 +87,11 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { style={[styles.container, container]} > item.type} @@ -101,8 +106,10 @@ const styles = StyleSheet.create({ alignSelf: 'stretch', }, contentContainer: { + borderRadius: 20, flexGrow: 1, justifyContent: 'space-around', marginVertical: 8, + paddingHorizontal: 5, }, }); diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx index b3d4ad8f92..61659e5ab5 100644 --- a/package/src/components/MessageMenu/ReactionButton.tsx +++ b/package/src/components/MessageMenu/ReactionButton.tsx @@ -29,14 +29,9 @@ export const ReactionButton = (props: ReactionButtonProps) => { const { Icon, onPress, selected, type } = props; const { theme: { - colors: { light_blue, accent_blue, white, grey }, + colors: { accent_blue, grey }, messageMenu: { - reactionButton: { - filledBackgroundColor = light_blue, - filledColor = accent_blue, - unfilledBackgroundColor = white, - unfilledColor = grey, - }, + reactionButton: { filledColor = accent_blue, unfilledColor = grey }, reactionPicker: { buttonContainer, reactionIconSize }, }, }, @@ -54,7 +49,7 @@ export const ReactionButton = (props: ReactionButtonProps) => { onPress={onPressHandler} style={({ pressed }) => [ styles.reactionButton, - { backgroundColor: pressed || selected ? filledBackgroundColor : unfilledBackgroundColor }, + { backgroundColor: 'transparent', opacity: pressed ? 0.5 : 1 }, buttonContainer, ]} > @@ -72,6 +67,8 @@ const styles = StyleSheet.create({ alignItems: 'center', borderRadius: 8, justifyContent: 'center', - padding: 8, + overflow: 'hidden', + paddingVertical: 8, + paddingHorizontal: 3, }, }); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 2a5c4ca433..9761265589 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -413,13 +413,13 @@ const OverlayHostLayer = () => { ) : null} - + - + @@ -427,4 +427,23 @@ const OverlayHostLayer = () => { ); }; + +const styles = StyleSheet.create({ + shadow3: { + overflow: 'visible', + ...Platform.select({ + ios: { + shadowColor: 'white', + shadowOpacity: 0.4, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + }, + android: { + elevation: 3, + // helps on newer Android (API 28+) to tint elevation shadow + shadowColor: '#000000', + }, + }), + }, +}); type Rect = { x: number; y: number; w: number; h: number } | undefined; From 05473bca1f463831bb9f586dd187c127e5d0f0ce Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Dec 2025 18:27:14 +0100 Subject: [PATCH 18/30] feat: implement emoji picker --- package/src/components/Channel/Channel.tsx | 11 +- .../MessageMenu/MessageReactionPicker.tsx | 106 +++++++- package/src/components/MessageMenu/emojis.ts | 163 ++++++++++++ .../UIComponents/BottomSheetModal.tsx | 242 +++++++++++------- package/src/utils/utils.ts | 1 + 5 files changed, 413 insertions(+), 110 deletions(-) create mode 100644 package/src/components/MessageMenu/emojis.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 723cd9b907..5474a4f489 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -204,10 +204,14 @@ import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator'; import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer'; import { UnreadMessagesNotification as UnreadMessagesNotificationDefault } from '../MessageList/UnreadMessagesNotification'; +import { emojis } from '../MessageMenu/emojis'; import { MessageActionList as MessageActionListDefault } from '../MessageMenu/MessageActionList'; import { MessageActionListItem as MessageActionListItemDefault } from '../MessageMenu/MessageActionListItem'; import { MessageMenu as MessageMenuDefault } from '../MessageMenu/MessageMenu'; -import { MessageReactionPicker as MessageReactionPickerDefault } from '../MessageMenu/MessageReactionPicker'; +import { + MessageReactionPicker as MessageReactionPickerDefault, + toUnicodeScalarString, +} from '../MessageMenu/MessageReactionPicker'; import { MessageUserReactions as MessageUserReactionsDefault } from '../MessageMenu/MessageUserReactions'; import { MessageUserReactionsAvatar as MessageUserReactionsAvatarDefault } from '../MessageMenu/MessageUserReactionsAvatar'; import { MessageUserReactionsItem as MessageUserReactionsItemDefault } from '../MessageMenu/MessageUserReactionsItem'; @@ -247,6 +251,11 @@ export const reactionData: ReactionData[] = [ Icon: WutReaction, type: 'wow', }, + ...emojis.map((emoji) => ({ + Icon: () => {emoji}, + isUnicode: true, + type: toUnicodeScalarString(emoji), + })), ]; /** diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 5e1e03879b..9fef0a940d 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -1,6 +1,8 @@ -import React from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import { emojis } from './emojis'; import { ReactionButton } from './ReactionButton'; import { scheduleActionOnClose } from '../../contexts'; @@ -12,9 +14,12 @@ import { import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; +import { Attach } from '../../icons'; import { NativeHandlers } from '../../native'; import { ReactionData } from '../../utils/utils'; +import { BottomSheetModal } from '../UIComponents'; export type MessageReactionPickerProps = Pick & Pick & { @@ -29,6 +34,8 @@ export type ReactionPickerItemType = ReactionData & { ownReactionTypes: string[]; }; +const keyExtractor = (item: ReactionPickerItemType) => item.type; + const renderItem = ({ index, item }: { index: number; item: ReactionPickerItemType }) => ( ); +const emojiKeyExtractor = (item: string) => `unicode-${item}`; + +// TODO: V9: Move this to utils and also clean it up a bit. +// This was done quickly and in a bit of a hurry. +export const toUnicodeScalarString = (emoji: string): string => { + const out: number[] = []; + for (const ch of emoji) out.push(ch.codePointAt(0)!); + return out.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`).join('-'); +}; + /** * MessageReactionPicker - A high level component which implements all the logic required for a message overlay reaction list */ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { + const [emojiViewerOpened, setEmojiViewerOpened] = React.useState(null); const { dismissOverlay, handleReaction, @@ -52,7 +70,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); const { theme: { - colors: { white }, + colors: { white, grey }, messageMenu: { reactionPicker: { container, contentContainer }, }, @@ -62,25 +80,63 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const supportedReactions = propSupportedReactions || contextSupportedReactions; - const onSelectReaction = (type: string) => { + const onSelectReaction = useStableCallback((type: string) => { NativeHandlers.triggerHaptic('impactLight'); + setEmojiViewerOpened(false); dismissOverlay(); if (handleReaction) { scheduleActionOnClose(() => handleReaction(type)); } - }; + }); + + const onOpenEmojiViewer = useStableCallback(() => { + NativeHandlers.triggerHaptic('impactLight'); + setEmojiViewerOpened(true); + }); + + const EmojiViewerButton = useCallback( + () => ( + + + + ), + [grey, onOpenEmojiViewer], + ); + + const reactions: ReactionPickerItemType[] = useMemo( + () => + supportedReactions + ?.filter((reaction) => !reaction.isUnicode) + ?.map((reaction) => ({ + ...reaction, + onSelectReaction, + ownReactionTypes, + })) ?? [], + [onSelectReaction, ownReactionTypes, supportedReactions], + ); + + const selectEmoji = useStableCallback((emoji: string) => { + const scalarString = toUnicodeScalarString(emoji); + onSelectReaction(scalarString); + }); + + const closeModal = useStableCallback(() => setEmojiViewerOpened(false)); + + const renderEmoji = useCallback( + ({ item }: { item: string }) => { + return ( + selectEmoji(item)} style={styles.emojiContainer}> + {item} + + ); + }, + [selectEmoji], + ); if (!own_capabilities.sendReaction) { return null; } - const reactions: ReactionPickerItemType[] = - supportedReactions?.map((reaction) => ({ - ...reaction, - onSelectReaction, - ownReactionTypes, - })) ?? []; - return ( { ]} data={reactions} horizontal - keyExtractor={(item) => item.type} + keyExtractor={keyExtractor} + ListFooterComponent={EmojiViewerButton} renderItem={renderItem} /> + {emojiViewerOpened ? ( + + + + ) : null} ); }; const styles = StyleSheet.create({ + bottomSheet: { height: 300 }, + bottomSheetColumnWrapper: { + alignItems: 'center', + justifyContent: 'space-evenly', + width: '100%', + }, + bottomSheetContentContainer: { paddingVertical: 16 }, container: { alignSelf: 'stretch', }, @@ -112,4 +189,7 @@ const styles = StyleSheet.create({ marginVertical: 8, paddingHorizontal: 5, }, + emojiContainer: { height: 30 }, + emojiText: { fontSize: 20, padding: 2 }, + emojiViewerButton: { alignItems: 'flex-start', justifyContent: 'flex-start', paddingTop: 4 }, }); diff --git a/package/src/components/MessageMenu/emojis.ts b/package/src/components/MessageMenu/emojis.ts new file mode 100644 index 0000000000..7693c0c474 --- /dev/null +++ b/package/src/components/MessageMenu/emojis.ts @@ -0,0 +1,163 @@ +// TODO: V9: This should really come from emoji mart or something else. +// No reason to pollute the SDK like this. It'll have to do for now though, +// as for the purposes of a PoC it's fine. +export const emojis = [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '🙃', + '😉', + '😊', + '😇', + '🥰', + '😍', + '🤩', + '😘', + '😗', + '😚', + '😙', + '😋', + '😛', + '😜', + '🤪', + '😝', + '🤑', + '🤗', + '🤭', + '🤫', + '🤔', + '🤐', + '🤨', + '😐', + '😑', + '😶', + '😶‍🌫️', + '😏', + '😒', + '🙄', + '😬', + '🤥', + '😌', + '😔', + '😪', + '🤤', + '😴', + '😷', + '🤒', + '🤕', + '🤢', + '🤮', + '🤧', + '🥵', + '🥶', + '🥴', + '😵‍💫', + '🤯', + '🤠', + '🥳', + '😎', + '🤓', + '🧐', + '😕', + '😟', + '🙁', + '☹️', + '😮', + '😯', + '😲', + '😳', + '🥺', + '😦', + '😧', + '😨', + '😰', + '😥', + '😢', + '😭', + '😱', + '😖', + '😣', + '😞', + '😓', + '😩', + '😫', + '🥱', + '😤', + '😡', + '😠', + '🤬', + '😈', + '👿', + '💀', + '☠️', + '💩', + '🤡', + '👹', + '👺', + '👻', + '👽', + '👾', + '🤖', + '🎃', + '😺', + '😸', + '😹', + '😻', + '😼', + '😽', + '🙀', + '😿', + '😾', + '👍', + '👎', + '👌', + '🤌', + '🤏', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👆', + '👇', + '☝️', + '✋', + '🤚', + '🖐️', + '🖖', + '👋', + '🤝', + '🙏', + '💪', + '👣', + '👀', + '🧠', + '🫶', + '💋', + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💔', + '❣️', + '💕', + '💞', + '💓', + '💗', + '💖', + '💘', + '💝', +]; diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 71bdfe4888..ed93a57310 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -1,6 +1,5 @@ -import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { - Animated, Keyboard, KeyboardEvent, Modal, @@ -9,42 +8,30 @@ import { useWindowDimensions, View, } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, - GestureUpdateEvent, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; - -import { runOnJS } from 'react-native-reanimated'; - -import { PortalHost } from 'react-native-teleport'; +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, + Easing, + FadeIn, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; export type BottomSheetModalProps = { - /** - * Function to call when the modal is closed. - * @returns void - */ onClose: () => void; - /** - * Whether the modal is visible. - */ visible: boolean; - /** - * The height of the modal. - */ height?: number; }; -/** - * A modal that slides up from the bottom of the screen. - */ export const BottomSheetModal = (props: PropsWithChildren) => { const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const { children, height = windowHeight / 2, onClose, visible } = props; + const { theme: { bottomSheetModal: { container, contentContainer, handle, overlay: overlayTheme, wrapper }, @@ -52,96 +39,163 @@ export const BottomSheetModal = (props: PropsWithChildren }, } = useTheme(); - const translateY = useMemo(() => new Animated.Value(height), [height]); + const translateY = useSharedValue(height); + const keyboardOffset = useSharedValue(0); + const isOpen = useSharedValue(false); - const openAnimation = useMemo( - () => - Animated.timing(translateY, { - duration: 200, - toValue: 0, - useNativeDriver: true, - }), - [translateY], - ); + const panStartY = useSharedValue(0); - const closeAnimation = Animated.timing(translateY, { - duration: 50, - toValue: height, - useNativeDriver: true, - }); + const [renderContent, setRenderContent] = useState(false); - const handleDismiss = () => { - closeAnimation.start(() => onClose()); - }; + const close = useStableCallback(() => { + // close always goes fully off-screen and only then notifies JS + setRenderContent(false); + isOpen.value = false; + cancelAnimation(translateY); + translateY.value = withTiming(height, { duration: 200 }, (finished) => { + if (finished) runOnJS(onClose)(); + }); + }); + + // Open animation: keep it simple (setting shared values from JS still runs on UI) + useLayoutEffect(() => { + if (!visible) return; + + isOpen.value = true; + keyboardOffset.value = 0; + + // clean up any leftover animations + cancelAnimation(translateY); + // kick animation on UI thread so JS congestion can't delay the start; only render content + // once the animation finishes + translateY.value = height; + + translateY.value = withTiming( + keyboardOffset.value, + { duration: 200, easing: Easing.inOut(Easing.ease) }, + (finished) => { + if (finished) runOnJS(setRenderContent)(true); + }, + ); + }, [visible, height, isOpen, keyboardOffset, translateY]); + + // if `visible` gets hard changed, we force a cleanup useEffect(() => { - if (visible) { - openAnimation.start(); + if (visible) return; + + setRenderContent(false); + + isOpen.value = false; + keyboardOffset.value = 0; + + cancelAnimation(translateY); + translateY.value = height; + }, [visible, height, isOpen, keyboardOffset, translateY]); + + const keyboardDidShow = useStableCallback((event: KeyboardEvent) => { + const offset = -event.endCoordinates.height; + keyboardOffset.value = offset; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(offset, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); } - }, [visible, openAnimation]); + }); + + const keyboardDidHide = useStableCallback(() => { + keyboardOffset.value = 0; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(0, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); + } + }); useEffect(() => { + if (!visible) return; + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', keyboardDidShow); const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', keyboardDidHide); - return () => { keyboardDidShowListener.remove(); keyboardDidHideListener.remove(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [visible, keyboardDidHide, keyboardDidShow]); - const [realVisible, setRealVisible] = useState(visible); + const sheetAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); - useEffect(() => { - if (visible) { - setRealVisible(visible); - } - }, [visible]); - - const keyboardDidShow = (event: KeyboardEvent) => { - Animated.timing(translateY, { - duration: 250, - toValue: -event.endCoordinates.height, - useNativeDriver: true, - }).start(); - }; - - const keyboardDidHide = () => { - Animated.timing(translateY, { - duration: 250, - toValue: 0, - useNativeDriver: true, - }).start(); - }; - - const handleUpdate = (event: GestureUpdateEvent) => { - const translationY = Math.max(event.translationY, 0); - translateY.setValue(translationY); - }; - - const gesture = Gesture.Pan() - .onUpdate((event) => { - runOnJS(handleUpdate)(event); - }) - .onEnd((event) => { - if (event.velocityY > 500 || event.translationY > height / 2) { - runOnJS(handleDismiss)(); - } else { - runOnJS(openAnimation.start)(); - } - }); + const gesture = useMemo( + () => + Gesture.Pan() + .onBegin(() => { + cancelAnimation(translateY); + panStartY.value = translateY.value; + }) + .onUpdate((event) => { + const minY = keyboardOffset.value; + translateY.value = Math.max(panStartY.value + event.translationY, minY); + }) + .onEnd((event) => { + const openY = keyboardOffset.value; + const draggedDown = Math.max(translateY.value - openY, 0); + const shouldClose = event.velocityY > 500 || draggedDown > height / 2; + + cancelAnimation(translateY); + + if (shouldClose) { + isOpen.value = false; + translateY.value = withTiming(height, { duration: 100 }, (finished) => { + if (finished) runOnJS(onClose)(); + }); + } else { + isOpen.value = true; + translateY.value = withTiming(openY, { + duration: 200, + easing: Easing.inOut(Easing.ease), + }); + } + }), + [height, isOpen, keyboardOffset, onClose, panStartY, translateY], + ); return ( - + - + - {/**/} + + + + + {renderContent ? ( + + {children} + + ) : null} + + @@ -155,10 +209,6 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 16, borderTopRightRadius: 16, }, - content: { - flex: 1, - padding: 16, - }, contentContainer: { flex: 1, marginTop: 8, diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 9d26951c21..ebab5759fe 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -15,6 +15,7 @@ import { ValueOf } from '../types/types'; export type ReactionData = { Icon: React.ComponentType; type: string; + isUnicode?: boolean; }; export const FileState = Object.freeze({ From ee66054dd5aa28eb446a18c40e4735af129f4157 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Dec 2025 18:40:36 +0100 Subject: [PATCH 19/30] chore: cleanup --- .../Message/MessageOverlayBundle.tsx | 225 ------------------ .../components/MessageMenu/MessageMenu.tsx | 40 ++-- .../components/MessageMenu/ReactionButton.tsx | 2 +- .../overlayContext/OverlayProvider.tsx | 12 +- 4 files changed, 23 insertions(+), 256 deletions(-) delete mode 100644 package/src/components/Message/MessageOverlayBundle.tsx diff --git a/package/src/components/Message/MessageOverlayBundle.tsx b/package/src/components/Message/MessageOverlayBundle.tsx deleted file mode 100644 index 5515d745e1..0000000000 --- a/package/src/components/Message/MessageOverlayBundle.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useMemo } from 'react'; -import { LayoutAnimation, Pressable, useWindowDimensions, View } from 'react-native'; -import Animated, { - Easing, - FadeIn, - FadeOut, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - withSpring, - withTiming, - ZoomIn, - ZoomOut, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -const clamp = (v: number, min: number, max: number) => { - 'worklet'; - return Math.max(min, Math.min(v, max)); -}; - -export function MessageOverlayBundle({ - active, - rect, - isMyMessage, - dismiss, - overlayColor, - children, // MessageSimple -}: { - active: boolean; - rect: { x: number; y: number; w: number; h: number } | null; - isMyMessage: boolean; - dismiss: () => void; - overlayColor: string; - children: React.ReactNode; -}) { - const insets = useSafeAreaInsets(); - const { height: screenH } = useWindowDimensions(); - - const topH = useSharedValue(0); - const bottomH = useSharedValue(0); - const progress = useSharedValue(0); - - React.useEffect(() => { - progress.value = withTiming(active ? 1 : 0, { - duration: 1250, - easing: Easing.out(Easing.cubic), - }); - }, [active, progress]); - - const padding = 8; - const minY = insets.top + padding; - const maxY = screenH - insets.bottom - padding; - - const shiftY = useDerivedValue(() => { - if (!active || !rect) return 0; - - const anchorY = rect.y; - const msgH = rect.h; - - const minTop = minY + topH.value; - const maxTop = maxY - (msgH + bottomH.value); - - // you said: assume it can fit - const solvedTop = clamp(anchorY, minTop, maxTop); - return solvedTop - anchorY; - }); - - const shiftYStatic = useMemo(() => { - if (!active || !rect) return 0; - - const anchorY = rect.y; - const msgH = rect.h; - - const minTop = minY + topH.value; - const maxTop = maxY - (msgH + bottomH.value); - - // you said: assume it can fit - const solvedTop = clamp(anchorY, minTop, maxTop); - return solvedTop - anchorY; - }, [active, bottomH.value, maxY, minY, rect, topH.value]); - - const [shift, setShift] = React.useState(0); - - React.useEffect(() => { - if (!active || !rect) { - setShift(0); - return; - } - - const anchorY = rect.y; - const msgH = rect.h; - - const minTop = minY + topH.value; - const maxTop = maxY - (msgH + bottomH.value); - - // you said: assume it can fit - const solvedTop = clamp(anchorY, minTop, maxTop); - const nextShift = solvedTop - anchorY; - - // animate the layout change - LayoutAnimation.configureNext({ - duration: 220, - update: { - type: LayoutAnimation.Types.spring, - springDamping: 0.85, - }, - }); - - setShift(nextShift); - }, [active, bottomH.value, maxY, minY, rect, topH.value]); - - const backdropStyle = useAnimatedStyle(() => ({ - opacity: progress.value, - })); - - const wrapperStyle = useAnimatedStyle(() => { - // if (!rect) return {}; - // animate only the container shift - return { - transform: [ - { - translateY: withTiming(active ? shiftY.value : -shiftY.value, { - duration: 150, - }), - }, - ], - }; - }); - - // if (!rect) { - // // inactive: render message inline (portal hostName undefined makes it inline) - // // IMPORTANT: still return children so MessageSimple exists and is stable. - // return <>{children}; - // } - - console.log('SHIFT: ', shift); - - return ( - <> - {/* When inactive, this is opacity 0 and basically inert */} - {active ? ( - - ) : null} - - {/* click outside only when active */} - {active ? ( - - ) : null} - - {/* The moving container (only one, only for active message) */} - - {/* Reactions positioned above the message; height measured */} - {active ? ( - { - topH.value = e.nativeEvent.layout.height; - }} - style={{ position: 'absolute', left: 0, right: 0, bottom: rect.h }} - > - - - ) : null} - - {/* The message itself: NOT animated; it just rides along as a child */} - {children} - - {/* Actions below; height measured */} - {active ? ( - { - bottomH.value = e.nativeEvent.layout.height; - }} - style={{ position: 'absolute', left: 0, right: 0, top: rect.h }} - > - - - ) : null} - - - ); -} - -const ReactionList = () => ; -const MessageActions = () => ( - -); - -const ANIMATED_DURATION = 1000; diff --git a/package/src/components/MessageMenu/MessageMenu.tsx b/package/src/components/MessageMenu/MessageMenu.tsx index 2ac2c4c8c1..28d1484169 100644 --- a/package/src/components/MessageMenu/MessageMenu.tsx +++ b/package/src/components/MessageMenu/MessageMenu.tsx @@ -4,14 +4,8 @@ import { useWindowDimensions } from 'react-native'; import { MessageActionType } from './MessageActionListItem'; -import { - MessageContextValue, - useMessageContext, -} from '../../contexts/messageContext/MessageContext'; -import { - MessagesContextValue, - useMessagesContext, -} from '../../contexts/messagesContext/MessagesContext'; +import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; +import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { BottomSheetModal } from '../UIComponents/BottomSheetModal'; @@ -65,22 +59,24 @@ export type MessageMenuProps = PropsWithChildren< } >; +// TODO: V9: Either remove this or refactor it so that it's useful again, as its logic +// is offloaded to other components now. export const MessageMenu = (props: MessageMenuProps) => { const { dismissOverlay, - handleReaction, - message: propMessage, - MessageActionList: propMessageActionList, - MessageActionListItem: propMessageActionListItem, - messageActions, - MessageReactionPicker: propMessageReactionPicker, - MessageUserReactions: propMessageUserReactions, - MessageUserReactionsAvatar: propMessageUserReactionsAvatar, - MessageUserReactionsItem: propMessageUserReactionsItem, - selectedReaction, + // handleReaction, + // message: propMessage, + // MessageActionList: propMessageActionList, + // MessageActionListItem: propMessageActionListItem, + // messageActions, + // MessageReactionPicker: propMessageReactionPicker, + // MessageUserReactions: propMessageUserReactions, + // MessageUserReactionsAvatar: propMessageUserReactionsAvatar, + // MessageUserReactionsItem: propMessageUserReactionsItem, + // selectedReaction, showMessageReactions, visible, - layout, + // layout, children, } = props; const { height } = useWindowDimensions(); @@ -112,11 +108,7 @@ export const MessageMenu = (props: MessageMenuProps) => { return ( Date: Fri, 26 Dec 2025 19:30:50 +0100 Subject: [PATCH 20/30] fix: wrong hit box coordinate systems --- .../overlayContext/OverlayProvider.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 32f669e7e4..2246ca33c5 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -350,7 +350,7 @@ const OverlayHostLayer = () => { topH?.value && bottomH?.value && messageH?.value ? Math.max( screenH, - topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, + topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset, ) : 0, ); @@ -381,13 +381,26 @@ const OverlayHostLayer = () => { const x = t.x; const y = t.y; - const rects = [bottomH, topH]; - for (let i = 0; i < rects.length; i++) { - const r = rects[i]?.value; - if ( - !r || - (x >= r.x && x <= r.x + r.w && y >= r.y + shiftY.value && y <= r.y + r.h + shiftY.value) - ) { + const yShift = shiftY.value; // overlay shift + const yParent = scrollY.value; // parent content translation + + const top = topH?.value; + if (top) { + // top rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const topY = top.y + yShift + yParent; + if (x >= top.x && x <= top.x + top.w && y >= topY && y <= topY + top.h) { + state.fail(); + return; + } + } + + const bot = bottomH?.value; + if (bot) { + // bottom rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const botY = bot.y + yShift + yParent; + if (x >= bot.x && x <= bot.x + bot.w && y >= botY && y <= botY + bot.h) { state.fail(); return; } From b5050ac3210f325dd4a4471058743f5a1c416081 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Dec 2025 23:58:41 +0100 Subject: [PATCH 21/30] fix: bugfixes --- .../main/java/com/sampleapp/MainActivity.kt | 82 ++++++++++--------- package/src/components/Message/Message.tsx | 5 +- .../overlayContext/OverlayProvider.tsx | 8 +- 3 files changed, 52 insertions(+), 43 deletions(-) diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt index f3ca98b78b..5e1a72a2c8 100644 --- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt +++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt @@ -1,52 +1,54 @@ package com.sampleapp -import com.facebook.react.ReactActivity -import com.facebook.react.ReactActivityDelegate -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled -import com.facebook.react.defaults.DefaultReactActivityDelegate - -import android.os.Bundle import android.os.Build +import android.os.Bundle import android.view.View +import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(null) - - if (Build.VERSION.SDK_INT >= 35) { - val rootView = findViewById(android.R.id.content) - - - ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() - or WindowInsetsCompat.Type.displayCutout() - or WindowInsetsCompat.Type.ime() // adding the ime's height - ) - rootView.updatePadding( - left = bars.left, - top = bars.top, - right = bars.right, - bottom = bars.bottom - ) - WindowInsetsCompat.CONSUMED - } - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(null) + + if (Build.VERSION.SDK_INT >= 35) { + val rootView = findViewById(android.R.id.content) + + // Keep the original padding so we can restore it when IME closes + val initial = Insets.of( + rootView.paddingLeft, + rootView.paddingTop, + rootView.paddingRight, + rootView.paddingBottom + ) + + ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> + // Only apply IME (keyboard) to avoid breaking safe-area libs or double-padding system bars. + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + + v.updatePadding( + left = initial.left, + top = initial.top, + right = initial.right, + bottom = initial.bottom + ime.bottom + ) + + // IMPORTANT: don't consume — allow insets to propagate to RN & safe-area-context + insets } - /** - * Returns the name of the main component registered from JavaScript. This is used to schedule - * rendering of the component. - */ - override fun getMainComponentName(): String = "SampleApp" - - /** - * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] - */ - override fun createReactActivityDelegate(): ReactActivityDelegate = - DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + // Make sure we get an initial insets dispatch + } + } + + override fun getMainComponentName(): String = "SampleApp" + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 5851c64c72..02c616b946 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, Keyboard, + StatusBar, StyleProp, useWindowDimensions, View, @@ -79,7 +80,9 @@ const measureInWindow = ( const handle = node.current; if (!handle) return reject(new Error('No native handle')); - handle.measureInWindow((x, y, w, h) => resolve({ h, w, x, y })); + handle.measureInWindow((x, y, w, h) => + resolve({ h, w, x, y: y + (StatusBar.currentHeight ?? 0) }), + ); }); }; diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 2246ca33c5..6347c806b0 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -4,6 +4,7 @@ import { BackHandler, Dimensions, Platform, + Pressable, StatusBar, StyleSheet, useWindowDimensions, @@ -425,11 +426,14 @@ const OverlayHostLayer = () => { /> ) : null} - + + {isActive ? ( + + ) : null} - + From cced0ac9d8e9f8d46834404a8e1b6e6580733878 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 00:45:05 +0100 Subject: [PATCH 22/30] fix: critical performance issues and hit slop correctness (again) --- .../overlayContext/OverlayProvider.tsx | 155 +++++++++--------- 1 file changed, 77 insertions(+), 78 deletions(-) diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 6347c806b0..d78409e9c9 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -2,10 +2,8 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } f import { BackHandler, - Dimensions, Platform, Pressable, - StatusBar, StyleSheet, useWindowDimensions, View, @@ -42,8 +40,6 @@ import { TranslationProvider, } from '../translationContext/TranslationContext'; -const { height: SCREEN_H } = Dimensions.get('screen'); - /** * - The highest level of these components is the `OverlayProvider`. The `OverlayProvider` allows users to interact with messages on long press above the underlying views, use the full screen image viewer, and use the `AttachmentPicker` as a keyboard-esk view. * Because these views must exist above all others `OverlayProvider` should wrap your navigation stack as well. Assuming [`React Navigation`](https://reactnavigation.org/) is being used, your highest level navigation stack should be wrapped in the provider: @@ -222,9 +218,8 @@ const OverlayHostLayer = () => { const insets = useSafeAreaInsets(); const { height: screenH } = useWindowDimensions(); - // TODO: Think about this more thoroughly, this is just a patch fix for now. - const topInset = Platform.OS === 'ios' ? insets.top : (StatusBar.currentHeight ?? 0) * 2; - const bottomInset = Platform.OS === 'ios' ? insets.bottom : (StatusBar.currentHeight ?? 0) * 2; + const topInset = insets.top; + const bottomInset = insets.bottom; const isActive = !!id; @@ -236,9 +231,7 @@ const OverlayHostLayer = () => { useEffect(() => { const target = isActive && !closing ? 1 : 0; - backdrop.value = withTiming(target, { - duration: 150, - }); + backdrop.value = withTiming(target, { duration: 150 }); }, [isActive, closing, backdrop]); const backdropStyle = useAnimatedStyle(() => ({ @@ -258,29 +251,78 @@ const OverlayHostLayer = () => { return solvedTop - anchorY; }); + const viewportH = useSharedValue(screenH); + useEffect(() => { + viewportH.value = screenH; + }, [screenH, viewportH]); + + const scrollY = useSharedValue(0); + const initialScrollOffset = useSharedValue(0); + + useEffect(() => { + if (isActive) scrollY.value = 0; + }, [isActive, scrollY]); + + const contentH = useDerivedValue(() => + topH?.value && bottomH?.value && messageH?.value + ? Math.max( + screenH, + topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, + ) + : 0, + ); + + const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); + + const pan = useMemo( + () => + Gesture.Pan() + .activeOffsetY([-8, 8]) + .failOffsetX([-12, 12]) + .onBegin(() => { + cancelAnimation(scrollY); + initialScrollOffset.value = scrollY.value; + }) + .onUpdate((e) => { + scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); + }) + .onEnd((e) => { + scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); + }), + [initialScrollOffset, maxScroll, scrollY], + ); + + const scrollAtClose = useSharedValue(0); + + useDerivedValue(() => { + if (closing) { + scrollAtClose.value = scrollY.value; + cancelAnimation(scrollY); + } + }, [closing]); + + const closeCompStyle = useAnimatedStyle(() => { + const target = closing ? -scrollAtClose.value : 0; + return { + transform: [{ translateY: withTiming(target, { duration: 150 }) }], + }; + }, [closing]); + const topItemStyle = useAnimatedStyle(() => { if (!topH?.value) return { height: 0 }; return { height: topH.value.h, left: topH.value.x, position: 'absolute', - top: topH.value.y, + top: topH.value.y + scrollY.value, width: topH.value.w, }; }); const topItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; - return { - transform: [ - { - translateY: withTiming(target, { duration: 150 }), - }, - { - scale: backdrop.value, - }, - ], + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], }; }, [isActive, closing]); @@ -290,49 +332,31 @@ const OverlayHostLayer = () => { height: bottomH.value.h, left: bottomH.value.x, position: 'absolute', - top: bottomH.value.y, + top: bottomH.value.y + scrollY.value, width: bottomH.value.w, }; }); const bottomItemTranslateStyle = useAnimatedStyle(() => { const target = isActive ? (closing ? 0 : shiftY.value) : 0; - return { - transform: [ - { - translateY: withTiming(target, { duration: 150 }), - }, - { - scale: backdrop.value, - }, - ], + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], }; }, [isActive, closing]); - const viewportH = useSharedValue(SCREEN_H); - - const scrollY = useSharedValue(0); - - useEffect(() => { - if (isActive) { - scrollY.value = 0; - } - }, [isActive, scrollY]); - const hostStyle = useAnimatedStyle(() => { if (!messageH?.value) return { height: 0 }; return { height: messageH.value.h, left: messageH.value.x, position: 'absolute', - top: messageH.value.y, + top: messageH.value.y + scrollY.value, // layout scroll (no special msg-only compensation) width: messageH.value.w, }; }); const hostTranslateStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? -scrollY.value : shiftY.value) : 0; + const target = isActive ? (closing ? 0 : shiftY.value) : 0; return { transform: [ @@ -347,32 +371,9 @@ const OverlayHostLayer = () => { }; }, [isActive, closing]); - const contentH = useDerivedValue(() => - topH?.value && bottomH?.value && messageH?.value - ? Math.max( - screenH, - topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset, - ) - : 0, - ); - const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); - const initialScrollOffset = useSharedValue(0); - - const pan = useMemo( - () => - Gesture.Pan() - .onBegin(() => { - cancelAnimation(scrollY); - initialScrollOffset.value = scrollY.value; - }) - .onUpdate((e) => { - scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); - }) - .onEnd((e) => { - scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); - }), - [initialScrollOffset, maxScroll, scrollY], - ); + const contentStyle = useAnimatedStyle(() => ({ + height: contentH.value, + })); const tap = Gesture.Tap() .onTouchesDown((e, state) => { @@ -383,13 +384,13 @@ const OverlayHostLayer = () => { const y = t.y; const yShift = shiftY.value; // overlay shift - const yParent = scrollY.value; // parent content translation + const yParent = scrollY.value; // parent content const top = topH?.value; if (top) { // top rectangle's final screen Y // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) - const topY = top.y + yShift + yParent; + const topY = top.y + yParent + yShift; if (x >= top.x && x <= top.x + top.w && y >= topY && y <= topY + top.h) { state.fail(); return; @@ -400,7 +401,7 @@ const OverlayHostLayer = () => { if (bot) { // bottom rectangle's final screen Y // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) - const botY = bot.y + yShift + yParent; + const botY = bot.y + yParent + yShift; if (x >= bot.x && x <= bot.x + bot.w && y >= botY && y <= botY + bot.h) { state.fail(); return; @@ -411,11 +412,6 @@ const OverlayHostLayer = () => { runOnJS(closeOverlay)(); }); - const contentStyle = useAnimatedStyle(() => ({ - height: contentH.value, - transform: [{ translateY: scrollY.value }], - })); - return ( @@ -426,16 +422,19 @@ const OverlayHostLayer = () => { /> ) : null} - + {isActive ? ( ) : null} + - + + + From 4c4b9cb7d52ad1e505188d74a72a029317436836 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 01:42:05 +0100 Subject: [PATCH 23/30] refactor: overlay provider ergonomics --- package/src/components/Message/Message.tsx | 3 +- .../MessageOverlayHostLayer.tsx | 268 +++++++++++++ .../overlayContext/OverlayProvider.tsx | 358 +----------------- package/src/state-store/index.ts | 1 + .../src/state-store/message-overlay-store.ts | 85 +++++ 5 files changed, 362 insertions(+), 353 deletions(-) create mode 100644 package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx create mode 100644 package/src/state-store/message-overlay-store.ts diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 02c616b946..f18e12a227 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -22,7 +22,6 @@ import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; import { messageActions as defaultMessageActions } from './utils/messageActions'; -import { closeOverlay, openOverlay, useIsOverlayActive } from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -51,6 +50,7 @@ import { } from '../../contexts/translationContext/TranslationContext'; import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; +import { closeOverlay, openOverlay, useIsOverlayActive } from '../../state-store'; import { FileTypes } from '../../types/types'; import { checkMessageEquality, @@ -847,6 +847,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }} /> ) : null} + {/*TODO: V9: Find a way to separate these in a dedicated file*/} {overlayActive && rect ? ( { + const { messageH, topH, bottomH, id, closing } = useOverlayController(); + const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + + const topInset = insets.top; + const bottomInset = insets.bottom; + + const isActive = !!id; + + const padding = 8; + const minY = topInset + padding; + const maxY = screenH - bottomInset - padding; + + const backdrop = useSharedValue(0); + + useEffect(() => { + const target = isActive && !closing ? 1 : 0; + backdrop.value = withTiming(target, { duration: 150 }); + }, [isActive, closing, backdrop]); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: backdrop.value, + })); + + const shiftY = useDerivedValue(() => { + if (!messageH?.value || !topH?.value || !bottomH?.value) return 0; + + const anchorY = messageH.value.y; + const msgH = messageH.value.h; + + const minTop = minY + topH.value.h; + const maxTop = maxY - (msgH + bottomH.value.h); + + const solvedTop = clamp(anchorY, minTop, maxTop); + return solvedTop - anchorY; + }); + + const viewportH = useSharedValue(screenH); + useEffect(() => { + viewportH.value = screenH; + }, [screenH, viewportH]); + + const scrollY = useSharedValue(0); + const initialScrollOffset = useSharedValue(0); + + useEffect(() => { + if (isActive) scrollY.value = 0; + }, [isActive, scrollY]); + + const contentH = useDerivedValue(() => + topH?.value && bottomH?.value && messageH?.value + ? Math.max( + screenH, + topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, + ) + : 0, + ); + + const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); + + const pan = useMemo( + () => + Gesture.Pan() + .activeOffsetY([-8, 8]) + .failOffsetX([-12, 12]) + .onBegin(() => { + cancelAnimation(scrollY); + initialScrollOffset.value = scrollY.value; + }) + .onUpdate((e) => { + scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); + }) + .onEnd((e) => { + scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); + }), + [initialScrollOffset, maxScroll, scrollY], + ); + + const scrollAtClose = useSharedValue(0); + + useDerivedValue(() => { + if (closing) { + scrollAtClose.value = scrollY.value; + cancelAnimation(scrollY); + } + }, [closing]); + + const closeCompStyle = useAnimatedStyle(() => { + const target = closing ? -scrollAtClose.value : 0; + return { + transform: [{ translateY: withTiming(target, { duration: 150 }) }], + }; + }, [closing]); + + const topItemStyle = useAnimatedStyle(() => { + if (!topH?.value) return { height: 0 }; + return { + height: topH.value.h, + left: topH.value.x, + position: 'absolute', + top: topH.value.y + scrollY.value, + width: topH.value.w, + }; + }); + + const topItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + return { + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + }; + }, [isActive, closing]); + + const bottomItemStyle = useAnimatedStyle(() => { + if (!bottomH?.value) return { height: 0 }; + return { + height: bottomH.value.h, + left: bottomH.value.x, + position: 'absolute', + top: bottomH.value.y + scrollY.value, + width: bottomH.value.w, + }; + }); + + const bottomItemTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + return { + transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], + }; + }, [isActive, closing]); + + const hostStyle = useAnimatedStyle(() => { + if (!messageH?.value) return { height: 0 }; + return { + height: messageH.value.h, + left: messageH.value.x, + position: 'absolute', + top: messageH.value.y + scrollY.value, // layout scroll (no special msg-only compensation) + width: messageH.value.w, + }; + }); + + const hostTranslateStyle = useAnimatedStyle(() => { + const target = isActive ? (closing ? 0 : shiftY.value) : 0; + + return { + transform: [ + { + translateY: withTiming(target, { duration: 150 }, (finished) => { + if (finished && closing) { + runOnJS(finalizeCloseOverlay)(); + } + }), + }, + ], + }; + }, [isActive, closing]); + + const contentStyle = useAnimatedStyle(() => ({ + height: contentH.value, + })); + + const tap = Gesture.Tap() + .onTouchesDown((e, state) => { + const t = e.allTouches[0]; + if (!t) return; + + const x = t.x; + const y = t.y; + + const yShift = shiftY.value; // overlay shift + const yParent = scrollY.value; // parent content + + const top = topH?.value; + if (top) { + // top rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const topY = top.y + yParent + yShift; + if (x >= top.x && x <= top.x + top.w && y >= topY && y <= topY + top.h) { + state.fail(); + return; + } + } + + const bot = bottomH?.value; + if (bot) { + // bottom rectangle's final screen Y + // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) + const botY = bot.y + yParent + yShift; + if (x >= bot.x && x <= bot.x + bot.w && y >= botY && y <= botY + bot.h) { + state.fail(); + return; + } + } + }) + .onEnd(() => { + runOnJS(closeOverlay)(); + }); + + return ( + + + {isActive ? ( + + ) : null} + + + {isActive ? ( + + ) : null} + + + + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + shadow3: { + overflow: 'visible', + ...Platform.select({ + android: { + elevation: 3, + // helps on newer Android (API 28+) to tint elevation shadow + shadowColor: '#000000', + }, + ios: { + shadowColor: 'white', + shadowOffset: { height: 4, width: 0 }, + shadowOpacity: 0.4, + shadowRadius: 10, + }, + }), + }, +}); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index d78409e9c9..35d03fba4f 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -1,36 +1,16 @@ -import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { PropsWithChildren, useEffect, useState } from 'react'; -import { - BackHandler, - Platform, - Pressable, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native'; - -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { - cancelAnimation, - clamp, - runOnJS, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - withDecay, - withTiming, -} from 'react-native-reanimated'; +import { BackHandler } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { PortalHost, PortalProvider } from 'react-native-teleport'; +import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; -import { StateStore } from 'stream-chat'; +import { PortalProvider } from 'react-native-teleport'; +import { MessageOverlayHostLayer } from './MessageOverlayHostLayer'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; -import { useStateStore } from '../../hooks'; import { useStreami18n } from '../../hooks/useStreami18n'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; @@ -129,7 +109,7 @@ export const OverlayProvider = (props: PropsWithChildren) overlayOpacity={overlayOpacity} /> )} - + @@ -137,329 +117,3 @@ export const OverlayProvider = (props: PropsWithChildren) ); }; - -type OverlayState = { - topH: Animated.SharedValue | undefined; - bottomH: Animated.SharedValue | undefined; - messageH: Animated.SharedValue | undefined; - id: string | undefined; - closing: boolean; -}; - -const DefaultState = { - bottomH: undefined, - closing: false, - id: undefined, - messageH: undefined, - topH: undefined, -}; - -export const openOverlay = (id: string, { messageH, topH, bottomH }: Partial) => - overlayStore.partialNext({ bottomH, closing: false, id, messageH, topH }); - -export const closeOverlay = () => { - requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); -}; - -let actionQueue: Array<() => void | Promise> = []; - -export const scheduleActionOnClose = (action: () => void | Promise) => { - const { id } = overlayStore.getLatestValue(); - if (id) { - actionQueue.push(action); - return; - } - action(); -}; - -const s = (nextState: OverlayState) => ({ active: !!nextState.id }); - -const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); - -export const overlayStore = new StateStore(DefaultState); - -overlayStore.subscribeWithSelector(s, async ({ active }) => { - if (!active) { - // flush the queue - for (const action of actionQueue) { - await action(); - } - - actionQueue = []; - } -}); - -const selector = (nextState: OverlayState) => ({ - bottomH: nextState.bottomH, - closing: nextState.closing, - id: nextState.id, - messageH: nextState.messageH, - topH: nextState.topH, -}); - -export const useOverlayController = () => { - return useStateStore(overlayStore, selector); -}; - -const noOpObject = { active: false, closing: false }; - -export const useIsOverlayActive = (messageId: string) => { - const messageOverlaySelector = useCallback( - (nextState: OverlayState) => - nextState.id === messageId ? { active: true, closing: nextState.closing } : noOpObject, - [messageId], - ); - - return useStateStore(overlayStore, messageOverlaySelector); -}; - -const OverlayHostLayer = () => { - const { messageH, topH, bottomH, id, closing } = useOverlayController(); - const insets = useSafeAreaInsets(); - const { height: screenH } = useWindowDimensions(); - - const topInset = insets.top; - const bottomInset = insets.bottom; - - const isActive = !!id; - - const padding = 8; - const minY = topInset + padding; - const maxY = screenH - bottomInset - padding; - - const backdrop = useSharedValue(0); - - useEffect(() => { - const target = isActive && !closing ? 1 : 0; - backdrop.value = withTiming(target, { duration: 150 }); - }, [isActive, closing, backdrop]); - - const backdropStyle = useAnimatedStyle(() => ({ - opacity: backdrop.value, - })); - - const shiftY = useDerivedValue(() => { - if (!messageH?.value || !topH?.value || !bottomH?.value) return 0; - - const anchorY = messageH.value.y; - const msgH = messageH.value.h; - - const minTop = minY + topH.value.h; - const maxTop = maxY - (msgH + bottomH.value.h); - - const solvedTop = clamp(anchorY, minTop, maxTop); - return solvedTop - anchorY; - }); - - const viewportH = useSharedValue(screenH); - useEffect(() => { - viewportH.value = screenH; - }, [screenH, viewportH]); - - const scrollY = useSharedValue(0); - const initialScrollOffset = useSharedValue(0); - - useEffect(() => { - if (isActive) scrollY.value = 0; - }, [isActive, scrollY]); - - const contentH = useDerivedValue(() => - topH?.value && bottomH?.value && messageH?.value - ? Math.max( - screenH, - topH.value.h + messageH.value.h + bottomH.value.h + topInset + bottomInset + 20, - ) - : 0, - ); - - const maxScroll = useDerivedValue(() => Math.max(0, contentH.value - viewportH.value)); - - const pan = useMemo( - () => - Gesture.Pan() - .activeOffsetY([-8, 8]) - .failOffsetX([-12, 12]) - .onBegin(() => { - cancelAnimation(scrollY); - initialScrollOffset.value = scrollY.value; - }) - .onUpdate((e) => { - scrollY.value = clamp(initialScrollOffset.value + e.translationY, 0, maxScroll.value); - }) - .onEnd((e) => { - scrollY.value = withDecay({ clamp: [0, maxScroll.value], velocity: e.velocityY }); - }), - [initialScrollOffset, maxScroll, scrollY], - ); - - const scrollAtClose = useSharedValue(0); - - useDerivedValue(() => { - if (closing) { - scrollAtClose.value = scrollY.value; - cancelAnimation(scrollY); - } - }, [closing]); - - const closeCompStyle = useAnimatedStyle(() => { - const target = closing ? -scrollAtClose.value : 0; - return { - transform: [{ translateY: withTiming(target, { duration: 150 }) }], - }; - }, [closing]); - - const topItemStyle = useAnimatedStyle(() => { - if (!topH?.value) return { height: 0 }; - return { - height: topH.value.h, - left: topH.value.x, - position: 'absolute', - top: topH.value.y + scrollY.value, - width: topH.value.w, - }; - }); - - const topItemTranslateStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? 0 : shiftY.value) : 0; - return { - transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], - }; - }, [isActive, closing]); - - const bottomItemStyle = useAnimatedStyle(() => { - if (!bottomH?.value) return { height: 0 }; - return { - height: bottomH.value.h, - left: bottomH.value.x, - position: 'absolute', - top: bottomH.value.y + scrollY.value, - width: bottomH.value.w, - }; - }); - - const bottomItemTranslateStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? 0 : shiftY.value) : 0; - return { - transform: [{ scale: backdrop.value }, { translateY: withTiming(target, { duration: 150 }) }], - }; - }, [isActive, closing]); - - const hostStyle = useAnimatedStyle(() => { - if (!messageH?.value) return { height: 0 }; - return { - height: messageH.value.h, - left: messageH.value.x, - position: 'absolute', - top: messageH.value.y + scrollY.value, // layout scroll (no special msg-only compensation) - width: messageH.value.w, - }; - }); - - const hostTranslateStyle = useAnimatedStyle(() => { - const target = isActive ? (closing ? 0 : shiftY.value) : 0; - - return { - transform: [ - { - translateY: withTiming(target, { duration: 150 }, (finished) => { - if (finished && closing) { - runOnJS(finalizeCloseOverlay)(); - } - }), - }, - ], - }; - }, [isActive, closing]); - - const contentStyle = useAnimatedStyle(() => ({ - height: contentH.value, - })); - - const tap = Gesture.Tap() - .onTouchesDown((e, state) => { - const t = e.allTouches[0]; - if (!t) return; - - const x = t.x; - const y = t.y; - - const yShift = shiftY.value; // overlay shift - const yParent = scrollY.value; // parent content - - const top = topH?.value; - if (top) { - // top rectangle's final screen Y - // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) - const topY = top.y + yParent + yShift; - if (x >= top.x && x <= top.x + top.w && y >= topY && y <= topY + top.h) { - state.fail(); - return; - } - } - - const bot = bottomH?.value; - if (bot) { - // bottom rectangle's final screen Y - // base layout Y + overlay shift (shiftY) + parent scroll transform (scrollY) - const botY = bot.y + yParent + yShift; - if (x >= bot.x && x <= bot.x + bot.w && y >= botY && y <= botY + bot.h) { - state.fail(); - return; - } - } - }) - .onEnd(() => { - runOnJS(closeOverlay)(); - }); - - return ( - - - {isActive ? ( - - ) : null} - - - {isActive ? ( - - ) : null} - - - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - shadow3: { - overflow: 'visible', - ...Platform.select({ - android: { - elevation: 3, - // helps on newer Android (API 28+) to tint elevation shadow - shadowColor: '#000000', - }, - ios: { - shadowColor: 'white', - shadowOffset: { height: 4, width: 0 }, - shadowOpacity: 0.4, - shadowRadius: 10, - }, - }), - }, -}); -type Rect = { x: number; y: number; w: number; h: number } | undefined; diff --git a/package/src/state-store/index.ts b/package/src/state-store/index.ts index 642110a9c6..4d896c62a9 100644 --- a/package/src/state-store/index.ts +++ b/package/src/state-store/index.ts @@ -1,3 +1,4 @@ export * from './audio-player'; export * from './in-app-notifications-store'; export * from './audio-player-pool'; +export * from './message-overlay-store'; diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts new file mode 100644 index 0000000000..898f705553 --- /dev/null +++ b/package/src/state-store/message-overlay-store.ts @@ -0,0 +1,85 @@ +import { useCallback } from 'react'; +import Animated from 'react-native-reanimated'; + +import { StateStore } from 'stream-chat'; + +import { useStateStore } from '../hooks'; + +type OverlayState = { + topH: Animated.SharedValue | undefined; + bottomH: Animated.SharedValue | undefined; + messageH: Animated.SharedValue | undefined; + id: string | undefined; + closing: boolean; +}; + +type Rect = { x: number; y: number; w: number; h: number } | undefined; + +const DefaultState = { + bottomH: undefined, + closing: false, + id: undefined, + messageH: undefined, + topH: undefined, +}; + +export const openOverlay = (id: string, { messageH, topH, bottomH }: Partial) => + overlayStore.partialNext({ bottomH, closing: false, id, messageH, topH }); + +export const closeOverlay = () => { + requestAnimationFrame(() => overlayStore.partialNext({ closing: true })); +}; + +let actionQueue: Array<() => void | Promise> = []; + +export const scheduleActionOnClose = (action: () => void | Promise) => { + const { id } = overlayStore.getLatestValue(); + if (id) { + actionQueue.push(action); + return; + } + action(); +}; + +export const finalizeCloseOverlay = () => overlayStore.partialNext(DefaultState); + +export const overlayStore = new StateStore(DefaultState); + +const actionQueueSelector = (nextState: OverlayState) => ({ active: !!nextState.id }); + +// TODO: V9: Consider having a store per `MessageOverlayHostLayer` to prevent multi-instance +// integrations causing UI issues. +overlayStore.subscribeWithSelector(actionQueueSelector, async ({ active }) => { + if (!active) { + // flush the queue + for (const action of actionQueue) { + await action(); + } + + actionQueue = []; + } +}); + +const selector = (nextState: OverlayState) => ({ + bottomH: nextState.bottomH, + closing: nextState.closing, + id: nextState.id, + messageH: nextState.messageH, + topH: nextState.topH, +}); + +export const useOverlayController = () => { + return useStateStore(overlayStore, selector); +}; + +const noOpObject = { active: false, closing: false }; + +export const useIsOverlayActive = (messageId: string) => { + const messageOverlaySelector = useCallback( + (nextState: OverlayState) => + nextState.id === messageId ? { active: true, closing: nextState.closing } : noOpObject, + [messageId], + ); + + return useStateStore(overlayStore, messageOverlaySelector); +}; From 8ba0c4f7d07aa6f0ff849efc1cb9d44a84656c79 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 01:53:07 +0100 Subject: [PATCH 24/30] chore: remove comments --- .../android/app/src/main/java/com/sampleapp/MainActivity.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt index 5e1a72a2c8..79546eb2e2 100644 --- a/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt +++ b/examples/SampleApp/android/app/src/main/java/com/sampleapp/MainActivity.kt @@ -20,7 +20,6 @@ class MainActivity : ReactActivity() { if (Build.VERSION.SDK_INT >= 35) { val rootView = findViewById(android.R.id.content) - // Keep the original padding so we can restore it when IME closes val initial = Insets.of( rootView.paddingLeft, rootView.paddingTop, @@ -29,7 +28,6 @@ class MainActivity : ReactActivity() { ) ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> - // Only apply IME (keyboard) to avoid breaking safe-area libs or double-padding system bars. val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) v.updatePadding( @@ -39,11 +37,8 @@ class MainActivity : ReactActivity() { bottom = initial.bottom + ime.bottom ) - // IMPORTANT: don't consume — allow insets to propagate to RN & safe-area-context insets } - - // Make sure we get an initial insets dispatch } } From 805a8562581b1e0ed2e19a595bb910e5ae6e71cd Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 01:58:34 +0100 Subject: [PATCH 25/30] chore: add Podfile.lock changes too --- examples/SampleApp/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index bc13cd1937..5af66105b1 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3656,7 +3656,7 @@ SPEC CHECKSUMS: React-timing: 42e8212c479d1e956d3024be0a07f205a2e34d9d React-utils: 0ebf25dd4eb1b5497933f4d8923b862d0fe9566f ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 - ReactCodegen: 07eaba6aed08b41813a9acf559012ecb4911b737 + ReactCodegen: f8ae44cfcb65af88f409f4b094b879591232f12c ReactCommon: a237545f08f598b8b37dc3a9163c1c4b85817b0b RNCAsyncStorage: afe7c3711dc256e492aa3a50dcac43eecebd0ae5 RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 From 7c39c9227ce7b0fabcb3b6ae01dfcb77ed7c7903 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 02:48:40 +0100 Subject: [PATCH 26/30] fix: imports --- package/src/components/MessageMenu/MessageActionListItem.tsx | 2 +- package/src/components/MessageMenu/MessageReactionPicker.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 86eda2753e..332dd0e6b4 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; -import { closeOverlay, scheduleActionOnClose } from '../../contexts'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; +import { closeOverlay, scheduleActionOnClose } from '../../state-store'; export type ActionType = | 'banUser' diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 9fef0a940d..bb8c552117 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -5,7 +5,6 @@ import { FlatList } from 'react-native-gesture-handler'; import { emojis } from './emojis'; import { ReactionButton } from './ReactionButton'; -import { scheduleActionOnClose } from '../../contexts'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { MessagesContextValue, @@ -17,6 +16,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { Attach } from '../../icons'; import { NativeHandlers } from '../../native'; +import { scheduleActionOnClose } from '../../state-store'; import { ReactionData } from '../../utils/utils'; import { BottomSheetModal } from '../UIComponents'; From f07c4a8750dc69a153236f29ed565e4f22c9148c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 03:13:32 +0100 Subject: [PATCH 27/30] fix: merge conflicts and FLashList api --- package/src/components/Message/Message.tsx | 1 - .../src/components/MessageList/MessageFlashList.tsx | 10 ++++++++++ .../components/UIComponents/BottomSheetModal.tsx | 13 +++---------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 70389de0e7..7a00669b91 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, - Keyboard, StatusBar, StyleProp, useWindowDimensions, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 31f31185ca..bac31e5441 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -316,6 +316,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); + const [scrollEnabled, setScrollEnabled] = useState(true); const stickyHeaderDateRef = useRef(undefined); /** @@ -717,6 +718,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [], ); + const setNativeScrollability = useStableCallback((value: boolean) => { + // FlashList does not have setNativeProps exposed, hence we cannot use that. + // Instead, we resort to state. + setScrollEnabled(value); + }); + const messageListItemContextValue: MessageListItemContextValue = useMemo( () => ({ goToMessage, @@ -724,6 +731,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, }), [ goToMessage, @@ -731,6 +739,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, ], ); @@ -1083,6 +1092,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + scrollEnabled={scrollEnabled} scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} style={flatListStyle} diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 5e22d77c07..8c80baf6e0 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -9,13 +9,8 @@ import { useWindowDimensions, View, } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, - GestureUpdateEvent, - PanGestureHandlerEventPayload, -} from 'react-native-gesture-handler'; +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; +import type { KeyboardEventData } from 'react-native-keyboard-controller'; import Animated, { cancelAnimation, Easing, @@ -26,11 +21,9 @@ import Animated, { withTiming, } from 'react-native-reanimated'; -import type { KeyboardEventData } from 'react-native-keyboard-controller'; - import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { useStableCallback } from '../../hooks'; +import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export type BottomSheetModalProps = { onClose: () => void; From 1c2b6eea67c3ef42054808aa5051ebdf8a79bd46 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 05:39:49 +0100 Subject: [PATCH 28/30] fix: inset bugs on android --- package/src/components/Message/Message.tsx | 10 +++++++--- .../src/components/UIComponents/BottomSheetModal.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 7a00669b91..46f249c59e 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, - StatusBar, + Platform, StyleProp, useWindowDimensions, View, @@ -9,6 +9,7 @@ import { } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; +import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -75,13 +76,14 @@ export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; const measureInWindow = ( node: React.RefObject, + insets: EdgeInsets, ): Promise<{ x: number; y: number; w: number; h: number }> => { return new Promise((resolve, reject) => { const handle = node.current; if (!handle) return reject(new Error('No native handle')); handle.measureInWindow((x, y, w, h) => - resolve({ h, w, x, y: y + (StatusBar.currentHeight ?? 0) }), + resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }), ); }); }; @@ -320,6 +322,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { MessageActionList, MessageActionListItem, } = props; + // TODO: V9: Reconsider using safe area insets in every message. + const insets = useSafeAreaInsets(); const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( () => isMessageAIGenerated(message), @@ -352,7 +356,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const showMessageOverlay = async (showMessageReactions = false) => { dismissKeyboard(); try { - const layout = await measureInWindow(messageWrapperRef); + const layout = await measureInWindow(messageWrapperRef, insets); setRect(layout); setShowMessageReactions(showMessageReactions); messageH.value = layout; diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 8c80baf6e0..5de007e7e8 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -151,7 +151,7 @@ export const BottomSheetModal = (props: PropsWithChildren return () => { listeners.forEach((listener) => listener.remove()); }; - }, [visible, keyboardDidHide, keyboardDidShow]); + }, [visible, keyboardDidHide, keyboardDidShow, keyboardOffset, isOpen, translateY]); const sheetAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], From 0a37c11eae020277868c2dba8bb771549aa2837f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 07:43:55 +0100 Subject: [PATCH 29/30] chore: android edge-to-edge edge cases and move util to separate file for easier testing --- package/src/components/Message/Message.tsx | 18 ++--------------- .../Message/utils/measureInWindow.ts | 20 +++++++++++++++++++ .../MessageOverlayHostLayer.tsx | 6 +++++- 3 files changed, 27 insertions(+), 17 deletions(-) create mode 100644 package/src/components/Message/utils/measureInWindow.ts diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 46f249c59e..e9dfa47373 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, - Platform, StyleProp, useWindowDimensions, View, @@ -9,7 +8,7 @@ import { } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; -import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -20,6 +19,7 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { measureInWindow } from './utils/measureInWindow'; import { messageActions as defaultMessageActions } from './utils/messageActions'; import { @@ -74,20 +74,6 @@ export type TouchableEmitter = export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; -const measureInWindow = ( - node: React.RefObject, - insets: EdgeInsets, -): Promise<{ x: number; y: number; w: number; h: number }> => { - return new Promise((resolve, reject) => { - const handle = node.current; - if (!handle) return reject(new Error('No native handle')); - - handle.measureInWindow((x, y, w, h) => - resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }), - ); - }); -}; - export type TextMentionTouchableHandlerPayload = { emitter: 'textMention'; additionalInfo?: TextMentionTouchableHandlerAdditionalInfo; diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts new file mode 100644 index 0000000000..fb22c07e49 --- /dev/null +++ b/package/src/components/Message/utils/measureInWindow.ts @@ -0,0 +1,20 @@ +import React from 'react'; +import { Platform, View } from 'react-native'; +import { EdgeInsets } from 'react-native-safe-area-context'; + +export const measureInWindow = ( + node: React.RefObject, + insets: EdgeInsets, +): Promise<{ x: number; y: number; w: number; h: number }> => { + return new Promise((resolve, reject) => { + const handle = node.current; + if (!handle) + return reject( + new Error('The native handle could not be found while invoking measureInWindow.'), + ); + + handle.measureInWindow((x, y, w, h) => + resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }), + ); + }); +}; diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index 34d6c82869..cf1226712c 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -23,7 +23,11 @@ export const MessageOverlayHostLayer = () => { const { height: screenH } = useWindowDimensions(); const topInset = insets.top; - const bottomInset = insets.bottom; + // Due to edge-to-edge in combination with various libraries, Android sometimes reports + // the insets to be 0. If that's the case, we use this as an escape hatch to offset the bottom + // of the overlay so that it doesn't collide with the navigation bar. Worst case scenario, + // if the navigation bar is actually 0 - we end up animating a little bit further. + const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom; const isActive = !!id; From 2cb9b487b59ba4fb278c35930f1d5fc5f0d78ed4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 27 Dec 2025 07:44:51 +0100 Subject: [PATCH 30/30] chore: fix failing tests --- package/jest-setup.js | 34 +- package/package.json | 5 +- .../Channel/__tests__/ownCapabilities.test.js | 20 +- .../MessageSimple/__tests__/Message.test.js | 21 +- .../__snapshots__/AttachButton.test.js.snap | 1329 +++++++---- .../__snapshots__/SendButton.test.js.snap | 830 ++++--- .../__tests__/MessageUserReactions.test.tsx | 51 +- .../components/Reply/__tests__/Reply.test.tsx | 18 +- .../__snapshots__/Thread.test.js.snap | 2010 +++++++++-------- package/yarn.lock | 8 +- 10 files changed, 2567 insertions(+), 1759 deletions(-) diff --git a/package/jest-setup.js b/package/jest-setup.js index 5b2987f3d0..85760c08a8 100644 --- a/package/jest-setup.js +++ b/package/jest-setup.js @@ -1,7 +1,8 @@ /* global require */ -import { FlatList, View } from 'react-native'; +import rn, { FlatList, View } from 'react-native'; import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js'; +import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock'; import { registerNativeHandlers } from './src/native'; @@ -65,3 +66,34 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () jest.mock('@shopify/flash-list', () => ({ FlashList: undefined, })); + +jest.mock('react-native-teleport', () => { + const rn = require('react-native'); + return { + Portal: rn.View, + PortalHost: rn.View, + PortalProvider: rn.View, + usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }), + }; +}); + +jest.mock('react-native-teleport', () => { + const rn = require('react-native'); + return { + Portal: rn.View, + PortalHost: rn.View, + PortalProvider: rn.View, + usePortal: jest.fn().mockReturnValue({ removePortal: jest.fn() }), + }; +}); + +jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); + +jest.mock('./src/components/Message/utils/measureInWindow', () => ({ + measureInWindow: jest.fn(async () => ({ + x: 10, + y: 100, + w: 250, + h: 60, + })), +})); diff --git a/package/package.json b/package/package.json index c5a1c2290a..4126ff35e2 100644 --- a/package/package.json +++ b/package/package.json @@ -94,7 +94,8 @@ "react-native-keyboard-controller": ">=1.20.2", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", - "react-native-svg": ">=15.8.0" + "react-native-svg": ">=15.8.0", + "react-native-teleport": ">=0.5.4" }, "peerDependenciesMeta": { "@op-engineering/op-sqlite": { @@ -162,7 +163,7 @@ "react-native-reanimated": "3.18.0", "react-native-safe-area-context": "^5.6.1", "react-native-svg": "15.12.0", - "react-native-teleport": "^0.5.3", + "react-native-teleport": "^0.5.4", "react-test-renderer": "19.1.0", "rimraf": "^6.0.1", "typescript": "5.8.3", diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js index 876bb5b483..b12e2f874d 100644 --- a/package/src/components/Channel/__tests__/ownCapabilities.test.js +++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js @@ -1,6 +1,8 @@ import React from 'react'; import { FlatList } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -47,14 +49,16 @@ describe('Own capabilities', () => { }); const getComponent = (props = {}) => ( - - - - - - - - + + + + + + + + + + ); const generateChannelWithCapabilities = async (capabilities = []) => { diff --git a/package/src/components/Message/MessageSimple/__tests__/Message.test.js b/package/src/components/Message/MessageSimple/__tests__/Message.test.js index 69779d57fa..1ea9467329 100644 --- a/package/src/components/Message/MessageSimple/__tests__/Message.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/Message.test.js @@ -1,5 +1,7 @@ import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; @@ -37,15 +39,16 @@ describe('Message', () => { renderMessage = (options) => render( - - - - - - - - , - , + + + + + + + + + + , ); }); 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 c88aacebce..ce8d93e13d 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.js.snap @@ -1,610 +1,1051 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AttachButton should call handleAttachButtonPress when the button is clicked if passed 1`] = ` - + - - - - - - - - - - - + + + + + + + + + - - - - - + + + + - -`; - -exports[`AttachButton should render a enabled AttachButton 1`] = ` - - + + + + + + - + + + + +`; + +exports[`AttachButton should render a enabled AttachButton 1`] = ` + + + + + - - - - - - - - - + + + + + + + + + - - - - - + + + + - -`; - -exports[`AttachButton should render an disabled AttachButton 1`] = ` - - + + + - + + + + + + + +`; + +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 ca6b9d765b..193309cc73 100644 --- a/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap +++ b/package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.js.snap @@ -1,103 +1,89 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`SendButton should render a SendButton 1`] = ` - + - - - - + - - + propList={ + [ + "fill", + ] + } + r={16} + /> + + + + - - - - - + + + + - -`; - -exports[`SendButton should render a disabled SendButton 1`] = ` - - - + + + + + + + + + + +`; + +exports[`SendButton should render a disabled SendButton 1`] = ` + + + + + - - + - - + propList={ + [ + "fill", + ] + } + r={16} + /> + + + + + + + + + - - - + + + + + + + + + + `; diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx index e93cefa59b..db623849bd 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Text } from 'react-native'; -import { fireEvent, render } from '@testing-library/react-native'; +import { render } from '@testing-library/react-native'; import { LocalMessage, ReactionResponse } from 'stream-chat'; @@ -85,30 +85,31 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { expect(getByText('Message Reactions')).toBeTruthy(); }); - it('renders reaction buttons', () => { - const { getByLabelText } = renderComponent(); - const likeReactionButton = getByLabelText('reaction-button-like-selected'); - expect(likeReactionButton).toBeDefined(); - const loveReactionButton = getByLabelText('reaction-button-love-unselected'); - expect(loveReactionButton).toBeDefined(); - }); - - it('selects the first reaction by default', () => { - const { getAllByLabelText } = renderComponent(); - const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); - expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected'); - expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); - }); - - it('changes selected reaction when a reaction button is pressed', () => { - const { getAllByLabelText } = renderComponent(); - const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); - - fireEvent.press(reactionButtons[1]); - - expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); - expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); - }); + // TODO: V9: Remove these with V9, they are no longer relevant tests. + // it('renders reaction buttons', () => { + // const { getByLabelText } = renderComponent(); + // const likeReactionButton = getByLabelText('reaction-button-like-selected'); + // expect(likeReactionButton).toBeDefined(); + // const loveReactionButton = getByLabelText('reaction-button-love-unselected'); + // expect(loveReactionButton).toBeDefined(); + // }); + // + // it('selects the first reaction by default', () => { + // const { getAllByLabelText } = renderComponent(); + // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-selected'); + // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-unselected'); + // }); + // + // it('changes selected reaction when a reaction button is pressed', () => { + // const { getAllByLabelText } = renderComponent(); + // const reactionButtons = getAllByLabelText(/\breaction-button[^\s]+/); + // + // fireEvent.press(reactionButtons[1]); + // + // expect(reactionButtons[0].props.accessibilityLabel).toBe('reaction-button-like-unselected'); + // expect(reactionButtons[1].props.accessibilityLabel).toBe('reaction-button-love-selected'); + // }); it('renders reactions list', () => { const { getByText } = renderComponent(); diff --git a/package/src/components/Reply/__tests__/Reply.test.tsx b/package/src/components/Reply/__tests__/Reply.test.tsx index 7a20c757d7..ed31d5e161 100644 --- a/package/src/components/Reply/__tests__/Reply.test.tsx +++ b/package/src/components/Reply/__tests__/Reply.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + import { render, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -24,13 +26,15 @@ describe('', () => { await channel.watch(); const TestComponent = () => ( - - - - - - - + + + + + + + + + ); try { 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 a917135ec3..b9c34b762b 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -262,322 +262,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message6 - - + + + Message6 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -632,322 +639,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message5 - - + + + Message5 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -1040,322 +1054,329 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message4 - - + + + Message4 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + @@ -1411,325 +1432,332 @@ exports[`Thread should match thread snapshot 1`] = ` } testID="message-wrapper" > - - - - - - - - - + + + - - + + + + + + + + - - - Message3 - - + + + Message3 + + + + + + + 2:50 PM + + + ⦁ + + + Edited + + - - - 2:50 PM - - - ⦁ - - - Edited - - + diff --git a/package/yarn.lock b/package/yarn.lock index d7c787a721..5d968e079d 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -7774,10 +7774,10 @@ react-native-svg@15.12.0: css-tree "^1.1.3" warn-once "0.1.1" -react-native-teleport@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.3.tgz#c29fb09f4f0faf1d6d6aa479b1a0792f3a9373b6" - integrity sha512-aWAui0yH0UqqC3Z3wnY3VLXZw30ST4Ikdx9ZzF7YyeVJdhfcDO/JjQb2D3uHnbarttLQojdblQLYoQzeAO07sg== +react-native-teleport@^0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" + integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== react-native-url-polyfill@^2.0.0: version "2.0.0"