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
2 changes: 1 addition & 1 deletion examples/E2ETestApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"react-native-svg": "^15.15.5",
"react-native-teleport": "^1.2.2",
"react-native-worklets": "^0.13.0",
"stream-chat": "^10.0.0-rc.28",
"stream-chat": "^10.0.0-rc.30",
"stream-chat-react-native": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/ExpoChatGPTSample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"react-native-svg": "15.15.5",
"react-native-teleport": "^1.2.2",
"react-native-worklets": "0.13.0",
"stream-chat": "^10.0.0-rc.28",
"stream-chat": "^10.0.0-rc.30",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^",
"victory-native": "^42.0.1"
Expand Down
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"react-native-teleport": "^1.2.2",
"react-native-web": "~0.21.4",
"react-native-worklets": "0.13.0",
"stream-chat": "^10.0.0-rc.28",
"stream-chat": "^10.0.0-rc.30",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"react-native-teleport": "^1.2.2",
"react-native-video": "^6.19.3",
"react-native-worklets": "^0.13.0",
"stream-chat": "^10.0.0-rc.28",
"stream-chat": "^10.0.0-rc.30",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"react-syntax-highlighter": "^16.1.0",
"stream-chat": "^10.0.0-rc.28",
"stream-chat": "^10.0.0-rc.30",
"use-sync-external-store": "^1.7.0",
"zod": "^4.1.12"
},
Expand Down
1 change: 1 addition & 0 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
{...shellProps}
channel={channel}
hasPendingInitialTargetLoad={hasPendingInitialTargetLoad}
initializeOnMount={initializeOnMount}
>
{children}
</ChannelShell>
Expand Down
12 changes: 12 additions & 0 deletions package/src/components/Channel/ChannelShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useCreateChannelContext } from './hooks/useCreateChannelContext';
import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext';
import { useCreateMessagesContext } from './hooks/useCreateMessagesContext';
import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext';
import { useJumpToLatestOnOpen } from './hooks/useJumpToLatestOnOpen';

import {
AttachmentPickerContextValue,
Expand Down Expand Up @@ -208,6 +209,8 @@ export type ChannelConfigProps = Partial<
type ChannelShellProps = ChannelConfigProps & {
channel: Channel;
hasPendingInitialTargetLoad: () => boolean;
/** `<Channel>`'s `initializeOnMount`: when false, the app places the list's pages itself. */
initializeOnMount?: boolean;
/** Set by `<Thread>`: the list and composer below show this thread's replies. */
thread?: Thread;
};
Expand All @@ -228,6 +231,13 @@ const channelStatusSelector = (state: ChannelDataState & ChannelLifecycleState)
pendingDisposal: state.pendingDisposal,
});

// Renders nothing. It sits under the providers below so its hook can read the
// channel and thread context this component provides.
const JumpToLatestOnOpen = () => {
useJumpToLatestOnOpen();
return null;
};

/**
* The providers a message list and composer need, shared by `<Channel>` and `<Thread>`, which each
* own their lifecycle (watching, loading, marking read) and render this around their children.
Expand Down Expand Up @@ -288,6 +298,7 @@ export const ChannelShell = (props: PropsWithChildren<ChannelShellProps>) => {
hasFilePicker = isDocumentPickerAvailable(),
hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(),
hasPendingInitialTargetLoad,
initializeOnMount = true,
allowDateSeparatorForSystemMessages = false,
hideDateSeparators = false,
hideStickyDateHeader = false,
Expand Down Expand Up @@ -508,6 +519,7 @@ export const ChannelShell = (props: PropsWithChildren<ChannelShellProps>) => {
<MessageInputProvider value={inputMessageInputContext}>
<AudioPlayerProvider value={audioPlayerContext}>
<NotificationAnnouncer />
{initializeOnMount ? <JumpToLatestOnOpen /> : null}
<View style={{ height: '100%' }}>{children}</View>
{shouldRenderAttachmentPicker ? <AttachmentPicker /> : null}
</AudioPlayerProvider>
Expand Down
102 changes: 102 additions & 0 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,108 @@ describe('Channel initial load useEffect', () => {
});
});

describe('reopening a channel left on an older page', () => {
const OLDER_PAGE_START = new Date('2020-01-01T00:00:00.000Z').getTime();

// A watched channel whose newest messages are loaded, but whose list shows an older page, as it
// does after a jump to a quoted, pinned or searched message.
const setUpChannelOnOlderPage = async () => {
const newestMessages = Array.from({ length: 10 }, (_, i) =>
generateMessage({ id: `newest-${i}` }),
);
const mockedChannel = generateChannelResponse({ messages: newestMessages });
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
const channel = chatClient.channel('messaging', mockedChannel.channel.id);
await channel.watch();

const olderMessages = Array.from({ length: 5 }, (_, i) =>
generateMessage({ id: `older-${i}`, timestamp: OLDER_PAGE_START + i * 1000 }),
);
channel.messagePaginator.ingestPage({
page: olderMessages,
policy: 'strict-overlap-only',
setActive: true,
});
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);

return channel;
};

it('shows the newest messages again', async () => {
const channel = await setUpChannelOnOlderPage();

renderComponent({ channel, markReadOnMount: false });

await waitFor(() => expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(true));
expect(channel.messagePaginator.items?.map((message) => message.id)).toContain('newest-9');
});

it('keeps a jump the integrator already made to a loaded message', async () => {
const channel = await setUpChannelOnOlderPage();
// A deep link from the screen: the target is loaded, so the jump lands at once.
await channel.messagePaginator.jumpToMessage('older-2', { focusReason: 'jump-to-message' });
const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');

renderComponent({ channel, markReadOnMount: false });

await waitFor(() => expect(seedSpy).toHaveBeenCalled());
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
expect(channel.messagePaginator.messageFocusSignal.getLatestValue().signal?.messageId).toBe(
'older-2',
);
});

it('leaves a jump that is still in flight alone', async () => {
const channel = await setUpChannelOnOlderPage();
// A deep link to a message that is not loaded: its query is in flight.
jest.spyOn(channel.messagePaginator, 'isLoading', 'get').mockReturnValue(true);
const jumpToTheLatestMessageSpy = jest.spyOn(
channel.messagePaginator,
'jumpToTheLatestMessage',
);
const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');

renderComponent({ channel, markReadOnMount: false });

await waitFor(() => expect(seedSpy).toHaveBeenCalled());
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
});

it('leaves the list alone when the app places its pages itself', async () => {
const channel = await setUpChannelOnOlderPage();
const jumpToTheLatestMessageSpy = jest.spyOn(
channel.messagePaginator,
'jumpToTheLatestMessage',
);

renderComponent({ channel, initializeOnMount: false, markReadOnMount: false });

expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
expect(channel.messagePaginator.isActiveIntervalAtHead).toBe(false);
});

it('leaves the jump to the first unread message to the initial load', async () => {
const channel = await setUpChannelOnOlderPage();
jest.spyOn(channel, 'countUnread').mockImplementation(() => 3);
const jumpToTheFirstUnreadMessageSpy = jest
.spyOn(channel.messagePaginator, 'jumpToTheFirstUnreadMessage')
.mockResolvedValue(undefined as never);
const jumpToTheLatestMessageSpy = jest.spyOn(
channel.messagePaginator,
'jumpToTheLatestMessage',
);

renderComponent({
channel,
initialScrollToFirstUnreadMessage: true,
markReadOnMount: false,
});

await waitFor(() => expect(jumpToTheFirstUnreadMessageSpy).toHaveBeenCalledTimes(1));
expect(jumpToTheLatestMessageSpy).not.toHaveBeenCalled();
});
});

describe('message page size', () => {
const mountChannel = async () => {
const mockedChannel = generateChannelResponse({ messages: [] });
Expand Down
42 changes: 42 additions & 0 deletions package/src/components/Channel/hooks/useJumpToLatestOnOpen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useLayoutEffect } from 'react';

import { useChannelContext } from '../../../contexts/channelContext/ChannelContext';
import { useThreadContext } from '../../../contexts/threadContext/ThreadContext';
import { useMessagePaginator } from '../../../hooks/useMessagePaginator';

/**
* Shows the newest messages when a channel or thread is opened on an older page. Its paginator
* outlives the screen, so it keeps the page it was left on, such as one after a jump to a quoted,
* pinned or searched message. Runs before the first paint, so the old page never flashes.
*
* Stays out of the way of a jump that already landed or is in flight (a deep link from the screen)
* and of the jump to the first unread message. Runs once per channel or thread: its dependencies
* are stable, so nothing that changes while it is open moves the list.
*
* `<Channel>` and `<Thread>` already run it, unless `<Channel initializeOnMount={false}>`. Call it
* from a component inside `<Channel>` or `<Thread>` only when the app opted out that way and still
* wants the newest messages shown on open.
*/
export const useJumpToLatestOnOpen = () => {
const { channel, hasPendingInitialTargetLoad } = useChannelContext();
const { thread } = useThreadContext();
const paginator = useMessagePaginator();

useLayoutEffect(() => {
// A channel that is not loaded yet, or a draft, gets its first page from `<Channel>` itself.
const isChannelLoading = !thread && (!channel.initialized || channel.isProvisional);
if (
isChannelLoading ||
!paginator.items?.length ||
paginator.isActiveIntervalAtHead ||
paginator.isLoading ||
hasPendingInitialTargetLoad?.()
) {
return;
}

paginator
.jumpToTheLatestMessage()
.catch((error: unknown) => console.warn('Jumping to the latest message failed:', error));
}, [channel, hasPendingInitialTargetLoad, paginator, thread]);
};
28 changes: 28 additions & 0 deletions package/src/components/ChannelList/__tests__/ChannelList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,34 @@ describe('ChannelList', () => {
expect(querySpy).not.toHaveBeenCalled();
});

it('loads the first page when an event put a channel in the list before it was ever queried', async () => {
const seededChannel = chatClient.channel(testChannel1.channel.type, testChannel1.channel.id);
// What `message.new` / `member.updated` do for a channel matching a list nobody queried yet.
chatClient.channelManager.ingestChannel(seededChannel);
expect(props.paginator.items?.map((channel) => channel.id)).toEqual([
testChannel1.channel.id,
]);
expect(props.paginator.isInitialized).toBe(false);

useMockedApis(chatClient, [queryChannelsApi([testChannel2, testChannel1])]);
const querySpy = jest.spyOn(chatClient, 'queryChannels');
render(
<Chat client={chatClient}>
<WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
<ChannelList {...props} />
</WithComponents>
</Chat>,
);

// The seeded channel does not count as a loaded first page, so the list queries on mount.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(querySpy).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.getByTestId(testChannel2.channel.id)).toBeTruthy());
expect(props.paginator.isInitialized).toBe(true);
});

it('leaves the paginator registered and loaded when it unmounts', async () => {
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato
// new paginator, and after `client.disconnectUser` resets it. A paginator that already has
// channels is shown as is, and a load in flight is left alone.
const userId = useClientUserId(client);
const isUnloaded = channels === undefined;
const isUnloaded = channels === undefined || !paginator.isInitialized;
useEffect(() => {
if (!isUnloaded || !userId || paginator.isLoading) return;
reloadList();
Expand Down
Loading
Loading