diff --git a/README.md b/README.md
index e4957fe678..ddd803118c 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://www.npmjs.com/package/stream-chat-react-native)
[](https://github.com/GetStream/stream-chat-react-native/actions)
[](https://getstream.io/chat/docs/sdk/reactnative)
-
+
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;
+};