diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 852b4490e4..56a64bcaf9 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -59,6 +59,11 @@ import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-ch import { Toast } from './src/components/ToastComponent/Toast'; import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler'; import AsyncStore from './src/utils/AsyncStore.ts'; +import { + MessageListImplementationConfigItem, + MessageListModeConfigItem, + MessageListPruningConfigItem, +} from './src/components/SecretMenu.tsx'; init({ data }); @@ -91,7 +96,15 @@ const Stack = createStackNavigator(); const UserSelectorStack = createStackNavigator(); const App = () => { const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient(); - const [messageListImplementation, setMessageListImplementation] = useState<'flashlist' | 'flatlist' | null>(null); + const [messageListImplementation, setMessageListImplementation] = useState< + MessageListImplementationConfigItem['id'] | undefined + >(undefined); + const [messageListMode, setMessageListMode] = useState< + MessageListModeConfigItem['mode'] | undefined + >(undefined); + const [messageListPruning, setMessageListPruning] = useState< + MessageListPruningConfigItem['value'] | undefined + >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); @@ -133,14 +146,26 @@ const App = () => { } } }); - const getMessageListImplementation = async () => { - const storedValue = await AsyncStore.getItem( + const getMessageListConfig = async () => { + const messageListImplementationStoredValue = await AsyncStore.getItem( '@stream-rn-sampleapp-messagelist-implementation', - { id: 'flashlist' } + { id: 'flatlist' }, ); - setMessageListImplementation(storedValue?.id as ('flashlist' | 'flatlist')); - } - getMessageListImplementation(); + const messageListModeStoredValue = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-mode', + { mode: 'default' }, + ); + const messageListPruningStoredValue = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-pruning', + { value: undefined }, + ); + setMessageListImplementation( + messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'], + ); + setMessageListMode(messageListModeStoredValue?.mode as MessageListModeConfigItem['mode']); + setMessageListPruning(messageListPruningStoredValue?.value as MessageListPruningConfigItem['value']); + }; + getMessageListConfig(); return () => { unsubscribeOnNotificationOpen(); unsubscribeForegroundEvent(); @@ -172,7 +197,7 @@ const App = () => { }); }, [chatClient]); - if (!messageListImplementation) { + if (!messageListImplementation || !messageListMode) { return; } @@ -194,7 +219,17 @@ const App = () => { dark: colorScheme === 'dark', }} > - + {isConnecting && !chatClient ? ( ) : chatClient ? ( diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index e1ecfaea7c..b0903a998d 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -37,7 +37,7 @@ "@react-navigation/drawer": "7.4.1", "@react-navigation/native": "^7.1.10", "@react-navigation/stack": "^7.3.3", - "@shopify/flash-list": "^2.0.3", + "@shopify/flash-list": "^2.1.0", "emoji-mart": "^5.6.0", "lodash.mergewith": "^4.6.2", "react": "19.1.0", diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 95762b85eb..0037645b82 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -12,13 +12,38 @@ import { View, Platform, StyleSheet, + ScrollView, } from 'react-native'; -import { Close, Notification, Delete, useTheme } from 'stream-chat-react-native'; +import { Close, Edit, Notification, Delete, Folder, ZIP, useTheme } from 'stream-chat-react-native'; import { styles as menuDrawerStyles } from './MenuDrawer.tsx'; import AsyncStore from '../utils/AsyncStore.ts'; import { StreamChat } from 'stream-chat'; import { LabeledTextInput } from '../screens/AdvancedUserSelectorScreen.tsx'; +const isAndroid = Platform.OS === 'android'; + +export type NotificationConfigItem = { label: string; name: string; id: string }; +export type MessageListImplementationConfigItem = { label: string; id: 'flatlist' | 'flashlist' }; +export type MessageListModeConfigItem = { label: string; mode: 'default' | 'livestream' }; +export type MessageListPruningConfigItem = { label: string; value: 100 | 500 | 1000 | undefined }; + +const messageListImplementationConfigItems: MessageListImplementationConfigItem[] = [ + { label: 'FlatList', id: 'flatlist' }, + { label: 'FlashList', id: 'flashlist' }, +]; + +const messageListModeConfigItems: MessageListModeConfigItem[] = [ + { label: 'Default', mode: 'default' }, + { label: 'Livestreaming', mode: 'livestream' }, +]; + +const messageListPruningConfigItems: MessageListPruningConfigItem[] = [ + { label: 'None', value: undefined }, + { label: '100 Messages', value: 100 }, + { label: '500 Messages', value: 500 }, + { label: '1000 Messages', value: 1000 }, +]; + export const SlideInView = ({ visible, children, @@ -55,11 +80,6 @@ export const SlideInView = ({ ); }; -const isAndroid = Platform.OS === 'android'; - -type NotificationConfigItem = { label: string; name: string; id: string }; -type MessageListImplementationConfigItem = { label: string; id: string }; - const SecretMenuNotificationConfigItem = ({ notificationConfigItem, storeProvider, @@ -124,7 +144,7 @@ const SecretMenuNotificationConfigItem = ({ ); }; -const SecretMenuMessageListConfigItem = ({ +const SecretMenuMessageListImplementationConfigItem = ({ messageListImplementationConfigItem, storeMessageListImplementation, isSelected, @@ -141,9 +161,43 @@ const SecretMenuMessageListConfigItem = ({ ); +const SecretMenuMessageListModeConfigItem = ({ + messageListModeConfigItem, + storeMessageListMode, + isSelected, +}: { + messageListModeConfigItem: MessageListModeConfigItem; + storeMessageListMode: (item: MessageListModeConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListMode(messageListModeConfigItem)} + > + {messageListModeConfigItem.label} + +); + +const SecretMenuMessageListPruningConfigItem = ({ + messageListPruningConfigItem, + storeMessageListPruning, + isSelected, +}: { + messageListPruningConfigItem: MessageListPruningConfigItem; + storeMessageListPruning: (item: MessageListPruningConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListPruning(messageListPruningConfigItem)} + > + {messageListPruningConfigItem.label} + +); + /* -* TODO: Please rewrite this entire component. -*/ + * TODO: Please rewrite this entire component. + */ export const SecretMenu = ({ close, @@ -156,7 +210,13 @@ export const SecretMenu = ({ }) => { const [selectedProvider, setSelectedProvider] = useState(null); const [selectedMessageListImplementation, setSelectedMessageListImplementation] = useState< - string | null + MessageListImplementationConfigItem['id'] | null + >(null); + const [selectedMessageListMode, setSelectedMessageListMode] = useState< + MessageListModeConfigItem['mode'] | null + >(null); + const [selectedMessageListPruning, setSelectedMessageListPruning] = useState< + MessageListPruningConfigItem['value'] | null >(null); const { theme: { @@ -172,14 +232,6 @@ export const SecretMenu = ({ [], ); - const messageListImplementationConfigItems = useMemo( - () => [ - { label: 'FlashList', id: 'flashlist' }, - { label: 'FlatList', id: 'flatlist' }, - ], - [], - ); - useEffect(() => { const getSelectedConfig = async () => { const notificationProvider = await AsyncStore.getItem( @@ -190,11 +242,23 @@ export const SecretMenu = ({ '@stream-rn-sampleapp-messagelist-implementation', messageListImplementationConfigItems[0], ); - setSelectedProvider(notificationProvider?.id ?? 'firebase'); - setSelectedMessageListImplementation(messageListImplementation?.id ?? 'flashlist'); + const messageListMode = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-mode', + messageListModeConfigItems[0], + ); + const messageListPruning = await AsyncStore.getItem( + '@stream-rn-sampleapp-messagelist-pruning', + messageListPruningConfigItems[0], + ); + setSelectedProvider(notificationProvider?.id ?? notificationConfigItems[0].id); + setSelectedMessageListImplementation( + messageListImplementation?.id ?? messageListImplementationConfigItems[0].id, + ); + setSelectedMessageListMode(messageListMode?.mode ?? messageListModeConfigItems[0].mode); + setSelectedMessageListPruning(messageListPruning?.value); }; getSelectedConfig(); - }, [notificationConfigItems, messageListImplementationConfigItems]); + }, [notificationConfigItems]); const storeProvider = useCallback(async (item: NotificationConfigItem) => { await AsyncStore.setItem('@stream-rn-sampleapp-push-provider', item); @@ -209,6 +273,16 @@ export const SecretMenu = ({ [], ); + const storeMessageListMode = useCallback(async (item: MessageListModeConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messagelist-mode', item); + setSelectedMessageListMode(item.mode); + }, []); + + const storeMessageListPruning = useCallback(async (item: MessageListPruningConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messagelist-pruning', item); + setSelectedMessageListPruning(item.value); + }, []); + const removeAllDevices = useCallback(async () => { const { devices } = await chatClient.getDevices(chatClient.userID); for (const device of devices ?? []) { @@ -218,74 +292,108 @@ export const SecretMenu = ({ return ( - - - + + + + + + Notification Provider + + + {notificationConfigItems.map((item) => ( + + ))} + + + + + + + Message List implementation + + {messageListImplementationConfigItems.map((item) => ( + + ))} + + + + + + + Message List mode + + {messageListModeConfigItems.map((item) => ( + + ))} + + + + + + + Message List pruning + + {messageListPruningConfigItems.map((item) => ( + + ))} + + + + + - Notification Provider + Remove all devices - - {notificationConfigItems.map((item) => ( - - ))} - - - - - - - Message List implementation - - {messageListImplementationConfigItems.map((item) => ( - - ))} - - - - - - - Remove all devices - - - - - - Close - - + + + + + Close + + + diff --git a/examples/SampleApp/src/context/AppContext.ts b/examples/SampleApp/src/context/AppContext.ts index 6a75fb8e99..9ec7c80990 100644 --- a/examples/SampleApp/src/context/AppContext.ts +++ b/examples/SampleApp/src/context/AppContext.ts @@ -3,13 +3,20 @@ import React from 'react'; import type { StreamChat } from 'stream-chat'; import type { LoginConfig } from '../types'; +import { + MessageListImplementationConfigItem, + MessageListModeConfigItem, + MessageListPruningConfigItem, +} from '../components/SecretMenu.tsx'; type AppContextType = { chatClient: StreamChat | null; loginUser: (config: LoginConfig) => void; logout: () => void; switchUser: (userId?: string) => void; - messageListImplementation: 'flatlist' | 'flashlist'; + messageListImplementation: MessageListImplementationConfigItem['id']; + messageListMode: MessageListModeConfigItem['mode']; + messageListPruning: MessageListPruningConfigItem['value']; }; export const AppContext = React.createContext({} as AppContextType); diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index e4a0c06653..9a48ff6ba9 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -5,6 +5,7 @@ import { Channel, ChannelAvatar, MessageInput, + MessageList, MessageFlashList, ThreadContextValue, useAttachmentPickerContext, @@ -32,7 +33,6 @@ import { channelMessageActions } from '../utils/messageActions.tsx'; import { MessageLocation } from '../components/LocationSharing/MessageLocation.tsx'; import { useStreamChatContext } from '../context/StreamChatContext.tsx'; import { CustomAttachmentPickerSelectionBar } from '../components/AttachmentPickerSelectionBar.tsx'; -import { MessageList } from 'stream-chat-react-native-core'; export type ChannelScreenNavigationProp = StackNavigationProp< StackNavigatorParamList, @@ -119,7 +119,8 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient, messageListImplementation } = useAppContext(); + const { chatClient, messageListImplementation, messageListMode, messageListPruning } = + useAppContext(); const navigation = useNavigation(); const { bottom } = useSafeAreaInsets(); const { @@ -215,12 +216,13 @@ export const ChannelScreen: React.FC = ({ messageId={messageId} NetworkDownIndicator={() => null} thread={selectedThread} + maximumMessageLimit={messageListPruning} > {messageListImplementation === 'flashlist' ? ( - + ) : ( - + )} diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index ff8c1c7832..b95479fc13 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -2620,12 +2620,10 @@ read-yaml-file "^2.1.0" strip-json-comments "^3.1.1" -"@shopify/flash-list@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.3.tgz#222427d1e09bf5cdd8a219d0a5a80f6f1d20465d" - integrity sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA== - dependencies: - tslib "2.8.1" +"@shopify/flash-list@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.1.0.tgz#b1eefcf9fbd01ca04a5f24a6003cda3b46a59f64" + integrity sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg== "@sideway/address@^4.1.5": version "4.1.5" @@ -3474,6 +3472,15 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" +axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + axios@^1.6.0: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" @@ -8179,14 +8186,14 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.17.0: - version "9.17.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.17.0.tgz#540cf1ea03b08a394d6140696aae8528e9ba9ce2" - integrity sha512-ys6K73wIVWs5+qsfPJ9wumEUtgbMXYVbH1dhmAZ1oYtQ01dY/avsvt25PYDakVjKeyrnT+y8T/xEzfeF/WDJsg== +stream-chat@^9.23.0: + version "9.23.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.23.0.tgz#e7e5cf729861597e7198907c1cab22a57d68a2fc" + integrity sha512-UW112HYsLnYb4RMIXBtAouNQCCe0weVzNivjezsw+JKK1b/TX0JLBi+wK25mBUEO+coOGKfXiye6IB3gao8ipw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.6.0" + axios "^1.12.2" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" @@ -8450,16 +8457,16 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -tslib@2.8.1, tslib@^2.1.0, tslib@^2.4.0: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.1.0, tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" diff --git a/package/package.json b/package/package.json index dfe68a4321..a892d446d7 100644 --- a/package/package.json +++ b/package/package.json @@ -79,14 +79,14 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.17.0", + "stream-chat": "^9.23.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@emoji-mart/data": ">=1.1.0", "@op-engineering/op-sqlite": ">=14.0.0", "@react-native-community/netinfo": ">=11.3.1", - "@shopify/flash-list": ">=2.0.3", + "@shopify/flash-list": ">=2.1.0", "emoji-mart": ">=5.4.0", "react-native": ">=0.73.0", "react-native-gesture-handler": ">=2.18.0", @@ -112,11 +112,11 @@ "@babel/core": "^7.27.4", "@babel/runtime": "^7.27.6", "@op-engineering/op-sqlite": "^14.0.3", + "@shopify/flash-list": "^2.1.0", "@react-native-community/eslint-config": "3.2.0", "@react-native-community/eslint-plugin": "1.3.0", "@react-native-community/netinfo": "^11.4.1", "@react-native/babel-preset": "0.79.3", - "@shopify/flash-list": "^2.0.3", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "13.2.0", "@types/better-sqlite3": "^7.6.13", diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 523e2d7297..9617284455 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -87,6 +87,7 @@ import { useStableCallback, useViewport } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; +import { usePrunableMessageList } from '../../hooks/usePrunableMessageList'; import { LOLReaction, LoveReaction, @@ -284,6 +285,7 @@ export type ChannelPropsWithContext = Pick & | 'maxTimeBetweenGroupedMessages' | 'NetworkDownIndicator' | 'StickyHeader' + | 'maximumMessageLimit' > > & Pick & @@ -706,6 +708,7 @@ const ChannelWithContext = (props: PropsWithChildren) = VideoAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, VideoThumbnail = VideoThumbnailDefault, isOnline, + maximumMessageLimit, } = props; const { thread: threadProps, threadInstance } = threadFromProps; @@ -761,7 +764,7 @@ const ChannelWithContext = (props: PropsWithChildren) = } = useChannelDataState(channel); const { - copyMessagesStateFromChannel, + copyMessagesStateFromChannel: rawCopyMessagesStateFromChannel, loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, loadInitialMessagesStateFromChannel, @@ -773,6 +776,9 @@ const ChannelWithContext = (props: PropsWithChildren) = channel, }); + const { setMessages: copyMessagesStateFromChannel, viewabilityChangedCallback } = + usePrunableMessageList({ maximumMessageLimit, setMessages: rawCopyMessagesStateFromChannel }); + const setReadThrottled = useMemo( () => throttle( @@ -1702,6 +1708,7 @@ const ChannelWithContext = (props: PropsWithChildren) = loading: channelMessagesState.loading, LoadingIndicator, markRead, + maximumMessageLimit, maxTimeBetweenGroupedMessages, members: channelState.members ?? {}, NetworkDownIndicator, @@ -1804,6 +1811,7 @@ const ChannelWithContext = (props: PropsWithChildren) = loadMore, loadMoreRecent, messages: channelMessagesState.messages ?? [], + viewabilityChangedCallback, }); const messagesContext = useCreateMessagesContext({ diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 108510e08f..2abb66883e 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -21,6 +21,7 @@ export const useCreateChannelContext = ({ LoadingIndicator, markRead, maxTimeBetweenGroupedMessages, + maximumMessageLimit, members, NetworkDownIndicator, read, @@ -64,6 +65,7 @@ export const useCreateChannelContext = ({ loading, LoadingIndicator, markRead, + maximumMessageLimit, maxTimeBetweenGroupedMessages, members, NetworkDownIndicator, @@ -96,6 +98,7 @@ export const useCreateChannelContext = ({ targetedMessage, threadList, watcherCount, + maximumMessageLimit, ], ); diff --git a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts index 920175183a..a06bafa032 100644 --- a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts +++ b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts @@ -15,6 +15,7 @@ export const useCreatePaginatedMessageListContext = ({ messages, setLoadingMore, setLoadingMoreRecent, + viewabilityChangedCallback, }: PaginatedMessageListContextValue & { channelId?: string; }) => { @@ -31,9 +32,10 @@ export const useCreatePaginatedMessageListContext = ({ messages, setLoadingMore, setLoadingMoreRecent, + viewabilityChangedCallback, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, hasMore, loadingMore, loadingMoreRecent, messagesStr], + [channelId, hasMore, loadingMore, loadingMoreRecent, messagesStr, viewabilityChangedCallback], ); return paginatedMessagesContext; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index 8fccb6d522..a902844f59 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { type SetStateAction, useEffect, useMemo, useState } from 'react'; import throttle from 'lodash/throttle'; import type { Channel, ChannelState, Event, MessageResponse, StreamChat } from 'stream-chat'; @@ -8,6 +8,15 @@ import { useIsChannelMuted } from './useIsChannelMuted'; import { useLatestMessagePreview } from './useLatestMessagePreview'; import { useChannelsContext } from '../../../contexts'; +import { useStableCallback } from '../../../hooks'; + +const setLastMessageThrottleTimeout = 500; +const setLastMessageThrottleOptions = { leading: true, trailing: true }; + +const refreshUnreadCountThrottleTimeout = 400; +const refreshUnreadCountThrottleOptions = setLastMessageThrottleOptions; + +type LastMessageType = ReturnType | MessageResponse; export const useChannelPreviewData = ( channel: Channel, @@ -15,9 +24,21 @@ export const useChannelPreviewData = ( forceUpdateOverride?: number, ) => { const [forceUpdate, setForceUpdate] = useState(0); - const [lastMessage, setLastMessage] = useState< - ReturnType | MessageResponse - >(channel.state.messages[channel.state.messages.length - 1]); + const [lastMessage, setLastMessageInner] = useState( + () => channel.state.messages[channel.state.messages.length - 1], + ); + const throttledSetLastMessage = useMemo( + () => + throttle( + (newLastMessage: SetStateAction) => setLastMessageInner(newLastMessage), + setLastMessageThrottleTimeout, + setLastMessageThrottleOptions, + ), + [], + ); + const setLastMessage = useStableCallback((newLastMessage: SetStateAction) => + throttledSetLastMessage(newLastMessage), + ); const [unread, setUnread] = useState(channel.countUnread()); const { muted } = useIsChannelMuted(channel); const { forceUpdate: contextForceUpdate } = useChannelsContext(); @@ -26,6 +47,22 @@ export const useChannelPreviewData = ( const channelLastMessage = channel.lastMessage(); const channelLastMessageString = `${channelLastMessage?.id}${channelLastMessage?.updated_at}`; + const refreshUnreadCount = useMemo( + () => + throttle( + () => { + if (muted) { + setUnread(0); + } else { + setUnread(channel.countUnread()); + } + }, + refreshUnreadCountThrottleTimeout, + refreshUnreadCountThrottleOptions, + ), + [channel, muted], + ); + useEffect(() => { const unsubscribe = channel.messageComposer.registerDraftEventSubscriptions(); return () => unsubscribe(); @@ -33,22 +70,27 @@ export const useChannelPreviewData = ( useEffect(() => { const { unsubscribe } = client.on('notification.mark_read', () => { - setUnread(channel.countUnread()); + refreshUnreadCount(); }); return unsubscribe; - }, [channel, client]); + }, [client, refreshUnreadCount]); useEffect(() => { - if ( + setLastMessage((prevLastMessage) => channelLastMessage && - (channelLastMessage.id !== lastMessage?.id || - channelLastMessage.updated_at !== lastMessage?.updated_at) - ) { - setLastMessage(channelLastMessage); - } - const newUnreadCount = channel.countUnread(); - setUnread(newUnreadCount); - }, [channel, channelLastMessage, channelLastMessageString, channelListForceUpdate, lastMessage]); + (channelLastMessage.id !== prevLastMessage?.id || + channelLastMessage.updated_at !== prevLastMessage?.updated_at) + ? channelLastMessage + : prevLastMessage, + ); + refreshUnreadCount(); + }, [ + channelLastMessage, + channelLastMessageString, + channelListForceUpdate, + setLastMessage, + refreshUnreadCount, + ]); /** * This effect listens for the `notification.mark_read` event and sets the unread count to 0 @@ -91,18 +133,6 @@ export const useChannelPreviewData = ( return unsubscribe; }, [client, channel]); - const refreshUnreadCount = useMemo( - () => - throttle(() => { - if (muted) { - setUnread(0); - } else { - setUnread(channel.countUnread()); - } - }, 400), - [channel, muted], - ); - /** * This effect listens for the `message.new`, `message.updated`, `message.deleted`, `message.undeleted`, and `channel.truncated` events */ @@ -118,7 +148,7 @@ export const useChannelPreviewData = ( const message = event.message; if (message && (!message.parent_id || message.show_in_channel)) { setLastMessage(message); - setUnread(channel.countUnread()); + refreshUnreadCount(); } }; @@ -140,7 +170,7 @@ export const useChannelPreviewData = ( ]; return () => listeners.forEach((l) => l.unsubscribe()); - }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate]); + }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]); const latestMessagePreview = useLatestMessagePreview(channel, forceUpdate, lastMessage); diff --git a/package/src/components/Message/MessageSimple/MessageBubble.tsx b/package/src/components/Message/MessageSimple/MessageBubble.tsx new file mode 100644 index 0000000000..be813308dc --- /dev/null +++ b/package/src/components/Message/MessageSimple/MessageBubble.tsx @@ -0,0 +1,235 @@ +import React, { useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; + +import Animated, { + Extrapolation, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import { MessageContentProps } from './MessageContent'; +import { MessageSimplePropsWithContext } from './MessageSimple'; +import { ReactionListTopProps } from './ReactionList/ReactionListTop'; + +import { MessagesContextValue, useTheme } from '../../../contexts'; + +import { NativeHandlers } from '../../../native'; + +export type MessageBubbleProps = Pick< + MessageSimplePropsWithContext, + 'reactionListPosition' | 'MessageContent' | 'ReactionListTop' +> & + Pick< + MessageContentProps, + | 'isVeryLastMessage' + | 'backgroundColor' + | 'messageGroupedSingleOrBottom' + | 'noBorder' + | 'setMessageContentWidth' + > & + Pick; + +export const MessageBubble = React.memo( + ({ + reactionListPosition, + messageContentWidth, + setMessageContentWidth, + MessageContent, + ReactionListTop, + backgroundColor, + isVeryLastMessage, + messageGroupedSingleOrBottom, + noBorder, + }: MessageBubbleProps) => { + const { + theme: { + messageSimple: { contentWrapper }, + }, + } = useTheme(); + + return ( + + + {reactionListPosition === 'top' && ReactionListTop ? ( + + ) : null} + + ); + }, +); + +const AnimatedWrapper = Animated.createAnimatedComponent(View); + +export const SwipableMessageBubble = React.memo( + ( + props: MessageBubbleProps & + Pick & + Pick< + MessageSimplePropsWithContext, + 'shouldRenderSwipeableWrapper' | 'messageSwipeToReplyHitSlop' + > & { onSwipe: () => void }, + ) => { + const { + MessageSwipeContent, + shouldRenderSwipeableWrapper, + messageSwipeToReplyHitSlop, + onSwipe, + ...messageBubbleProps + } = props; + + const { + theme: { + messageSimple: { contentWrapper, swipeContentContainer }, + }, + } = useTheme(); + + const translateX = useSharedValue(0); + const touchStart = useSharedValue<{ x: number; y: number } | null>(null); + const isSwiping = useSharedValue(false); + const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState( + shouldRenderSwipeableWrapper, + ); + + const SWIPABLE_THRESHOLD = 25; + + const triggerHaptic = NativeHandlers.triggerHaptic; + + const swipeGesture = useMemo( + () => + Gesture.Pan() + .hitSlop(messageSwipeToReplyHitSlop) + .onBegin((event) => { + touchStart.value = { x: event.x, y: event.y }; + }) + .onTouchesMove((event, state) => { + if (!touchStart.value || !event.changedTouches.length) { + state.fail(); + return; + } + + const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x); + const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y); + const isHorizontalPanning = xDiff > yDiff; + + if (isHorizontalPanning) { + state.activate(); + isSwiping.value = true; + if (!shouldRenderSwipeableWrapper) { + runOnJS(setShouldRenderAnimatedWrapper)(isSwiping.value); + } + } else { + state.fail(); + } + }) + .onStart(() => { + translateX.value = 0; + }) + .onChange(({ translationX }) => { + if (translationX > 0) { + translateX.value = translationX; + } + }) + .onEnd(() => { + if (translateX.value >= SWIPABLE_THRESHOLD) { + runOnJS(onSwipe)(); + if (triggerHaptic) { + runOnJS(triggerHaptic)('impactMedium'); + } + } + isSwiping.value = false; + translateX.value = withSpring( + 0, + { + dampingRatio: 1, + duration: 500, + overshootClamping: true, + stiffness: 1, + }, + () => { + if (!shouldRenderSwipeableWrapper) { + runOnJS(setShouldRenderAnimatedWrapper)(isSwiping.value); + } + }, + ); + }), + [ + isSwiping, + messageSwipeToReplyHitSlop, + onSwipe, + touchStart, + translateX, + triggerHaptic, + shouldRenderSwipeableWrapper, + ], + ); + + const messageBubbleAnimatedStyle = useAnimatedStyle( + () => ({ + transform: [{ translateX: translateX.value }], + }), + [], + ); + + const swipeContentAnimatedStyle = useAnimatedStyle( + () => ({ + opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]), + transform: [ + { + translateX: interpolate( + translateX.value, + [0, SWIPABLE_THRESHOLD], + [-SWIPABLE_THRESHOLD, 0], + Extrapolation.CLAMP, + ), + }, + ], + }), + [], + ); + + return ( + + + {shouldRenderAnimatedWrapper ? ( + <> + + {MessageSwipeContent ? : null} + + + + + + ) : ( + + )} + + + ); + }, +); + +const styles = StyleSheet.create({ + contentWrapper: { + alignItems: 'center', + flexDirection: 'row', + }, + swipeContentContainer: { + position: 'absolute', + }, +}); diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 635adabb1c..472d4c47be 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -1,18 +1,7 @@ import React, { useMemo, useState } from 'react'; import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; - -import Animated, { - Extrapolation, - interpolate, - runOnJS, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; - -const AnimatedWrapper = Animated.createAnimatedComponent(View); +import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; import { MessageContextValue, @@ -25,7 +14,6 @@ import { import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../../hooks/useStableCallback'; -import { NativeHandlers } from '../../../native'; import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { useMessageData } from '../hooks/useMessageData'; @@ -36,10 +24,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, contentContainer: {}, - contentWrapper: { - alignItems: 'center', - flexDirection: 'row', - }, lastMessageContainer: { marginBottom: 12, }, @@ -53,9 +37,6 @@ const styles = StyleSheet.create({ rightAlignItems: { alignItems: 'flex-end', }, - swipeContentContainer: { - position: 'absolute', - }, }); export type MessageSimplePropsWithContext = Pick< @@ -149,13 +130,11 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { receiverMessageBackgroundColor, senderMessageBackgroundColor, }, - contentWrapper, headerWrapper, lastMessageContainer, messageGroupedSingleOrBottomContainer, messageGroupedTopContainer, reactionListTop: { position: reactionPosition }, - swipeContentContainer, }, }, } = useTheme(); @@ -212,13 +191,6 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { const repliesCurveColor = isMessageReceivedOrErrorType ? grey_gainsboro : backgroundColor; - const translateX = useSharedValue(0); - const touchStart = useSharedValue<{ x: number; y: number } | null>(null); - const isSwiping = useSharedValue(false); - const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState( - shouldRenderSwipeableWrapper, - ); - const onSwipeActionHandler = useStableCallback(() => { if (customMessageSwipeAction) { customMessageSwipeAction({ channel, message }); @@ -227,169 +199,6 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { setQuotedMessage(message); }); - const THRESHOLD = 25; - - const triggerHaptic = NativeHandlers.triggerHaptic; - - const swipeGesture = useMemo( - () => - Gesture.Pan() - .hitSlop(messageSwipeToReplyHitSlop) - .onBegin((event) => { - touchStart.value = { x: event.x, y: event.y }; - }) - .onTouchesMove((event, state) => { - if (!touchStart.value || !event.changedTouches.length) { - state.fail(); - return; - } - - const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x); - const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y); - const isHorizontalPanning = xDiff > yDiff; - - if (isHorizontalPanning) { - state.activate(); - isSwiping.value = true; - if (!shouldRenderSwipeableWrapper) { - runOnJS(setShouldRenderAnimatedWrapper)(isSwiping.value); - } - } else { - state.fail(); - } - }) - .onStart(() => { - translateX.value = 0; - }) - .onChange(({ translationX }) => { - if (translationX > 0) { - translateX.value = translationX; - } - }) - .onEnd(() => { - if (translateX.value >= THRESHOLD) { - runOnJS(onSwipeActionHandler)(); - if (triggerHaptic) { - runOnJS(triggerHaptic)('impactMedium'); - } - } - isSwiping.value = false; - translateX.value = withSpring( - 0, - { - dampingRatio: 1, - duration: 500, - overshootClamping: true, - stiffness: 1, - }, - () => { - if (!shouldRenderSwipeableWrapper) { - runOnJS(setShouldRenderAnimatedWrapper)(isSwiping.value); - } - }, - ); - }), - [ - isSwiping, - messageSwipeToReplyHitSlop, - onSwipeActionHandler, - touchStart, - translateX, - triggerHaptic, - shouldRenderSwipeableWrapper, - ], - ); - - const messageBubbleAnimatedStyle = useAnimatedStyle( - () => ({ - transform: [{ translateX: translateX.value }], - }), - [], - ); - - const swipeContentAnimatedStyle = useAnimatedStyle( - () => ({ - opacity: interpolate(translateX.value, [0, THRESHOLD], [0, 1]), - transform: [ - { - translateX: interpolate( - translateX.value, - [0, THRESHOLD], - [-THRESHOLD, 0], - Extrapolation.CLAMP, - ), - }, - ], - }), - [], - ); - - const renderMessageBubble = useMemo( - () => ( - - - {reactionListPosition === 'top' && ReactionListTop ? ( - - ) : null} - - ), - [ - messageContentWidth, - reactionListPosition, - MessageContent, - ReactionListTop, - backgroundColor, - contentWrapper, - isVeryLastMessage, - messageGroupedSingleOrBottom, - noBorder, - ], - ); - - const renderAnimatedMessageBubble = useMemo( - () => ( - - - {shouldRenderAnimatedWrapper ? ( - <> - - {MessageSwipeContent ? : null} - - - {renderMessageBubble} - - - ) : ( - renderMessageBubble - )} - - - ), - [ - MessageSwipeContent, - contentWrapper, - shouldRenderAnimatedWrapper, - messageBubbleAnimatedStyle, - messageSwipeToReplyHitSlop, - renderMessageBubble, - swipeContentAnimatedStyle, - swipeContentContainer, - swipeGesture, - ], - ); - return ( { )} {message.pinned ? : null} - {enableSwipeToReply ? renderAnimatedMessageBubble : renderMessageBubble} + {enableSwipeToReply ? ( + + ) : ( + + )} {reactionListPosition === 'bottom' && ReactionListBottom ? : null} diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 863615053b..a5cdce21bc 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -118,6 +118,7 @@ type MessageFlashListPropsWithContext = Pick< | 'StickyHeader' | 'targetedMessage' | 'threadList' + | 'maximumMessageLimit' > & Pick & Pick & @@ -200,6 +201,15 @@ type MessageFlashListPropsWithContext = Pick< * ``` */ setFlatListRef?: (ref: FlashListRef | null) => void; + /** + * If true, the message list will be used in a live-streaming scenario. + * This flag is used to make sure that the auto scroll behaves well, if multiple messages are received. + * + * This flag is experimental and is subject to change. Please test thoroughly before using it. + * + * @experimental + */ + isLiveStreaming?: boolean; }; const WAIT_FOR_SCROLL_TIMEOUT = 0; @@ -262,6 +272,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => InlineDateSeparator, InlineUnreadIndicator, isListActive = false, + isLiveStreaming = false, legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, @@ -271,6 +282,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreRecentThread, loadMoreThread, markRead, + maximumMessageLimit, Message, MessageSystem, myMessageTheme, @@ -303,7 +315,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); - const [autoScrollToRecent, setAutoScrollToRecent] = useState(false); const stickyHeaderDateRef = useRef(undefined); /** @@ -337,12 +348,18 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [myMessageThemeString, theme], ); - const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = - useMessageList({ - isFlashList: true, - noGroupByUser, - threadList, - }); + const { + dateSeparatorsRef, + messageGroupStylesRef, + processedMessageList, + rawMessageList, + viewabilityChangedCallback, + } = useMessageList({ + isFlashList: true, + isLiveStreaming, + noGroupByUser, + threadList, + }); /** * We need topMessage and channelLastRead values to set the initial scroll position. @@ -366,13 +383,23 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [processedMessageList], ); + const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); + + useEffect(() => { + if (autoscrollToRecent && flashListRef.current) { + flashListRef.current.scrollToEnd({ + animated: true, + }); + } + }, [autoscrollToRecent]); + const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, - autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, + autoscrollToBottomThreshold: autoscrollToRecent ? 1 : undefined, startRenderingFromBottom: true, }; - }, [autoScrollToRecent, threadList]); + }, [autoscrollToRecent]); useEffect(() => { if (disabled) { @@ -380,6 +407,18 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [disabled]); + const indexToScrollToRef = useRef(undefined); + + const initialIndexToScrollTo = useMemo(() => { + return targetedMessage + ? processedMessageList.findIndex((message) => message?.id === targetedMessage) + : -1; + }, [processedMessageList, targetedMessage]); + + useEffect(() => { + indexToScrollToRef.current = initialIndexToScrollTo; + }, [initialIndexToScrollTo]); + /** * Check if a messageId needs to be scrolled to after list loads, and scroll to it * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender @@ -415,28 +454,15 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const indexOfParentInMessageList = processedMessageList.findIndex( (message) => message?.id === messageId, ); - if (indexOfParentInMessageList !== -1) { - flashListRef.current?.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, - }); - setTargetedMessage(messageId); - return; - } + + indexToScrollToRef.current = indexOfParentInMessageList; + try { if (indexOfParentInMessageList === -1) { clearTimeout(scrollToDebounceTimeoutRef.current); - await loadChannelAroundMessage({ messageId }); + await loadChannelAroundMessage({ messageId, setTargetedMessage }); + } else { setTargetedMessage(messageId); - - // now scroll to it with animated=true - flashListRef.current?.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - return; } } catch (e) { console.warn('Error while scrolling to message', e); @@ -471,25 +497,23 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setScrollToBottomButtonVisible(false); resetPaginationTrackersRef.current(); - setAutoScrollToRecent(true); setTimeout(() => { channelResyncScrollSet.current = true; if (channel.countUnread() > 0) { markRead(); } - setAutoScrollToRecent(false); }, WAIT_FOR_SCROLL_TIMEOUT); } }; - if (isMessageRemovedFromMessageList) { + if (isMessageRemovedFromMessageList && !maximumMessageLimit) { scrollToBottomIfNeeded(); } messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; topMessageBeforeUpdate.current = topMessageAfterUpdate; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id]); + }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]); useEffect(() => { if (!processedMessageList.length) { @@ -500,16 +524,18 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; - setAutoScrollToRecent(false); + setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; + } else { + indexToScrollToRef.current = undefined; + setAutoscrollToRecent(true); } const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; latestNonCurrentMessageBeforeUpdateRef.current = undefined; const latestCurrentMessageAfterUpdate = processedMessageList[processedMessageList.length - 1]; if (!latestCurrentMessageAfterUpdate) { - setAutoScrollToRecent(true); return; } const didMergeMessageSetsWithNoUpdates = @@ -527,28 +553,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); - /** - * Effect to scroll to the bottom of the message list when a new message is received if the scroll to bottom button is not visible. - */ - useEffect(() => { - const handleEvent = (event: Event) => { - if (event.message?.user?.id !== client.userID) { - if (!scrollToBottomButtonVisible) { - flashListRef.current?.scrollToEnd({ - animated: true, - }); - } else { - setAutoScrollToRecent(false); - } - } - }; - const listener: ReturnType = channel.on('message.new', handleEvent); - - return () => { - listener?.unsubscribe(); - }; - }, [channel, client.userID, scrollToBottomButtonVisible]); - /** * Effect to mark the channel as read when the user scrolls to the bottom of the message list. */ @@ -695,6 +699,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (!viewableItems) { return; } + viewabilityChangedCallback({ inverted: false, viewableItems }); if (!hideStickyDateHeader) { updateStickyHeaderDateIfNeeded(viewableItems); } @@ -1074,23 +1079,15 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } const flatListStyle = useMemo( - () => ({ ...styles.listContainer, ...listContainer, ...additionalFlashListProps?.style }), + () => [styles.listContainer, listContainer, additionalFlashListProps?.style], [additionalFlashListProps?.style, listContainer], ); const flatListContentContainerStyle = useMemo( - () => ({ - ...styles.contentContainer, - ...contentContainer, - }), + () => [styles.contentContainer, contentContainer], [contentContainer], ); - const getItemType = useStableCallback((item: LocalMessage) => { - const type = getItemTypeInternal(item); - return client.userID === item.user?.id ? `own-${type}` : type; - }); - const currentListHeightRef = useRef(undefined); const onLayout = useStableCallback((e: LayoutChangeEvent) => { @@ -1136,7 +1133,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => contentContainerStyle={flatListContentContainerStyle} data={processedMessageList} drawDistance={800} - getItemType={getItemType} + getItemType={getItemTypeInternal} + initialScrollIndex={ + indexToScrollToRef.current === -1 ? undefined : indexToScrollToRef.current + } keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={FooterComponent} @@ -1150,6 +1150,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} style={flatListStyle} testID='message-flash-list' @@ -1204,6 +1205,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loading, LoadingIndicator, markRead, + maximumMessageLimit, NetworkDownIndicator, reloadChannel, scrollToFirstUnreadThreshold, @@ -1263,6 +1265,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loadMoreRecentThread, loadMoreThread, markRead, + maximumMessageLimit, Message, MessageSystem, myMessageTheme, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 8cfc0eca48..8c0fedc147 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -140,6 +140,7 @@ type MessageListPropsWithContext = Pick< | 'StickyHeader' | 'targetedMessage' | 'threadList' + | 'maximumMessageLimit' > & Pick & Pick & @@ -275,6 +276,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMoreRecentThread, loadMoreThread, markRead, + maximumMessageLimit, Message, MessageSystem, myMessageTheme, @@ -322,12 +324,17 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * NOTE: rawMessageList changes only when messages array state changes * processedMessageList changes on any state change */ - const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = - useMessageList({ - isLiveStreaming, - noGroupByUser, - threadList, - }); + const { + dateSeparatorsRef, + messageGroupStylesRef, + processedMessageList, + rawMessageList, + viewabilityChangedCallback, + } = useMessageList({ + isLiveStreaming, + noGroupByUser, + threadList, + }); const messageListLengthBeforeUpdate = useRef(0); const messageListLengthAfterUpdate = processedMessageList.length; @@ -348,10 +355,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const minIndexForVisible = Math.min(1, processedMessageList.length); - const autoscrollToTopThreshold = useMemo( - () => (isLiveStreaming ? 64 : autoscrollToRecent ? 10 : undefined), - [autoscrollToRecent, isLiveStreaming], - ); + const autoscrollToTopThreshold = autoscrollToRecent ? (isLiveStreaming ? 300 : 10) : undefined; const maintainVisibleContentPosition = useMemo( () => ({ @@ -503,6 +507,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }: { viewableItems: ViewToken[] | undefined; }) => { + viewabilityChangedCallback({ inverted, viewableItems }); + if (!viewableItems) { return; } @@ -631,14 +637,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }; - if (threadList || isMessageRemovedFromMessageList) { + if (!maximumMessageLimit && (threadList || isMessageRemovedFromMessageList)) { scrollToBottomIfNeeded(); } messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; topMessageBeforeUpdate.current = topMessageAfterUpdate; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id]); + }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]); useEffect(() => { if (threadList) { @@ -673,7 +679,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { !didMergeMessageSetsWithNoUpdates || processedMessageList.length - messageListLengthBeforeUpdate.current > 0; - setAutoscrollToRecent(shouldForceScrollToRecent); + // we don't want this behaviour while pruning, as it may scroll unnecessarily in + // certain scenarios + if ((maximumMessageLimit && shouldForceScrollToRecent) || !maximumMessageLimit) { + setAutoscrollToRecent(shouldForceScrollToRecent); + } if (!didMergeMessageSetsWithNoUpdates) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); @@ -688,7 +698,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed } } - }, [channel, threadList, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef]); + }, [ + channel, + threadList, + processedMessageList, + shouldScrollToRecentOnNewOwnMessageRef, + maximumMessageLimit, + ]); const goToMessage = useStableCallback(async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( @@ -1288,6 +1304,7 @@ export const MessageList = (props: MessageListProps) => { loadChannelAroundMessage, loading, LoadingIndicator, + maximumMessageLimit, markRead, NetworkDownIndicator, reloadChannel, @@ -1348,6 +1365,7 @@ export const MessageList = (props: MessageListProps) => { loadMoreRecentThread, loadMoreThread, markRead, + maximumMessageLimit, Message, MessageSystem, myMessageTheme, diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 088e6e2ac9..01969ce221 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -56,7 +56,7 @@ export const useMessageList = (params: UseMessageListParams) => { const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = useMessagesContext(); - const { messages } = usePaginatedMessageListContext(); + const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; @@ -129,7 +129,8 @@ export const useMessageList = (params: UseMessageListParams) => { processedMessageList: data, /** Raw messages from the channel state */ rawMessageList: messageList, + viewabilityChangedCallback, }), - [data, messageList], + [data, messageList, viewabilityChangedCallback], ); }; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 62542bdedc..ce034b3e4c 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -147,6 +147,12 @@ export type ChannelContextValue = { * to still consider them grouped together */ maxTimeBetweenGroupedMessages?: number; + /** + * The maximum number of messages that can be loaded into the state when new messages arrive. + * Any excess messages will be pruned from the back of the list (oldest first), unless we are + * currently near them within the viewport. + */ + maximumMessageLimit?: number; /** * Custom UI component for sticky header of channel. * diff --git a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx b/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx index d9b51e038d..7f14c58439 100644 --- a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx +++ b/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx @@ -2,6 +2,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { ChannelState } from 'stream-chat'; +import { ViewabilityChangedCallbackInput } from '../../hooks/usePrunableMessageList'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -24,6 +25,10 @@ export type PaginatedMessageListContextValue = { * Messages from client state */ messages: ChannelState['messages']; + /** + * A callback that is to be passed to onViewableItemsChanged in the underlying `MessageList` + */ + viewabilityChangedCallback: (config: ViewabilityChangedCallbackInput) => void; /** * Has more messages to load */ diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts new file mode 100644 index 0000000000..4973ec74f1 --- /dev/null +++ b/package/src/hooks/usePrunableMessageList.ts @@ -0,0 +1,79 @@ +import { useRef } from 'react'; + +import { ViewToken } from 'react-native'; + +import { Channel } from 'stream-chat'; + +import { useStableCallback } from './useStableCallback'; + +import { ChannelPropsWithContext } from '../components'; + +export type VisibleRangeConfig = { first: number; last: number; inverted: boolean }; +export type ViewabilityChangedCallbackInput = { + viewableItems: ViewToken[] | undefined; + inverted: boolean; +}; + +// The number of messages from an edge we want to be viewing before we stop pruning +const calculateSafeGap = (maximumMessages: number) => 0.2 * maximumMessages; + +const isNearEnd = ({ + rangeConfig, + maximumMessageLimit, +}: { + rangeConfig: VisibleRangeConfig; + maximumMessageLimit: number; +}) => { + const { first, last, inverted } = rangeConfig; + + const safeGap = calculateSafeGap(maximumMessageLimit); + + if (!inverted) return first <= safeGap; + + return last >= maximumMessageLimit - 1 - safeGap; +}; + +export function usePrunableMessageList({ + // setter to update the array used by the List + setMessages: rawSetMessages, + maximumMessageLimit, +}: { + setMessages: (channel: Channel) => void; +} & Pick) { + // Track visible index range (index in `channel.messages`) + const visibleRangeConfigRef = useRef({ first: 0, inverted: true, last: -1 }); + + const viewabilityChangedCallback = useStableCallback( + ({ viewableItems, inverted = true }: ViewabilityChangedCallbackInput) => { + if (!viewableItems?.length || maximumMessageLimit == null) return; + let first = Infinity; + let last = -1; + for (const v of viewableItems) { + if (v.index == null) continue; + if (v.index < first) first = v.index; + if (v.index > last) last = v.index; + } + if (first !== Infinity) visibleRangeConfigRef.current = { first, inverted, last }; + }, + ); + + // Prune when length exceeds MAX, but only if the viewport is far from the back edge + const setMessages = useStableCallback((channel: Channel) => { + const rangeConfig = visibleRangeConfigRef.current; + + if ( + maximumMessageLimit == null || + channel.state.messages.length <= maximumMessageLimit || + isNearEnd({ maximumMessageLimit, rangeConfig }) + ) { + rawSetMessages(channel); + return; + } + + channel.state.pruneOldest(maximumMessageLimit); + + rawSetMessages(channel); + }); + + return { setMessages, viewabilityChangedCallback }; +} diff --git a/package/yarn.lock b/package/yarn.lock index f905a5a148..0e0cfcab86 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2120,12 +2120,10 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@shopify/flash-list@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.0.3.tgz#222427d1e09bf5cdd8a219d0a5a80f6f1d20465d" - integrity sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA== - dependencies: - tslib "2.8.1" +"@shopify/flash-list@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.1.0.tgz#b1eefcf9fbd01ca04a5f24a6003cda3b46a59f64" + integrity sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg== "@sinclair/typebox@^0.27.8": version "0.27.8" @@ -2956,13 +2954,13 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.6.0: - version "1.8.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.1.tgz#7c118d2146e9ebac512b7d1128771cdd738d11e3" - integrity sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g== +axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== dependencies: follow-redirects "^1.15.6" - form-data "^4.0.0" + form-data "^4.0.4" proxy-from-env "^1.1.0" b4a@^1.6.4: @@ -4651,16 +4649,6 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" - integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - mime-types "^2.1.12" - form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -8359,14 +8347,14 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat@^9.17.0: - version "9.17.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.17.0.tgz#540cf1ea03b08a394d6140696aae8528e9ba9ce2" - integrity sha512-ys6K73wIVWs5+qsfPJ9wumEUtgbMXYVbH1dhmAZ1oYtQ01dY/avsvt25PYDakVjKeyrnT+y8T/xEzfeF/WDJsg== +stream-chat@^9.23.0: + version "9.23.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.23.0.tgz#e7e5cf729861597e7198907c1cab22a57d68a2fc" + integrity sha512-UW112HYsLnYb4RMIXBtAouNQCCe0weVzNivjezsw+JKK1b/TX0JLBi+wK25mBUEO+coOGKfXiye6IB3gao8ipw== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.6.0" + axios "^1.12.2" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" @@ -8699,16 +8687,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.8.1, tslib@^2.4.0: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@^1.8.1: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"