diff --git a/README.md b/README.md index e4957fe678..ddd803118c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![NPM](https://img.shields.io/npm/v/stream-chat-react-native.svg)](https://www.npmjs.com/package/stream-chat-react-native) [![Build Status](https://github.com/GetStream/stream-chat-react-native/actions/workflows/release.yml/badge.svg)](https://github.com/GetStream/stream-chat-react-native/actions) [![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/reactnative) -![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-284%20KB-blue) +![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-288%20KB-blue) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index d8d08b3681..523e2d7297 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1972,7 +1972,9 @@ const ChannelWithContext = (props: PropsWithChildren) = {children} - + {isImageMediaLibraryAvailable() && ( + + )} diff --git a/package/src/components/Message/MessageSimple/utils/renderText.tsx b/package/src/components/Message/MessageSimple/utils/renderText.tsx index cf5e2828cc..7ec1599f50 100644 --- a/package/src/components/Message/MessageSimple/utils/renderText.tsx +++ b/package/src/components/Message/MessageSimple/utils/renderText.tsx @@ -525,7 +525,7 @@ const Bullet = ({ index, style }: BulletProps) => ( ); const ListRow = ({ children, style }: PropsWithChildren) => ( - {children} + {children} ); const ListItem = ({ children, style }: PropsWithChildren) => ( diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index bb5ff63ad5..2ebde3f6cf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -222,6 +222,15 @@ type MessageListPropsWithContext = Pick< * ``` */ setFlatListRef?: (ref: FlatListType | 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; }; /** @@ -256,6 +265,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { InlineUnreadIndicator, inverted = true, isListActive = false, + isLiveStreaming = false, legacyImageViewerSwipeBehaviour, loadChannelAroundMessage, loading, @@ -313,6 +323,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } = useMessageList({ + isLiveStreaming, noGroupByUser, threadList, }); @@ -336,12 +347,17 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const minIndexForVisible = Math.min(1, processedMessageList.length); + const autoscrollToTopThreshold = useMemo( + () => (isLiveStreaming ? 64 : autoscrollToRecent ? 10 : undefined), + [autoscrollToRecent, isLiveStreaming], + ); + const maintainVisibleContentPosition = useMemo( () => ({ - autoscrollToTopThreshold: autoscrollToRecent ? 10 : undefined, + autoscrollToTopThreshold, minIndexForVisible, }), - [autoscrollToRecent, minIndexForVisible], + [autoscrollToTopThreshold, minIndexForVisible], ); /** @@ -652,7 +668,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { latestNonCurrentMessageBeforeUpdate?.id === latestCurrentMessageAfterUpdate.id; // if didMergeMessageSetsWithNoUpdates=false, we got new messages // so we should scroll to bottom if we are near the bottom already - setAutoscrollToRecent(!didMergeMessageSetsWithNoUpdates); + const shouldForceScrollToRecent = + !didMergeMessageSetsWithNoUpdates || + processedMessageList.length - messageListLengthBeforeUpdate.current > 0; + + setAutoscrollToRecent(shouldForceScrollToRecent); if (!didMergeMessageSetsWithNoUpdates) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); @@ -667,8 +687,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel, processedMessageList, threadList]); + }, [channel, threadList, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef]); const goToMessage = useStableCallback(async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( @@ -1218,7 +1237,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onViewableItemsChanged={stableOnViewableItemsChanged} ref={refCallback} renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} showsVerticalScrollIndicator={false} + // @ts-expect-error react-native internal + strictMode={isLiveStreaming} style={flatListStyle} testID='message-flat-list' viewabilityConfig={flatListViewabilityConfig} diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 3518ae1fd5..53c6e14665 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -11,6 +11,7 @@ import { import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import { useRAFCoalescedValue } from '../../../hooks'; import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators'; import { getGroupStyles } from '../utils/getGroupStyles'; @@ -18,6 +19,7 @@ export type UseMessageListParams = { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; noGroupByUser?: boolean; threadList?: boolean; + isLiveStreaming?: boolean; }; export type GroupType = string; @@ -48,7 +50,7 @@ export const shouldIncludeMessageInList = ( }; export const useMessageList = (params: UseMessageListParams) => { - const { noGroupByUser, threadList } = params; + const { noGroupByUser, threadList, isLiveStreaming } = params; const { client } = useChatContext(); const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = @@ -110,14 +112,19 @@ export const useMessageList = (params: UseMessageListParams) => { return newMessageList; }, [client.userID, deletedMessagesVisibilityType, messageList]); - return { - /** Date separators */ - dateSeparatorsRef, - /** Message group styles */ - messageGroupStylesRef, - /** Messages enriched with dates/readby/groups and also reversed in order */ - processedMessageList, - /** Raw messages from the channel state */ - rawMessageList: messageList, - }; + const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); + + return useMemo( + () => ({ + /** Date separators */ + dateSeparatorsRef, + /** Message group styles */ + messageGroupStylesRef, + /** Messages enriched with dates/readby/groups and also reversed in order */ + processedMessageList: data, + /** Raw messages from the channel state */ + rawMessageList: messageList, + }), + [data, messageList], + ); }; diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 76a6febf47..d51a608fc2 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -9,3 +9,4 @@ export * from './useMessageReminder'; export * from './useQueryReminders'; export * from './useClientNotifications'; export * from './useInAppNotificationsState'; +export * from './useRAFCoalescedValue'; diff --git a/package/src/hooks/useRAFCoalescedValue.ts b/package/src/hooks/useRAFCoalescedValue.ts new file mode 100644 index 0000000000..eb5a4b3f1d --- /dev/null +++ b/package/src/hooks/useRAFCoalescedValue.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * A utility hook that coalesces a fast changing value to the display’s frame rate. + * It accepts any “noisy” input (arrays, objects, numbers, etc.) and exposes a value + * that React consumers will see at most once per animation frame (via + * `requestAnimationFrame`). This is useful when upstream sources (selectors, sockets, + * DB listeners) can fire multiple times within a single paint and you want to avoid + * extra renders and layout churn. + * + * How it works: + * - Keeps track of the latest incoming value + * - Ensures there is **at most one** pending RAF at a time + * - When the RAF fires, commits the **latest** value to state (`emitted`) + * - If additional changes arrive before the RAF runs, they are merged (the last write + * operation wins) and no new RAF is scheduled + * + * With this hook you can: + * - Feed a `FlatList`/`SectionList` from fast changing sources without spamming re-renders + * - Align React updates to the paint cadence (one publish per frame) + * - Help preserve item anchoring logic (e.g., MVCP) by reducing in-frame updates + * + * **Caveats:** + * - This hook intentionally skips intermediate states that occur within the same + * frame. If you must observe every transition (e.g., for analytics/reducers), do that + * upstream; this hook is for visual coalescing + * - Equality checks are simple referential equalities. If your producer recreates arrays + * or objects each time, you’ll still publish once per frame. To avoid even those + * emissions, stabilize upstream + * - This is not a silver bullet for throttle/debounce; it uses the screen’s refresh cycle; + * If you need “no more than once per X ms”, layer that upstream + * + * Usage tips: + * - Prefer passing already-memoized values when possible (e.g., stable arrays by ID). + * - Pair with a stable `keyExtractor` in lists so coalesced updates map cleanly to rows. + * - Do not cancel/reschedule on prop changes; cancellation is handled on unmount only. + * + * @param value The upstream value that may change multiple times within a single frame. + * @param isEnabled Determines whether the hook should be run or not (useful for cases where + * we want to conditionally use RAF when certain feature feature flags are enabled). If `false`, + * it will simply pass the data through (maintaining the reference as well). + * @returns A value that updates **at most once per frame** with the latest input. + */ +export const useRAFCoalescedValue = (value: S, isEnabled: boolean | undefined): S => { + const [emitted, setEmitted] = useState(value); + const pendingRef = useRef(value); + const rafIdRef = useRef(null); + + // If `value` changes, schedule a single RAF to publish the latest one. + useEffect(() => { + if (value === pendingRef.current || !isEnabled) return; + pendingRef.current = value; + + // already scheduled the next frame, skip + if (rafIdRef.current) return; + + const run = () => { + rafIdRef.current = null; + setEmitted(pendingRef.current); + }; + + rafIdRef.current = requestAnimationFrame(run); + }, [value, isEnabled]); + + useEffect(() => { + return () => { + // cancel the frame if it exists only on unmount + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }; + }, []); + + return isEnabled ? emitted : value; +};