Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions package/src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ type MessageListPropsWithContext = Pick<
* ```
*/
setFlatListRef?: (ref: FlatListType<LocalMessage> | 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;
};

/**
Expand Down Expand Up @@ -256,6 +265,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
InlineUnreadIndicator,
inverted = true,
isListActive = false,
isLiveStreaming = false,
legacyImageViewerSwipeBehaviour,
loadChannelAroundMessage,
loading,
Expand Down Expand Up @@ -313,6 +323,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
*/
const { dateSeparatorsRef, messageGroupStylesRef, processedMessageList, rawMessageList } =
useMessageList({
isLiveStreaming,
noGroupByUser,
threadList,
});
Expand All @@ -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],
);

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -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}
Expand Down
29 changes: 18 additions & 11 deletions package/src/components/MessageList/hooks/useMessageList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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';

export type UseMessageListParams = {
deletedMessagesVisibilityType?: DeletedMessagesVisibilityType;
noGroupByUser?: boolean;
threadList?: boolean;
isLiveStreaming?: boolean;
};

export type GroupType = string;
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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],
);
};
1 change: 1 addition & 0 deletions package/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './useMessageReminder';
export * from './useQueryReminders';
export * from './useClientNotifications';
export * from './useInAppNotificationsState';
export * from './useRAFCoalescedValue';
76 changes: 76 additions & 0 deletions package/src/hooks/useRAFCoalescedValue.ts
Original file line number Diff line number Diff line change
@@ -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 = <S>(value: S, isEnabled: boolean | undefined): S => {
const [emitted, setEmitted] = useState<S>(value);
const pendingRef = useRef<S>(value);
const rafIdRef = useRef<number | null>(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;
};
Loading