diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 771d8db352..b71b8c6a67 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,43 @@ const App = () => { backgroundColor: streamChatTheme.colors?.white_snow || '#FCFCFC', }} > - - - - {isConnecting && !chatClient ? ( - - ) : chatClient ? ( - - ) : ( - - )} - - - + + + + + + {isConnecting && !chatClient ? ( + + ) : chatClient ? ( + + ) : ( + + )} + + + + + ); }; @@ -265,32 +271,26 @@ 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/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..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 @@ -1,52 +1,49 @@ 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) + + val initial = Insets.of( + rootView.paddingLeft, + rootView.paddingTop, + rootView.paddingRight, + rootView.paddingBottom + ) + + ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + + v.updatePadding( + left = initial.left, + top = initial.top, + right = initial.right, + bottom = initial.bottom + ime.bottom + ) + + 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) + } + } + + override fun getMainComponentName(): String = "SampleApp" + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 16f46a1c00..acfbd0474e 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3224,6 +3224,65 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - Teleport (0.5.4): + - 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.4) + - Yoga + - Teleport/common (0.5.4): + - 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: @@ -3329,6 +3388,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: @@ -3552,6 +3612,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" @@ -3584,7 +3646,7 @@ SPEC CHECKSUMS: op-sqlite: b9a4028bef60145d7b98fbbc4341c83420cdcfd5 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 RCTRequired: e0446b01093475b7082fbeee5d1ef4ad1fe20ac4 RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f @@ -3675,6 +3737,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: 7a042480d22a8a87aaee6186bf2f1013af017d3a + Teleport: 9ae328b3384204b23236e47bd0d8e994ce6bdb48 Yoga: ce248fb32065c9b00451491b06607f1c25b2f1ed PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 105ff7f23a..c1171deb10 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -56,6 +56,7 @@ "react-native-screens": "^4.11.1", "react-native-share": "^12.0.11", "react-native-svg": "^15.12.0", + "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 c119e101b9..a93301a6f6 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7736,6 +7736,11 @@ react-native-svg@^15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +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" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589" 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 71ad526cc5..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,6 +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.4", "react-test-renderer": "19.1.0", "rimraf": "^6.0.1", "typescript": "5.8.3", diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 60edac220d..6afeff2d77 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -207,10 +207,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'; @@ -250,6 +254,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/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/Message.tsx b/package/src/components/Message/Message.tsx index fb95d80bc0..e9dfa47373 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,5 +1,15 @@ -import React, { useMemo, useState } from 'react'; -import { GestureResponderEvent, StyleProp, View, ViewStyle } from 'react-native'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + GestureResponderEvent, + StyleProp, + useWindowDimensions, + View, + ViewStyle, +} from 'react-native'; + +import { useSharedValue } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Portal } from 'react-native-teleport'; import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; @@ -9,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 { @@ -25,6 +36,7 @@ import { useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -38,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, @@ -193,6 +206,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 & { @@ -220,12 +240,11 @@ export type MessagePropsWithContext = Pick< * each individual Message component. */ const MessageWithContext = (props: MessagePropsWithContext) => { - const [messageOverlayVisible, setMessageOverlayVisible] = useState(false); const [isErrorInMessage, setIsErrorInMessage] = useState(false); 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, @@ -259,7 +278,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { MessageBlocked, MessageBounce, messageContentOrder: messageContentOrderProp, - MessageMenu, messagesContext, MessageSimple, onLongPressMessage: onLongPressMessageProp, @@ -283,7 +301,15 @@ const MessageWithContext = (props: MessagePropsWithContext) => { updateMessage, readBy, setQuotedMessage, + MessageUserReactions, + MessageUserReactionsAvatar, + MessageUserReactionsItem, + MessageReactionPicker, + 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), @@ -299,15 +325,37 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }, } = useTheme(); - const showMessageOverlay = (showMessageReactions = false, selectedReaction?: string) => { + const topH = useSharedValue<{ w: number; h: number; x: number; y: number } | undefined>( + undefined, + ); + 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) => { dismissKeyboard(); - setShowMessageReactions(showMessageReactions); - setMessageOverlayVisible(true); - setSelectedReaction(selectedReaction); + try { + const layout = await measureInWindow(messageWrapperRef, insets); + setRect(layout); + setShowMessageReactions(showMessageReactions); + messageH.value = layout; + openOverlay(message.id, { bottomH, messageH, topH }); + } catch (e) { + console.error(e); + } }; + const { setNativeScrollability } = useMessageListItemContext(); + const dismissOverlay = () => { - setMessageOverlayVisible(false); + closeOverlay(); }; const actionsEnabled = @@ -620,7 +668,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => { unpinMessage: handleTogglePinMessage, }; + const messageWrapperRef = useRef(null); + const onLongPress = () => { + setNativeScrollability(false); if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { return; } @@ -633,6 +684,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { showMessageOverlay(); }; + const frozenMessage = useRef(message); + const { active: overlayActive } = useIsOverlayActive(message.id); + const messageContext = useCreateMessageContext({ actionsEnabled, alignment, @@ -652,7 +706,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, - message, + message: overlayActive ? frozenMessage.current : message, messageContentOrder, myMessageTheme: messagesContext.myMessageTheme, onLongPress: (payload) => { @@ -716,7 +770,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } : null, otherAttachments: attachments.other, - preventPress, + preventPress: overlayActive ? true : preventPress, reactions, readBy, setIsEditedMessageOpen, @@ -728,6 +782,21 @@ const MessageWithContext = (props: MessagePropsWithContext) => { videos: attachments.videos, }); + const prevActive = useRef(overlayActive); + + useEffect(() => { + if (!overlayActive && prevActive.current && setNativeScrollability) { + setNativeScrollability(true); + } + prevActive.current = overlayActive; + }, [setNativeScrollability, overlayActive]); + + useEffect(() => { + if (!overlayActive) { + frozenMessage.current = message; + } + }, [overlayActive, message]); + if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; } @@ -759,20 +828,75 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ]} testID='message-wrapper' > - + {overlayActive && rect ? ( + + ) : null} + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + topH.value = { + h, + w, + x: isMyMessage ? screenW - rect.x - w : rect.x, + y: rect.y - h, + }; + }} + > + reaction.type) || []} + /> + + ) : null} + + + + + + {overlayActive && rect ? ( + { + 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, + }; + }} + > + {showMessageReactions ? ( + + ) : ( + + )} + + ) : 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..bddd71b580 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, @@ -16,6 +16,7 @@ import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -121,6 +122,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, @@ -239,10 +241,13 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { return bordersFromTheme; }; + const { setNativeScrollability } = useMessageListItemContext(); + return ( { + setLongPressFired(true); if (onLongPress) { onLongPress({ emitter: 'messageContent', @@ -266,8 +271,16 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }); } }} - style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }, container]} + 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 && ( @@ -373,6 +386,7 @@ const areEqual = ( nextProps: MessageContentPropsWithContext, ) => { const { + preventPress: prevPreventPress, goToMessage: prevGoToMessage, groupStyles: prevGroupStyles, isAttachmentEqual, @@ -384,6 +398,7 @@ const areEqual = ( t: prevT, } = prevProps; const { + preventPress: nextPreventPress, goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isEditedMessageOpen: nextIsEditedMessageOpen, @@ -394,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/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 472d4c47be..db9e5b4f7e 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,105 @@ 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 +436,7 @@ export type MessageSimpleProps = Partial; * * Message UI component */ -export const MessageSimple = (props: MessageSimpleProps) => { +export const MessageSimple = forwardRef((props, ref) => { const { alignment, channel, @@ -506,9 +511,10 @@ export const MessageSimple = (props: MessageSimpleProps) => { shouldRenderSwipeableWrapper, showMessageStatus, }} + ref={ref} {...props} /> ); -}; +}); MessageSimple.displayName = 'MessageSimple{messageSimple{container}}'; 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/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/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/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/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/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 45618918f2..ac6693a396 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -766,6 +766,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, @@ -773,6 +779,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, }), [ goToMessage, @@ -780,6 +787,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { modifiedTheme, noGroupByUser, onThreadSelect, + setNativeScrollability, ], ); diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 6fc69abfe2..14dbd05024 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: {}, + container: { + borderRadius: 16, + marginTop: 6, + }, contentContainer: { - paddingHorizontal: 16, + borderRadius: 16, + flexGrow: 1, + minWidth: 250, + paddingHorizontal: 12, + paddingVertical: 4, }, }); diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 47f8c26cf5..332dd0e6b4 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Pressable, StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; +import { closeOverlay, scheduleActionOnClose } from '../../state-store'; 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 }]}> -> & - 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; + }; + } +>; +// 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, + 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: { @@ -101,36 +108,12 @@ export const MessageMenu = (props: MessageMenuProps) => { return ( - {showMessageReactions ? ( - - ) : ( - <> - reaction.type) || []} - /> - - - )} + {children} ); }; diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 72131b94be..bb8c552117 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 { MessageContextValue } from '../../contexts/messageContext/MessageContext'; @@ -11,9 +13,13 @@ 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 { scheduleActionOnClose } from '../../state-store'; import { ReactionData } from '../../utils/utils'; +import { BottomSheetModal } from '../UIComponents'; export type MessageReactionPickerProps = Pick & Pick & { @@ -28,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, @@ -51,6 +70,7 @@ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); const { theme: { + colors: { white, grey }, messageMenu: { reactionPicker: { container, contentContainer }, }, @@ -60,48 +80,116 @@ 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) { - handleReaction(type); + scheduleActionOnClose(() => handleReaction(type)); } - dismissOverlay(); - }; + }); + + 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 ( 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', }, contentContainer: { + borderRadius: 20, flexGrow: 1, justifyContent: 'space-around', 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/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index ee7dfe5da7..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< @@ -32,26 +30,14 @@ export type MessageUserReactionsProps = Partial< * The selected reaction */ selectedReaction?: string; + reactionFilterEnabled?: boolean; }; 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) => { const { @@ -59,13 +45,8 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { MessageUserReactionsAvatar: propMessageUserReactionsAvatar, MessageUserReactionsItem: propMessageUserReactionsItem, reactions: propReactions, - selectedReaction: propSelectedReaction, supportedReactions: propSupportedReactions, } = props; - const reactionTypes = Object.keys(message?.reaction_groups ?? {}); - const [selectedReaction, setSelectedReaction] = React.useState( - propSelectedReaction ?? reactionTypes[0], - ); const { MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, MessageUserReactionsItem: contextMessageUserReactionsItem, @@ -76,51 +57,21 @@ 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, }); const { theme: { + colors: { white }, messageMenu: { - userReactions: { - container, - contentContainer, - flatlistColumnContainer, - flatlistContainer, - reactionSelectorContainer, - reactionsText, - }, + userReactions: { container, flatlistColumnContainer, flatlistContainer, reactionsText }, }, }, } = useTheme(); @@ -149,52 +100,34 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { [MessageUserReactionsAvatar, MessageUserReactionsItem, supportedReactions], ); - const renderHeader = useCallback( - () => {t('Message Reactions')}, - [t, reactionsText], - ); - - const selectorReactions: ReactionSelectorItemType[] = messageReactions.map((reaction) => ({ - ...reaction, - onSelectReaction, - selectedReaction, - })); - - return ( + return !loading ? ( - - item.type} - renderItem={renderSelectorItem} - /> - - - {!loading ? ( + <> + {t('Message Reactions')} item.id} - ListHeaderComponent={renderHeader} + keyExtractor={keyExtractor} numColumns={4} onEndReached={loadNextPage} renderItem={renderItem} /> - ) : null} + - ); + ) : null; }; const styles = StyleSheet.create({ container: { - flex: 1, + borderRadius: 16, + marginTop: 16, + maxHeight: 256, + width: Dimensions.get('window').width * 0.9, }, contentContainer: { flexGrow: 1, @@ -206,6 +139,7 @@ const styles = StyleSheet.create({ }, flatListContainer: { justifyContent: 'center', + paddingHorizontal: 8, }, reactionSelectorContainer: { flexDirection: 'row', @@ -215,6 +149,7 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: 'bold', marginVertical: 16, + paddingHorizontal: 8, textAlign: 'center', }, }); 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', diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx index b3d4ad8f92..63fb815d1b 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', + paddingHorizontal: 3, + paddingVertical: 8, }, }); 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/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/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/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/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 3d8e319750..5de007e7e8 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -1,6 +1,5 @@ -import React, { PropsWithChildren, useEffect, useMemo } from 'react'; +import React, { PropsWithChildren, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { - Animated, EventSubscription, Keyboard, KeyboardEvent, @@ -10,42 +9,32 @@ 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 { runOnJS } from 'react-native-reanimated'; +import Animated, { + cancelAnimation, + Easing, + FadeIn, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStableCallback } from '../../hooks'; import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; 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 }, @@ -53,44 +42,102 @@ 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 listeners: EventSubscription[] = []; if (KeyboardControllerPackage?.KeyboardEvents) { - const keyboardDidShow = (e: KeyboardEventData) => { - Animated.timing(translateY, { - duration: 250, - toValue: -e.height, - useNativeDriver: true, - }).start(); + const keyboardDidShow = (event: KeyboardEventData) => { + const offset = -event.height; + keyboardOffset.value = offset; + + if (isOpen.value) { + cancelAnimation(translateY); + translateY.value = withTiming(offset, { + duration: 250, + easing: Easing.inOut(Easing.ease), + }); + } }; listeners.push( @@ -101,45 +148,48 @@ export const BottomSheetModal = (props: PropsWithChildren listeners.push(Keyboard.addListener('keyboardDidShow', keyboardDidShow)); listeners.push(Keyboard.addListener('keyboardDidHide', keyboardDidHide)); } - return () => { listeners.forEach((listener) => listener.remove()); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - 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)(); - } - }); + }, [visible, keyboardDidHide, keyboardDidShow, keyboardOffset, isOpen, translateY]); + + const sheetAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + 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 ( @@ -147,24 +197,28 @@ export const BottomSheetModal = (props: PropsWithChildren - + + - {children} + + {renderContent ? ( + + {children} + + ) : null} + @@ -179,10 +233,6 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 16, borderTopRightRadius: 16, }, - content: { - flex: 1, - padding: 16, - }, contentContainer: { flex: 1, marginTop: 8, 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/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index fdfa6a27d2..aa4e4d36b0 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, @@ -272,7 +272,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/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx new file mode 100644 index 0000000000..cf1226712c --- /dev/null +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useMemo } from 'react'; +import { 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { PortalHost } from 'react-native-teleport'; + +import { closeOverlay, useOverlayController } from '../../state-store'; +import { finalizeCloseOverlay } from '../../state-store'; + +export const MessageOverlayHostLayer = () => { + const { messageH, topH, bottomH, id, closing } = useOverlayController(); + const insets = useSafeAreaInsets(); + const { height: screenH } = useWindowDimensions(); + + const topInset = insets.top; + // 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; + + 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 e4397032fb..35d03fba4f 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -4,6 +4,9 @@ import { BackHandler } from 'react-native'; import { cancelAnimation, useSharedValue, withTiming } from 'react-native-reanimated'; +import { PortalProvider } from 'react-native-teleport'; + +import { MessageOverlayHostLayer } from './MessageOverlayHostLayer'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; @@ -93,18 +96,21 @@ export const OverlayProvider = (props: PropsWithChildren) - {children} - {overlay === 'gallery' && ( - - )} + + {children} + {overlay === 'gallery' && ( + + )} + + 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); +}; 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({ diff --git a/package/yarn.lock b/package/yarn.lock index 336dfb3363..5d968e079d 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -7774,6 +7774,11 @@ react-native-svg@15.12.0: css-tree "^1.1.3" warn-once "0.1.1" +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" resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz#db714520a2985cff1d50ab2e66279b9f91ffd589"