From 752e24337c18d2a3062fe3c671597a1f307b0c0b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 1 Oct 2025 16:40:24 +0200 Subject: [PATCH 01/27] fix: add RAF coalescing --- .../MessageList/MessageFlashList.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 863615053b..adba0ac257 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -200,6 +200,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 +271,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => InlineDateSeparator, InlineUnreadIndicator, isListActive = false, + isLiveStreaming = false, legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, @@ -340,6 +350,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = useMessageList({ isFlashList: true, + isLiveStreaming, noGroupByUser, threadList, }); @@ -369,10 +380,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, - autoscrollToBottomThreshold: autoScrollToRecent || threadList ? 10 : undefined, + autoscrollToBottomThreshold: isLiveStreaming + ? 64 + : autoScrollToRecent || threadList + ? 10 + : undefined, startRenderingFromBottom: true, }; - }, [autoScrollToRecent, threadList]); + }, [autoScrollToRecent, threadList, isLiveStreaming]); useEffect(() => { if (disabled) { @@ -1150,6 +1165,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} style={flatListStyle} testID='message-flash-list' From db6bceb9d7224ec8019b386c7d3310093fecf061 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 2 Oct 2025 12:19:00 +0200 Subject: [PATCH 02/27] fix: correct import --- examples/SampleApp/src/screens/ChannelScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index e4a0c06653..5cc35675fa 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, From 36f28a8092a3934a5b1deb30ae0cce33b56cddb1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 3 Oct 2025 14:34:50 +0200 Subject: [PATCH 03/27] fix: livestreaming mode MVCP --- .../MessageList/MessageFlashList.tsx | 35 ++----------------- .../components/MessageList/MessageList.tsx | 5 +-- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index adba0ac257..16597fb714 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -313,7 +313,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); /** @@ -380,14 +379,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, - autoscrollToBottomThreshold: isLiveStreaming - ? 64 - : autoScrollToRecent || threadList - ? 10 - : undefined, + autoscrollToBottomThreshold: 1, startRenderingFromBottom: true, }; - }, [autoScrollToRecent, threadList, isLiveStreaming]); + }, []); useEffect(() => { if (disabled) { @@ -486,13 +481,11 @@ 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); } }; @@ -515,7 +508,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; - setAutoScrollToRecent(false); setScrollToBottomButtonVisible(true); return; } @@ -524,7 +516,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const latestCurrentMessageAfterUpdate = processedMessageList[processedMessageList.length - 1]; if (!latestCurrentMessageAfterUpdate) { - setAutoScrollToRecent(true); return; } const didMergeMessageSetsWithNoUpdates = @@ -542,28 +533,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. */ diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 8cfc0eca48..29e406fc26 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -348,10 +348,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 ? 64 : 10) : undefined; const maintainVisibleContentPosition = useMemo( () => ({ From 21eab51ac79dc5572dc4be1b1b1c600013050db4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 3 Oct 2025 14:35:57 +0200 Subject: [PATCH 04/27] perf: throttle certain channel preview updates to avoid stressing the state update queue --- .../hooks/useChannelPreviewData.ts | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) 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); From 14633682086a7f8b39acaf7251fa727bacc2714f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 3 Oct 2025 19:03:04 +0200 Subject: [PATCH 05/27] fix: goToMessage behaviour --- .../MessageList/MessageFlashList.tsx | 42 ++++--------------- 1 file changed, 9 insertions(+), 33 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 16597fb714..0f682c529b 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -376,13 +376,15 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [processedMessageList], ); + const [should, setShould] = useState(true); + const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, - autoscrollToBottomThreshold: 1, + autoscrollToBottomThreshold: should ? 1 : undefined, startRenderingFromBottom: true, }; - }, []); + }, [should]); useEffect(() => { if (disabled) { @@ -403,6 +405,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => (message) => message?.id === targetedMessage, ); + setShould(false); // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); @@ -416,41 +419,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => index: indexOfParentInMessageList, viewPosition: 0.5, }); + setShould(true); setTargetedMessage(undefined); - }, WAIT_FOR_SCROLL_TIMEOUT); + }, 100); } }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); - const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - if (indexOfParentInMessageList !== -1) { - flashListRef.current?.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, - }); - setTargetedMessage(messageId); - return; - } - try { - if (indexOfParentInMessageList === -1) { - clearTimeout(scrollToDebounceTimeoutRef.current); - await loadChannelAroundMessage({ messageId }); - 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); - } + const goToMessage = useStableCallback((messageId: string) => { + setTargetedMessage(messageId); }); useEffect(() => { From 8b6aefc8c6e75e1ff2c6cfdd8e01148b4368cbf9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Oct 2025 13:46:12 +0200 Subject: [PATCH 06/27] feat: add message pruning capabilities --- package/src/components/Channel/Channel.tsx | 12 ++- .../Channel/hooks/useCreateChannelContext.ts | 3 + .../useCreatePaginatedMessageListContext.ts | 4 +- .../MessageList/MessageFlashList.tsx | 38 +++++---- .../components/MessageList/MessageList.tsx | 41 +++++++--- .../MessageList/hooks/useMessageList.ts | 5 +- .../channelContext/ChannelContext.tsx | 6 ++ .../PaginatedMessageListContext.tsx | 5 ++ package/src/hooks/usePrunableMessageList.ts | 79 +++++++++++++++++++ 9 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 package/src/hooks/usePrunableMessageList.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 523e2d7297..5e8ef8ebff 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( @@ -1684,6 +1690,8 @@ const ChannelWithContext = (props: PropsWithChildren) = overrideCapabilities: overrideOwnCapabilities, }); + console.log('TEST: ', maximumMessageLimit); + const channelContext = useCreateChannelContext({ channel, channelUnreadState, @@ -1702,6 +1710,7 @@ const ChannelWithContext = (props: PropsWithChildren) = loading: channelMessagesState.loading, LoadingIndicator, markRead, + maximumMessageLimit, maxTimeBetweenGroupedMessages, members: channelState.members ?? {}, NetworkDownIndicator, @@ -1804,6 +1813,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/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 0f682c529b..f6b1d3af4f 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 & @@ -281,6 +282,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreRecentThread, loadMoreThread, markRead, + maximumMessageLimit, Message, MessageSystem, myMessageTheme, @@ -346,13 +348,18 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [myMessageThemeString, theme], ); - const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = - useMessageList({ - isFlashList: true, - isLiveStreaming, - 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. @@ -376,15 +383,15 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [processedMessageList], ); - const [should, setShould] = useState(true); + const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, - autoscrollToBottomThreshold: should ? 1 : undefined, + autoscrollToBottomThreshold: autoscrollToRecent ? 1 : undefined, startRenderingFromBottom: true, }; - }, [should]); + }, [autoscrollToRecent]); useEffect(() => { if (disabled) { @@ -405,7 +412,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => (message) => message?.id === targetedMessage, ); - setShould(false); + setAutoscrollToRecent(false); // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); @@ -419,7 +426,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => index: indexOfParentInMessageList, viewPosition: 0.5, }); - setShould(true); + setAutoscrollToRecent(true); setTargetedMessage(undefined); }, 100); } @@ -466,14 +473,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }; - 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) { @@ -655,6 +662,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (!viewableItems) { return; } + viewabilityChangedCallback({ inverted: false, viewableItems }); if (!hideStickyDateHeader) { updateStickyHeaderDateIfNeeded(viewableItems); } @@ -1165,6 +1173,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loading, LoadingIndicator, markRead, + maximumMessageLimit, NetworkDownIndicator, reloadChannel, scrollToFirstUnreadThreshold, @@ -1224,6 +1233,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 29e406fc26..e517fa175e 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; @@ -500,6 +507,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }: { viewableItems: ViewToken[] | undefined; }) => { + viewabilityChangedCallback({ inverted, viewableItems }); + if (!viewableItems) { return; } @@ -628,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) { @@ -670,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(); @@ -685,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( @@ -1285,6 +1304,7 @@ export const MessageList = (props: MessageListProps) => { loadChannelAroundMessage, loading, LoadingIndicator, + maximumMessageLimit, markRead, NetworkDownIndicator, reloadChannel, @@ -1345,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..a6c6e198a1 --- /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.pruneFromEnd(maximumMessageLimit); + + rawSetMessages(channel); + }); + + return { setMessages, viewabilityChangedCallback }; +} From 775d07c9fe75d69c801ed516f40872d526d0bdd9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Oct 2025 18:02:38 +0200 Subject: [PATCH 07/27] perf: only register animated values when STR is enabled --- .../Message/MessageSimple/MessageSimple.tsx | 405 ++++++++++-------- 1 file changed, 232 insertions(+), 173 deletions(-) diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index 635adabb1c..0e6d5ca812 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -14,6 +14,10 @@ import Animated, { const AnimatedWrapper = Animated.createAnimatedComponent(View); +import { MessageContentProps } from './MessageContent'; + +import { ReactionListTopProps } from './ReactionList/ReactionListTop'; + import { MessageContextValue, useMessageContext, @@ -103,6 +107,205 @@ export type MessageSimplePropsWithContext = Pick< shouldRenderSwipeableWrapper: boolean; }; +type MessageBubbleProps = Pick< + MessageSimplePropsWithContext, + 'reactionListPosition' | 'MessageContent' | 'ReactionListTop' +> & + Pick< + MessageContentProps, + | 'isVeryLastMessage' + | 'backgroundColor' + | 'messageGroupedSingleOrBottom' + | 'noBorder' + | 'setMessageContentWidth' + > & + Pick; + +const MessageBubble = ({ + reactionListPosition, + messageContentWidth, + setMessageContentWidth, + MessageContent, + ReactionListTop, + backgroundColor, + isVeryLastMessage, + messageGroupedSingleOrBottom, + noBorder, +}: MessageBubbleProps) => { + const { + theme: { + messageSimple: { contentWrapper }, + }, + } = useTheme(); + + return ( + + + {reactionListPosition === 'top' && ReactionListTop ? ( + + ) : null} + + ); +}; + +const SwipableMessageBubble = ( + 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 MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { const [messageContentWidth, setMessageContentWidth] = useState(0); const { width } = Dimensions.get('screen'); @@ -149,13 +352,11 @@ const MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { receiverMessageBackgroundColor, senderMessageBackgroundColor, }, - contentWrapper, headerWrapper, lastMessageContainer, messageGroupedSingleOrBottomContainer, messageGroupedTopContainer, reactionListTop: { position: reactionPosition }, - swipeContentContainer, }, }, } = useTheme(); @@ -212,13 +413,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 +421,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} From 1fffddba6a50097f4e8d582ffb22d0b446e1acdf Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Oct 2025 20:09:15 +0200 Subject: [PATCH 08/27] chore: extract message bubble --- .../Message/MessageSimple/MessageBubble.tsx | 231 ++++++++++++++++++ .../Message/MessageSimple/MessageSimple.tsx | 224 +---------------- 2 files changed, 232 insertions(+), 223 deletions(-) create mode 100644 package/src/components/Message/MessageSimple/MessageBubble.tsx diff --git a/package/src/components/Message/MessageSimple/MessageBubble.tsx b/package/src/components/Message/MessageSimple/MessageBubble.tsx new file mode 100644 index 0000000000..1c6eac5f11 --- /dev/null +++ b/package/src/components/Message/MessageSimple/MessageBubble.tsx @@ -0,0 +1,231 @@ +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 = ({ + 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 = ( + 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 0e6d5ca812..472d4c47be 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -1,22 +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 { MessageContentProps } from './MessageContent'; - -import { ReactionListTopProps } from './ReactionList/ReactionListTop'; +import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; import { MessageContextValue, @@ -29,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'; @@ -40,10 +24,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, contentContainer: {}, - contentWrapper: { - alignItems: 'center', - flexDirection: 'row', - }, lastMessageContainer: { marginBottom: 12, }, @@ -57,9 +37,6 @@ const styles = StyleSheet.create({ rightAlignItems: { alignItems: 'flex-end', }, - swipeContentContainer: { - position: 'absolute', - }, }); export type MessageSimplePropsWithContext = Pick< @@ -107,205 +84,6 @@ export type MessageSimplePropsWithContext = Pick< shouldRenderSwipeableWrapper: boolean; }; -type MessageBubbleProps = Pick< - MessageSimplePropsWithContext, - 'reactionListPosition' | 'MessageContent' | 'ReactionListTop' -> & - Pick< - MessageContentProps, - | 'isVeryLastMessage' - | 'backgroundColor' - | 'messageGroupedSingleOrBottom' - | 'noBorder' - | 'setMessageContentWidth' - > & - Pick; - -const MessageBubble = ({ - reactionListPosition, - messageContentWidth, - setMessageContentWidth, - MessageContent, - ReactionListTop, - backgroundColor, - isVeryLastMessage, - messageGroupedSingleOrBottom, - noBorder, -}: MessageBubbleProps) => { - const { - theme: { - messageSimple: { contentWrapper }, - }, - } = useTheme(); - - return ( - - - {reactionListPosition === 'top' && ReactionListTop ? ( - - ) : null} - - ); -}; - -const SwipableMessageBubble = ( - 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 MessageSimpleWithContext = (props: MessageSimplePropsWithContext) => { const [messageContentWidth, setMessageContentWidth] = useState(0); const { width } = Dimensions.get('screen'); From 54104d3dd8e26aaf4b7c12804f241879c0389cea Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Oct 2025 21:25:15 +0200 Subject: [PATCH 09/27] chore: add menu option --- examples/SampleApp/App.tsx | 43 +++- .../SampleApp/src/components/SecretMenu.tsx | 200 +++++++++++------- examples/SampleApp/src/context/AppContext.ts | 4 +- .../SampleApp/src/screens/ChannelScreen.tsx | 6 +- 4 files changed, 169 insertions(+), 84 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 852b4490e4..c38a24949b 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -59,6 +59,10 @@ 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, +} from './src/components/SecretMenu.tsx'; init({ data }); @@ -91,7 +95,12 @@ 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 colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); @@ -133,14 +142,21 @@ 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' }, + ); + setMessageListImplementation( + messageListImplementationStoredValue?.id as MessageListImplementationConfigItem['id'], + ); + setMessageListMode(messageListModeStoredValue?.mode as MessageListModeConfigItem['mode']); + }; + getMessageListConfig(); return () => { unsubscribeOnNotificationOpen(); unsubscribeForegroundEvent(); @@ -172,7 +188,7 @@ const App = () => { }); }, [chatClient]); - if (!messageListImplementation) { + if (!messageListImplementation || !messageListMode) { return; } @@ -194,7 +210,16 @@ const App = () => { dark: colorScheme === 'dark', }} > - + {isConnecting && !chatClient ? ( ) : chatClient ? ( diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index 95762b85eb..f1feefa549 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -12,8 +12,9 @@ import { View, Platform, StyleSheet, + ScrollView, } from 'react-native'; -import { Close, Notification, Delete, useTheme } from 'stream-chat-react-native'; +import { Close, Edit, Notification, Delete, Folder, useTheme } from 'stream-chat-react-native'; import { styles as menuDrawerStyles } from './MenuDrawer.tsx'; import AsyncStore from '../utils/AsyncStore.ts'; import { StreamChat } from 'stream-chat'; @@ -57,8 +58,9 @@ export const SlideInView = ({ const isAndroid = Platform.OS === 'android'; -type NotificationConfigItem = { label: string; name: string; id: string }; -type MessageListImplementationConfigItem = { label: string; id: string }; +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' }; const SecretMenuNotificationConfigItem = ({ notificationConfigItem, @@ -124,7 +126,7 @@ const SecretMenuNotificationConfigItem = ({ ); }; -const SecretMenuMessageListConfigItem = ({ +const SecretMenuMessageListImplementationConfigItem = ({ messageListImplementationConfigItem, storeMessageListImplementation, isSelected, @@ -141,9 +143,26 @@ const SecretMenuMessageListConfigItem = ({ ); +const SecretMenuMessageListModeConfigItem = ({ + messageListModeConfigItem, + storeMessageListMode, + isSelected, +}: { + messageListModeConfigItem: MessageListModeConfigItem; + storeMessageListMode: (item: MessageListModeConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListMode(messageListModeConfigItem)} + > + {messageListModeConfigItem.label} + +); + /* -* TODO: Please rewrite this entire component. -*/ + * TODO: Please rewrite this entire component. + */ export const SecretMenu = ({ close, @@ -156,8 +175,9 @@ export const SecretMenu = ({ }) => { const [selectedProvider, setSelectedProvider] = useState(null); const [selectedMessageListImplementation, setSelectedMessageListImplementation] = useState< - string | null + MessageListImplementationConfigItem['id'] | null >(null); + const [selectedMessageListMode, setSelectedMessageListMode] = useState(null); const { theme: { colors: { black, grey }, @@ -172,10 +192,18 @@ export const SecretMenu = ({ [], ); - const messageListImplementationConfigItems = useMemo( + const messageListImplementationConfigItems = useMemo( () => [ - { label: 'FlashList', id: 'flashlist' }, { label: 'FlatList', id: 'flatlist' }, + { label: 'FlashList', id: 'flashlist' }, + ], + [], + ); + + const messageListModeConfigItems = useMemo( + () => [ + { label: 'Default', mode: 'default' }, + { label: 'Livestreaming', mode: 'livestream' }, ], [], ); @@ -190,11 +218,18 @@ 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], + ); + setSelectedProvider(notificationProvider?.id ?? notificationConfigItems[0].id); + setSelectedMessageListImplementation( + messageListImplementation?.id ?? messageListImplementationConfigItems[0].id, + ); + setSelectedMessageListMode(messageListMode?.mode ?? messageListModeConfigItems[0].mode); }; getSelectedConfig(); - }, [notificationConfigItems, messageListImplementationConfigItems]); + }, [messageListModeConfigItems, notificationConfigItems, messageListImplementationConfigItems]); const storeProvider = useCallback(async (item: NotificationConfigItem) => { await AsyncStore.setItem('@stream-rn-sampleapp-push-provider', item); @@ -209,6 +244,11 @@ export const SecretMenu = ({ [], ); + const storeMessageListMode = useCallback(async (item: MessageListModeConfigItem) => { + await AsyncStore.setItem('@stream-rn-sampleapp-messagelist-mode', item); + setSelectedMessageListMode(item.mode); + }, []); + const removeAllDevices = useCallback(async () => { const { devices } = await chatClient.getDevices(chatClient.userID); for (const device of devices ?? []) { @@ -218,74 +258,92 @@ export const SecretMenu = ({ return ( - - - + + + + + + Notification Provider + + + {notificationConfigItems.map((item) => ( + + ))} + + + + + + + Message List implementation + + {messageListImplementationConfigItems.map((item) => ( + + ))} + + + + + + + Message List mode + + {messageListModeConfigItems.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..2650841780 100644 --- a/examples/SampleApp/src/context/AppContext.ts +++ b/examples/SampleApp/src/context/AppContext.ts @@ -3,13 +3,15 @@ import React from 'react'; import type { StreamChat } from 'stream-chat'; import type { LoginConfig } from '../types'; +import { MessageListImplementationConfigItem, MessageListModeConfigItem } 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']; }; export const AppContext = React.createContext({} as AppContextType); diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 5cc35675fa..d184b45890 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -119,7 +119,7 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient, messageListImplementation } = useAppContext(); + const { chatClient, messageListImplementation, messageListMode } = useAppContext(); const navigation = useNavigation(); const { bottom } = useSafeAreaInsets(); const { @@ -218,9 +218,9 @@ export const ChannelScreen: React.FC = ({ > {messageListImplementation === 'flashlist' ? ( - + ) : ( - + )} From 358a8e0094f6acb4769769f7e10b425fdde623d4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 9 Oct 2025 22:00:37 +0200 Subject: [PATCH 10/27] chore: add menu option for pruning a well --- examples/SampleApp/App.tsx | 10 ++ .../SampleApp/src/components/SecretMenu.tsx | 100 +++++++++++++----- examples/SampleApp/src/context/AppContext.ts | 7 +- .../SampleApp/src/screens/ChannelScreen.tsx | 4 +- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index c38a24949b..56a64bcaf9 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -62,6 +62,7 @@ import AsyncStore from './src/utils/AsyncStore.ts'; import { MessageListImplementationConfigItem, MessageListModeConfigItem, + MessageListPruningConfigItem, } from './src/components/SecretMenu.tsx'; init({ data }); @@ -101,6 +102,9 @@ const App = () => { const [messageListMode, setMessageListMode] = useState< MessageListModeConfigItem['mode'] | undefined >(undefined); + const [messageListPruning, setMessageListPruning] = useState< + MessageListPruningConfigItem['value'] | undefined + >(undefined); const colorScheme = useColorScheme(); const streamChatTheme = useStreamChatTheme(); @@ -151,10 +155,15 @@ const App = () => { '@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 () => { @@ -218,6 +227,7 @@ const App = () => { switchUser, messageListImplementation, messageListMode, + messageListPruning, }} > {isConnecting && !chatClient ? ( diff --git a/examples/SampleApp/src/components/SecretMenu.tsx b/examples/SampleApp/src/components/SecretMenu.tsx index f1feefa549..0037645b82 100644 --- a/examples/SampleApp/src/components/SecretMenu.tsx +++ b/examples/SampleApp/src/components/SecretMenu.tsx @@ -14,12 +14,36 @@ import { StyleSheet, ScrollView, } from 'react-native'; -import { Close, Edit, Notification, Delete, Folder, 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, @@ -56,12 +80,6 @@ export const SlideInView = ({ ); }; -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' }; - const SecretMenuNotificationConfigItem = ({ notificationConfigItem, storeProvider, @@ -160,6 +178,23 @@ const SecretMenuMessageListModeConfigItem = ({ ); +const SecretMenuMessageListPruningConfigItem = ({ + messageListPruningConfigItem, + storeMessageListPruning, + isSelected, +}: { + messageListPruningConfigItem: MessageListPruningConfigItem; + storeMessageListPruning: (item: MessageListPruningConfigItem) => void; + isSelected: boolean; +}) => ( + storeMessageListPruning(messageListPruningConfigItem)} + > + {messageListPruningConfigItem.label} + +); + /* * TODO: Please rewrite this entire component. */ @@ -177,7 +212,12 @@ export const SecretMenu = ({ const [selectedMessageListImplementation, setSelectedMessageListImplementation] = useState< MessageListImplementationConfigItem['id'] | null >(null); - const [selectedMessageListMode, setSelectedMessageListMode] = useState(null); + const [selectedMessageListMode, setSelectedMessageListMode] = useState< + MessageListModeConfigItem['mode'] | null + >(null); + const [selectedMessageListPruning, setSelectedMessageListPruning] = useState< + MessageListPruningConfigItem['value'] | null + >(null); const { theme: { colors: { black, grey }, @@ -192,22 +232,6 @@ export const SecretMenu = ({ [], ); - const messageListImplementationConfigItems = useMemo( - () => [ - { label: 'FlatList', id: 'flatlist' }, - { label: 'FlashList', id: 'flashlist' }, - ], - [], - ); - - const messageListModeConfigItems = useMemo( - () => [ - { label: 'Default', mode: 'default' }, - { label: 'Livestreaming', mode: 'livestream' }, - ], - [], - ); - useEffect(() => { const getSelectedConfig = async () => { const notificationProvider = await AsyncStore.getItem( @@ -222,14 +246,19 @@ export const SecretMenu = ({ '@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(); - }, [messageListModeConfigItems, notificationConfigItems, messageListImplementationConfigItems]); + }, [notificationConfigItems]); const storeProvider = useCallback(async (item: NotificationConfigItem) => { await AsyncStore.setItem('@stream-rn-sampleapp-push-provider', item); @@ -249,6 +278,11 @@ export const SecretMenu = ({ 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 ?? []) { @@ -317,6 +351,22 @@ export const SecretMenu = ({ + + + + Message List pruning + + {messageListPruningConfigItems.map((item) => ( + + ))} + + + void; 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 d184b45890..9a48ff6ba9 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -119,7 +119,8 @@ export const ChannelScreen: React.FC = ({ params: { channel: channelFromProp, channelId, messageId }, }, }) => { - const { chatClient, messageListImplementation, messageListMode } = useAppContext(); + const { chatClient, messageListImplementation, messageListMode, messageListPruning } = + useAppContext(); const navigation = useNavigation(); const { bottom } = useSafeAreaInsets(); const { @@ -215,6 +216,7 @@ export const ChannelScreen: React.FC = ({ messageId={messageId} NetworkDownIndicator={() => null} thread={selectedThread} + maximumMessageLimit={messageListPruning} > {messageListImplementation === 'flashlist' ? ( From ef757484b9b82184fd97e9f2916dff61297ceeef Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 10:36:52 +0200 Subject: [PATCH 11/27] fix: variable dynamic size message items --- package/src/components/MessageList/MessageList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index e517fa175e..8c0fedc147 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -355,7 +355,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const minIndexForVisible = Math.min(1, processedMessageList.length); - const autoscrollToTopThreshold = autoscrollToRecent ? (isLiveStreaming ? 64 : 10) : undefined; + const autoscrollToTopThreshold = autoscrollToRecent ? (isLiveStreaming ? 300 : 10) : undefined; const maintainVisibleContentPosition = useMemo( () => ({ @@ -637,7 +637,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }; - if (maximumMessageLimit && (threadList || isMessageRemovedFromMessageList)) { + if (!maximumMessageLimit && (threadList || isMessageRemovedFromMessageList)) { scrollToBottomIfNeeded(); } From ddc38aae9c793e52fdf3105775c54eef1102f0b5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 11:25:40 +0200 Subject: [PATCH 12/27] fix: flashlist mvcp disjoint set bug --- package/src/components/MessageList/MessageFlashList.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f6b1d3af4f..05daf33284 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -389,6 +389,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return { animateAutoscrollToBottom: true, autoscrollToBottomThreshold: autoscrollToRecent ? 1 : undefined, + disabled: !autoscrollToRecent, startRenderingFromBottom: true, }; }, [autoscrollToRecent]); @@ -412,7 +413,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => (message) => message?.id === targetedMessage, ); - setAutoscrollToRecent(false); // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); @@ -426,9 +426,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => index: indexOfParentInMessageList, viewPosition: 0.5, }); - setAutoscrollToRecent(true); setTargetedMessage(undefined); - }, 100); + }, WAIT_FOR_SCROLL_TIMEOUT); } }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); @@ -491,8 +490,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; + setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; + } else { + setAutoscrollToRecent(true); } const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; latestNonCurrentMessageBeforeUpdateRef.current = undefined; From 0c37e4cedf38f53bfb9836eb9bdef640ee60a764 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 11:55:13 +0200 Subject: [PATCH 13/27] fix: memoize message bubble components to be in line with previous behaviour --- .../Message/MessageSimple/MessageBubble.tsx | 360 +++++++++--------- 1 file changed, 182 insertions(+), 178 deletions(-) diff --git a/package/src/components/Message/MessageSimple/MessageBubble.tsx b/package/src/components/Message/MessageSimple/MessageBubble.tsx index 1c6eac5f11..be813308dc 100644 --- a/package/src/components/Message/MessageSimple/MessageBubble.tsx +++ b/package/src/components/Message/MessageSimple/MessageBubble.tsx @@ -33,192 +33,196 @@ export type MessageBubbleProps = Pick< > & Pick; -export const MessageBubble = ({ - reactionListPosition, - messageContentWidth, - setMessageContentWidth, - MessageContent, - ReactionListTop, - backgroundColor, - isVeryLastMessage, - messageGroupedSingleOrBottom, - noBorder, -}: MessageBubbleProps) => { - const { - theme: { - messageSimple: { contentWrapper }, - }, - } = useTheme(); - - return ( - - - {reactionListPosition === 'top' && ReactionListTop ? ( - - ) : null} - - ); -}; +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 = ( - 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'); +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; } - } - isSwiping.value = false; - translateX.value = withSpring( - 0, - { - dampingRatio: 1, - duration: 500, - overshootClamping: true, - stiffness: 1, - }, - () => { + + 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); } - }, - ); - }), - [ - 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, - ), - }, + } 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, ], - }), - [], - ); - - return ( - - - {shouldRenderAnimatedWrapper ? ( - <> - - {MessageSwipeContent ? : null} - - - - - - ) : ( - - )} - - - ); -}; + ); + + 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: { From 38ca199bd41707205def9ae06cba20675764b291 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 12:50:23 +0200 Subject: [PATCH 14/27] fix: save a render cycle through restructuring --- .../MessageList/MessageFlashList.tsx | 31 +++++++++-------- .../components/MessageList/MessageList.tsx | 33 +++---------------- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 05daf33284..a160d32fd4 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -400,26 +400,17 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [disabled]); - /** - * 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 - */ - useEffect(() => { - if (!targetedMessage) { - return; - } - + const goToMessage = useStableCallback(async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, + (message) => message?.id === messageId, ); // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { - loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); + await loadChannelAroundMessage({ messageId, setTargetedMessage }); } else { scrollToDebounceTimeoutRef.current = setTimeout(() => { clearTimeout(scrollToDebounceTimeoutRef.current); - // now scroll to it flashListRef.current?.scrollToIndex({ animated: true, @@ -429,12 +420,20 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setTargetedMessage(undefined); }, WAIT_FOR_SCROLL_TIMEOUT); } - }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); - - const goToMessage = useStableCallback((messageId: string) => { - setTargetedMessage(messageId); }); + /** + * 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 + */ + useEffect(() => { + if (!targetedMessage) { + return; + } + + goToMessage(targetedMessage); + }, [targetedMessage, goToMessage]); + useEffect(() => { /** * Condition to check if a message is removed from MessageList. diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 8c0fedc147..9d29f5aabf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -713,7 +713,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { try { if (indexOfParentInMessageList === -1) { await loadChannelAroundMessage({ messageId }); - return; } else { if (!flatListRef.current) { return; @@ -729,7 +728,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { 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); @@ -745,34 +743,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return; } scrollToDebounceTimeoutRef.current = setTimeout(async () => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, - ); - - // the message we want to scroll to has not been loaded in the state yet - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); - } else { - if (!flatListRef.current) { - return; - } - // By a fresh scroll we should clear the retries for the previous failed scroll - clearTimeout(scrollToDebounceTimeoutRef.current); - clearTimeout(failScrollTimeoutId.current); - // reset the retry count - scrollToIndexFailedRetryCountRef.current = 0; - // now scroll to it - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - setTargetedMessage(undefined); - } + await goToMessage(targetedMessage); }, WAIT_FOR_SCROLL_TIMEOUT); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage]); + return () => { + clearTimeout(scrollToDebounceTimeoutRef.current); + }; + }, [goToMessage, targetedMessage]); const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { From 8dd98f78defa8c9d320adfb228fa72c56f9ae606 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 12:52:39 +0200 Subject: [PATCH 15/27] fix: remove redundant check --- package/src/components/MessageList/MessageFlashList.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index a160d32fd4..af5134f53d 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -492,9 +492,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; - } else { - setAutoscrollToRecent(true); } + + setAutoscrollToRecent(true); + const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; latestNonCurrentMessageBeforeUpdateRef.current = undefined; From 84cfe17d92e5d89c7b887e93b7d7ef21635eb439 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 16:34:01 +0200 Subject: [PATCH 16/27] Revert "fix: remove redundant check" This reverts commit 8dd98f78defa8c9d320adfb228fa72c56f9ae606. --- package/src/components/MessageList/MessageFlashList.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index af5134f53d..a160d32fd4 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -492,10 +492,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; + } else { + setAutoscrollToRecent(true); } - - setAutoscrollToRecent(true); - const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; latestNonCurrentMessageBeforeUpdateRef.current = undefined; From fe0a416a4e0622ac7bac20c95cefce13f72174be Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 16:34:21 +0200 Subject: [PATCH 17/27] Revert "fix: save a render cycle through restructuring" This reverts commit 38ca199bd41707205def9ae06cba20675764b291. --- .../MessageList/MessageFlashList.tsx | 31 ++++++++--------- .../components/MessageList/MessageList.tsx | 33 ++++++++++++++++--- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index a160d32fd4..05daf33284 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -400,17 +400,26 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [disabled]); - const goToMessage = useStableCallback(async (messageId: string) => { + /** + * 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 + */ + useEffect(() => { + if (!targetedMessage) { + return; + } + const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, + (message) => message?.id === targetedMessage, ); // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId, setTargetedMessage }); + loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); } else { scrollToDebounceTimeoutRef.current = setTimeout(() => { clearTimeout(scrollToDebounceTimeoutRef.current); + // now scroll to it flashListRef.current?.scrollToIndex({ animated: true, @@ -420,19 +429,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setTargetedMessage(undefined); }, WAIT_FOR_SCROLL_TIMEOUT); } - }); + }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); - /** - * 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 - */ - useEffect(() => { - if (!targetedMessage) { - return; - } - - goToMessage(targetedMessage); - }, [targetedMessage, goToMessage]); + const goToMessage = useStableCallback((messageId: string) => { + setTargetedMessage(messageId); + }); useEffect(() => { /** diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 9d29f5aabf..8c0fedc147 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -713,6 +713,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { try { if (indexOfParentInMessageList === -1) { await loadChannelAroundMessage({ messageId }); + return; } else { if (!flatListRef.current) { return; @@ -728,6 +729,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { 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); @@ -743,13 +745,34 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return; } scrollToDebounceTimeoutRef.current = setTimeout(async () => { - await goToMessage(targetedMessage); + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === targetedMessage, + ); + + // the message we want to scroll to has not been loaded in the state yet + if (indexOfParentInMessageList === -1) { + await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); + } else { + if (!flatListRef.current) { + return; + } + // By a fresh scroll we should clear the retries for the previous failed scroll + clearTimeout(scrollToDebounceTimeoutRef.current); + clearTimeout(failScrollTimeoutId.current); + // reset the retry count + scrollToIndexFailedRetryCountRef.current = 0; + // now scroll to it + flatListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); + setTargetedMessage(undefined); + } }, WAIT_FOR_SCROLL_TIMEOUT); - return () => { - clearTimeout(scrollToDebounceTimeoutRef.current); - }; - }, [goToMessage, targetedMessage]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetedMessage]); const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { From 40d32607b6826b22a31f16ea61bac40ad5b5a494 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Oct 2025 23:37:35 +0200 Subject: [PATCH 18/27] fix: experiment with scrollToIndex --- .../MessageList/MessageFlashList.tsx | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 05daf33284..d02096d9b2 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -400,6 +400,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 @@ -431,8 +443,28 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); - const goToMessage = useStableCallback((messageId: string) => { - setTargetedMessage(messageId); + const goToMessage = useStableCallback(async (messageId: string) => { + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === messageId, + ); + + indexToScrollToRef.current = indexOfParentInMessageList; + + try { + if (indexOfParentInMessageList === -1) { + clearTimeout(scrollToDebounceTimeoutRef.current); + await loadChannelAroundMessage({ messageId, setTargetedMessage }); + } else { + flashListRef.current?.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); + setTargetedMessage(messageId); + } + } catch (e) { + console.warn('Error while scrolling to message', e); + } }); useEffect(() => { @@ -494,6 +526,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setScrollToBottomButtonVisible(true); return; } else { + indexToScrollToRef.current = undefined; setAutoscrollToRecent(true); } const latestNonCurrentMessageBeforeUpdate = latestNonCurrentMessageBeforeUpdateRef.current; @@ -1107,6 +1140,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => data={processedMessageList} drawDistance={800} getItemType={getItemType} + initialScrollIndex={ + indexToScrollToRef.current === -1 ? undefined : indexToScrollToRef.current + } keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={FooterComponent} From f2d4c703f2a5a473848192892ea9b7a57596208c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Oct 2025 00:02:35 +0200 Subject: [PATCH 19/27] fix: styles since upgrade --- package/src/components/MessageList/MessageFlashList.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index d02096d9b2..fd218c1323 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1077,15 +1077,12 @@ 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], ); From 3d12757211019453a09ceeb21574152ab54b5be1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Oct 2025 00:44:04 +0200 Subject: [PATCH 20/27] fix: scroll to bottom not always working --- package/src/components/MessageList/MessageFlashList.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index fd218c1323..303780dd83 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -385,6 +385,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); + useEffect(() => { + if (autoscrollToRecent && flashListRef.current) { + flashListRef.current.scrollToEnd({ + animated: true, + }); + } + }, [autoscrollToRecent]); + const maintainVisibleContentPosition = useMemo(() => { return { animateAutoscrollToBottom: true, From 1937fecc1b37b60f1eea9ba1c3856e47e18bcdf9 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Oct 2025 00:45:14 +0200 Subject: [PATCH 21/27] fix: api rename --- package/src/hooks/usePrunableMessageList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts index a6c6e198a1..4973ec74f1 100644 --- a/package/src/hooks/usePrunableMessageList.ts +++ b/package/src/hooks/usePrunableMessageList.ts @@ -70,7 +70,7 @@ export function usePrunableMessageList({ return; } - channel.state.pruneFromEnd(maximumMessageLimit); + channel.state.pruneOldest(maximumMessageLimit); rawSetMessages(channel); }); From 17c86939df0b3ecb0b7e9d1a478758665d598109 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 11 Oct 2025 00:45:27 +0200 Subject: [PATCH 22/27] chore: flashlist bump --- examples/SampleApp/package.json | 2 +- examples/SampleApp/yarn.lock | 20 +++++++++----------- package/package.json | 4 ++-- package/yarn.lock | 20 +++++++++----------- 4 files changed, 21 insertions(+), 25 deletions(-) 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/yarn.lock b/examples/SampleApp/yarn.lock index ff8c1c7832..575be3421e 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" @@ -8450,16 +8448,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 5076d1b189..42dad4ed33 100644 --- a/package/package.json +++ b/package/package.json @@ -85,7 +85,7 @@ "@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", @@ -110,7 +110,7 @@ "@babel/core": "^7.27.4", "@babel/runtime": "^7.27.6", "@op-engineering/op-sqlite": "^14.0.3", - "@shopify/flash-list": "^2.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", diff --git a/package/yarn.lock b/package/yarn.lock index b2258ec83a..b5343c5279 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" @@ -8694,16 +8692,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" From 40ddb8c1bc335ed974e85fbb8d45970e5622de59 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Oct 2025 08:54:16 +0200 Subject: [PATCH 23/27] fix: stutter when scrolling to message --- .../src/components/MessageList/MessageFlashList.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 303780dd83..e1baa52f18 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -463,11 +463,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => clearTimeout(scrollToDebounceTimeoutRef.current); await loadChannelAroundMessage({ messageId, setTargetedMessage }); } else { - flashListRef.current?.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, - }); setTargetedMessage(messageId); } } catch (e) { @@ -1094,11 +1089,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [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) => { @@ -1144,7 +1134,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => contentContainerStyle={flatListContentContainerStyle} data={processedMessageList} drawDistance={800} - getItemType={getItemType} + getItemType={getItemTypeInternal} initialScrollIndex={ indexToScrollToRef.current === -1 ? undefined : indexToScrollToRef.current } From cc3c3023acc4fa7db59b6c6d9038e0073eafac96 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Oct 2025 09:20:46 +0200 Subject: [PATCH 24/27] fix: infinite scroll bug --- package/src/components/MessageList/MessageFlashList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index e1baa52f18..a5cdce21bc 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -397,7 +397,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return { animateAutoscrollToBottom: true, autoscrollToBottomThreshold: autoscrollToRecent ? 1 : undefined, - disabled: !autoscrollToRecent, startRenderingFromBottom: true, }; }, [autoscrollToRecent]); From 0825ade50471814910ebcc28bf3b8e835d2aa92a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Oct 2025 12:02:26 +0200 Subject: [PATCH 25/27] chore: bump stream-chat-js --- examples/SampleApp/yarn.lock | 19 ++++++++++++++----- package/yarn.lock | 30 ++++++++++-------------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 575be3421e..b95479fc13 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -3472,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" @@ -8177,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" diff --git a/package/yarn.lock b/package/yarn.lock index b5343c5279..e64c8a8dc2 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2954,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: @@ -4649,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" @@ -8352,14 +8342,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" From 88a2bd101f75321dee78ad8dbf35f90f55469ad2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Oct 2025 12:02:53 +0200 Subject: [PATCH 26/27] chore: add package.json as well --- package/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/package.json b/package/package.json index 42dad4ed33..26ef59e04a 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "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": { From 07a7c770d9670dbb82b62b97b47a778be5c9daa6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 15 Oct 2025 12:45:15 +0200 Subject: [PATCH 27/27] fix: remove console.log --- package/src/components/Channel/Channel.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 5e8ef8ebff..9617284455 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1690,8 +1690,6 @@ const ChannelWithContext = (props: PropsWithChildren) = overrideCapabilities: overrideOwnCapabilities, }); - console.log('TEST: ', maximumMessageLimit); - const channelContext = useCreateChannelContext({ channel, channelUnreadState,