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
60 changes: 60 additions & 0 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ rg '<Chat\b' -A10 src/ | rg '\bchannelManager\b'
rg '\b(recoverState|recoverStateOnReconnect|preventThreadCleanup)\b' src/
rg 'connection\.(changed|recovered)' src/

# Β§O β€” message-list pruning moved to paginator configuration
rg '\bmaximumMessageLimit\b' src/

# Β§K β€” unified channel.state (removed *Store handles, in-place data mutation)
rg '\bchannel\.state\.(read|typing|members|watcher|ownCapabilities)Store\b' src/
rg '\bchannel\.state\.mutedUsersStore\b' src/
Expand Down Expand Up @@ -1879,6 +1882,63 @@ message in your own state; there is no replacement on `SearchController`.

---

# Part O β€” Message-list pruning (`maximumMessageLimit` β†’ `maxLoadedItems`)

## O.1 `<Channel maximumMessageLimit>` removed; the cap is paginator configuration

```diff
- <Channel channel={channel} maximumMessageLimit={200}>
+ <Channel channel={channel}>
```

```diff
+ // any ONE of these
+ channel.messagePaginator.updateConfig({ maxLoadedItems: 200 });
+ client.config.set({ messagePaginator: { maxLoadedItems: 200 } }); // every message list
+ client.config.set({ channel: { messagePaginator: { maxLoadedItems: 200 } } }); // channel lists only
+ client.config.set({ thread: { messagePaginator: { maxLoadedItems: 100 } } }); // thread replies only
```

Bounding the loaded window is state-layer work β€” it is the paginator that owns the loaded set, its
pagination cursors and its store membership β€” so in v10 it is paginator configuration rather than a
component prop. Same default (unset β‡’ unbounded) and the same purpose: a livestream channel that would
otherwise accumulate every message it has ever received.

Three things the prop could not give you, which the config route does:

- **Per-surface control.** The prop capped the channel list only. `thread.messagePaginator` can now be
capped separately, or both together through the shared `messagePaginator` key.
- **Runtime changes.** `updateConfig` applies to an open channel immediately.
- **It works outside React.** Nothing has to render for the cap to be in force.

**Affects:** anyone passing `maximumMessageLimit`. The prop is **removed, not deprecated** β€” TypeScript
flags it, and there is no silent-fallback case to worry about.

## O.2 Pruning actually prunes again (behavioural)

In v9 the cap was enforced by `channel.state.pruneOldest()`, which went away with `channel.state.messages`.
For part of the v10 pre-release line `maximumMessageLimit` was therefore **inert** β€” it altered some
scroll behaviour but never bounded the window. If you set it during that period and saw no effect, that is
why. `maxLoadedItems` enforces it.

What a prune does now, which is worth knowing if you build on the paginator:

- Drops the oldest messages from the loaded window and releases them from `client.messageStore`. A message
another collection still holds β€” a pinned message, or a `show_in_channel` reply in an open thread β€”
keeps its content; only this list's reference goes.
- Re-opens `hasMoreTail` and re-points `cursor.tailward`, so scrolling back re-fetches. This happens even
if the list had already paginated to the very first message in the channel.
- Never drops an unsent or failed message, which sorts by the time it was composed and would otherwise be
destroyed by a cap. The window sits slightly above the cap while one is pending.
- Never runs while the user has jumped away from the newest window, and never while the SDK's viewability
tracking says the viewport is near the oldest loaded message.
- Does **not** touch the offline database. A pruned message is still in SQLite.

A value below the list's `pageSize` is raised to it: a cap smaller than a page would prune away the page a
"load older" query had just fetched, and the list would immediately ask for it again.

---

# Part I β€” i18n

## 19. English-only bundle, dotted keys, shared runtime
Expand Down
10 changes: 9 additions & 1 deletion examples/SampleApp/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route

const [selectedThread, setSelectedThread] = useState<LocalMessage | null>();

/**
* Message-list pruning is state-layer configuration now, not a `<Channel>` prop: the paginator
* bounds its own loaded window. Set on the instance so the secret-menu toggle applies to the open
* channel without a reload; `undefined` restores an unbounded list.
*/
useEffect(() => {
channel?.messagePaginator.updateConfig({ maxLoadedItems: messageListPruning });
}, [channel, messageListPruning]);

useEffect(() => {
const initChannel = async () => {
if (!chatClient || !channelId || channelFromProp) {
Expand Down Expand Up @@ -366,7 +375,6 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route
messageId={messageId}
onAlsoSentToChannelHeaderPress={onAlsoSentToChannelHeaderPress}
thread={selectedThread}
maximumMessageLimit={messageListPruning}
>
<PortalWhileClosingView portalHostName='overlay-header' portalName='channel-header'>
<ChannelHeader channel={channel} />
Expand Down
3 changes: 0 additions & 3 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
| 'hideStickyDateHeader'
| 'hideDateSeparators'
| 'maxTimeBetweenGroupedMessages'
| 'maximumMessageLimit'
>
> &
Pick<ChatContextValue, 'client' | 'enableOfflineSupport' | 'isOnline'> &
Expand Down Expand Up @@ -455,7 +454,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
thread: threadFromProps,
threadList,
topInset = 0,
maximumMessageLimit,
initializeOnMount = true,
urlPreviewType = 'full',
} = props;
Expand Down Expand Up @@ -914,7 +912,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
loadChannelAroundMessage,
loadChannelAtFirstUnreadMessage,
loading: channelMessagesState.loading,
maximumMessageLimit,
maxTimeBetweenGroupedMessages,
reloadChannel,
scrollToFirstUnreadThreshold,
Expand Down
12 changes: 1 addition & 11 deletions package/src/components/Channel/hooks/useCreateChannelContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const useCreateChannelContext = ({
loadChannelAtFirstUnreadMessage,
loading,
maxTimeBetweenGroupedMessages,
maximumMessageLimit,
reloadChannel,
scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
Expand All @@ -36,23 +35,14 @@ export const useCreateChannelContext = ({
loadChannelAroundMessage,
loadChannelAtFirstUnreadMessage,
loading,
maximumMessageLimit,
maxTimeBetweenGroupedMessages,
reloadChannel,
scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
threadList,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
channelId,
disabled,
isChannelActive,
highlightedMessageId,
loading,
threadList,
maximumMessageLimit,
],
[channelId, disabled, isChannelActive, highlightedMessageId, loading, threadList],
);

return channelContext;
Expand Down
29 changes: 16 additions & 13 deletions package/src/components/MessageList/MessageFlashList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ type MessageFlashListPropsWithContext = Pick<
| 'scrollToFirstUnreadThreshold'
| 'hasPendingInitialTargetLoad'
| 'threadList'
| 'maximumMessageLimit'
> &
Pick<ChatContextValue, 'client'> &
Pick<
Expand Down Expand Up @@ -324,7 +323,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
loadMore,
loadMoreRecent,
markRead,
maximumMessageLimit,
messageInputFloating,
messageInputHeightStore,
myMessageTheme,
Expand Down Expand Up @@ -394,12 +392,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
[myMessageThemeString, scheme, theme],
);

const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({
isFlashList: true,
isLiveStreaming,
maximumMessageLimit,
threadList,
});
const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } =
useMessageList({
isFlashList: true,
isLiveStreaming,
threadList,
});

const renderItem = useCallback(
({ item: message, index }: { item: LocalMessage; index: number }) => {
Expand Down Expand Up @@ -576,14 +574,21 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
}
};

if (isMessageRemovedFromMessageList && !maximumMessageLimit) {
scrollToBottomIfNeeded();
if (isMessageRemovedFromMessageList) {
if (maxLoadedItems) {
// The list shrank while a window cap is configured, so a prune is the likely cause. The
// trackers are keyed by list length, and a prune returns the length to a value already
// marked as consumed β€” leaving them would permanently wedge back-pagination.
resetPaginationTrackersRef.current();
} else {
scrollToBottomIfNeeded();
}
}

messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate;
topMessageBeforeUpdate.current = topMessageAfterUpdate;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]);
}, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]);

useEffect(() => {
if (!processedMessageList.length) {
Expand Down Expand Up @@ -1322,7 +1327,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
isChannelActive,
loadChannelAroundMessage,
loading,
maximumMessageLimit,
reloadChannel,
scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
Expand Down Expand Up @@ -1364,7 +1368,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
loadingMore,
loadingMoreRecent,
markRead,
maximumMessageLimit,
messageInputFloating,
messageInputHeightStore,
myMessageTheme,
Expand Down
22 changes: 9 additions & 13 deletions package/src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ type MessageListPropsWithContext = Pick<
| 'reloadChannel'
| 'scrollToFirstUnreadThreshold'
| 'threadList'
| 'maximumMessageLimit'
> &
Pick<ChatContextValue, 'client'> & {
loadMore: () => Promise<void>;
Expand Down Expand Up @@ -336,7 +335,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
loadMore,
loadMoreRecent,
markRead,
maximumMessageLimit,
messageInputFloating,
messageInputHeightStore,
myMessageTheme,
Expand Down Expand Up @@ -390,11 +388,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
* NOTE: rawMessageList changes only when messages array state changes
* processedMessageList changes on any state change
*/
const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({
isLiveStreaming,
maximumMessageLimit,
threadList,
});
const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } =
useMessageList({
isLiveStreaming,
threadList,
});

const previousDerivedItemsRef = useRef<Map<string, MessageListItemWithNeighbours>>(undefined);

Expand Down Expand Up @@ -746,7 +744,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
};

if (threadList || isMessageRemovedFromMessageList) {
if (maximumMessageLimit) {
if (maxLoadedItems) {
// pruning has happened, reset the trackers
resetPaginationTrackersRef.current();
} else {
Expand All @@ -757,7 +755,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate;
topMessageBeforeUpdate.current = topMessageAfterUpdate;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maximumMessageLimit]);
}, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]);

useEffect(() => {
if (threadList) {
Expand Down Expand Up @@ -794,7 +792,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {

// we don't want this behaviour while pruning, as it may scroll unnecessarily in
// certain scenarios
if ((maximumMessageLimit && shouldForceScrollToRecent) || !maximumMessageLimit) {
if ((maxLoadedItems && shouldForceScrollToRecent) || !maxLoadedItems) {
setAutoscrollToRecent(shouldForceScrollToRecent);
}

Expand All @@ -816,7 +814,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
threadList,
processedMessageList,
shouldScrollToRecentOnNewOwnMessageRef,
maximumMessageLimit,
maxLoadedItems,
]);

// Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump
Expand Down Expand Up @@ -1425,7 +1423,6 @@ export const MessageList = (props: MessageListProps) => {
highlightedMessageId,
loadChannelAroundMessage,
loading,
maximumMessageLimit,
reloadChannel,
scrollToFirstUnreadThreshold,
threadList,
Expand Down Expand Up @@ -1463,7 +1460,6 @@ export const MessageList = (props: MessageListProps) => {
loadMore,
loadMoreRecent,
markRead,
maximumMessageLimit,
messageInputFloating,
messageInputHeightStore,
myMessageTheme,
Expand Down
21 changes: 11 additions & 10 deletions package/src/components/MessageList/hooks/useMessageList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export type UseMessageListParams = {
threadList?: boolean;
isLiveStreaming?: boolean;
isFlashList?: boolean;
maximumMessageLimit?: number;
};

/**
Expand All @@ -29,16 +28,13 @@ const EMPTY_MESSAGES: LocalMessage[] = [];
const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: state.items });

export const useMessageList = (params: UseMessageListParams) => {
const { threadList, isLiveStreaming, isFlashList = false, maximumMessageLimit } = params;
const { threadList, isLiveStreaming, isFlashList = false } = params;
const { channel } = useChannelContext();
const { threadInstance } = useThreadContext();
const messagePaginatorState = threadList
? threadInstance?.messagePaginator?.state
: channel.messagePaginator.state;
const { messages } = useStateStore(messagePaginatorState, messageListSelector) ?? {};
const { viewabilityChangedCallback } = usePrunableMessageList({
maximumMessageLimit,
setMessages: () => {},
const messagePaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
const { messages } = useStateStore(messagePaginator?.state, messageListSelector) ?? {};
const { maxLoadedItems, viewabilityChangedCallback } = usePrunableMessageList({
paginator: messagePaginator,
});
const messageList = messages ?? EMPTY_MESSAGES;

Expand All @@ -51,12 +47,17 @@ export const useMessageList = (params: UseMessageListParams) => {

return useMemo(
() => ({
/**
* The paginator's configured window cap, or `undefined` when the list is unbounded. Read from
* the paginator rather than a prop β€” it is state-layer configuration.
*/
maxLoadedItems,
/** Messages enriched with dates/readby/groups and also reversed in order */
processedMessageList: data,
/** Raw messages from the channel state */
rawMessageList: messageList,
viewabilityChangedCallback,
}),
[data, messageList, viewabilityChangedCallback],
[data, messageList, maxLoadedItems, viewabilityChangedCallback],
);
};
6 changes: 0 additions & 6 deletions package/src/contexts/channelContext/ChannelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,6 @@ 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;
threadList?: boolean;
};

Expand Down
Loading
Loading