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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,9 @@ Native code shared by both wrappers lives in `package/shared-native/{ios,android

### AI components entry point

`package/src/ai-components/` (ported 1:1 from the deprecated `@stream-io/chat-react-native-ai`) is reachable only through the `ai-components` subpath, implemented as physical folders (`package/ai-components/package.json`, `package/{native,expo}-package/ai-components/`) rather than an `exports` map, which would break deep imports. Metro does not tree-shake, so `bin/validate-entry-isolation.js` (run by `yarn lint`) enforces both directions:
`package/src/ai-components/` (ported 1:1 from the deprecated `@stream-io/chat-react-native-ai`) is reachable only through the `ai-components` subpath, implemented as physical folders (`package/ai-components/package.json`, `package/{native,expo}-package/ai-components/`) rather than an `exports` map, which would break deep imports. Metro does not tree-shake, so `package/bin/validate-entry-isolation.js` (run by `yarn lint`) enforces both directions:

- the main entry points must never reach `src/ai-components` or its dependencies (`react-syntax-highlighter`, `@khanacademy/simple-markdown`, `@stream-io/state-store`, `zod`, `victory-native`, Skia);
- the main entry points must never reach `src/ai-components` or the AI-only packages (`react-syntax-highlighter`, `@khanacademy/simple-markdown`, `zod`, `victory-native`, Skia). `@stream-io/state-store` and `lodash` are shared: the main entry imports them too (the stores in `state-store/`, the Poll contexts);
- the AI entry points may only reach `src/ai-components` plus an explicit allowlist (`src/native.ts`, the native shimmer). The wrappers' `ai-components/index.js` registers the native pieces the AI code needs itself and must never import the wrappers' `src/index.js`.

Do not add AI slots to `DEFAULT_COMPONENTS`; core `require`s the defaults, which would break isolation. Skia and `victory-native` are optional peers (native libraries must be owned by the app); the JS-only AI dependencies are regular dependencies.
Expand Down
4 changes: 2 additions & 2 deletions examples/ExpoChatGPTSample/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';

import { Drawer } from 'expo-router/drawer';

import type { LocalMessage, OperationRequestFn, StreamChat, UserResponse } from 'stream-chat';
import type { LocalMessage, OperationRequestFn, StreamChat } from 'stream-chat';
import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo';

import { StreamTheme } from 'stream-chat-expo/ai-components';
Expand Down Expand Up @@ -54,7 +54,7 @@ export default function Layout() {
const chatClient = useCreateChatClient({
apiKey: chatApiKey,
tokenOrProvider: chatUserToken,
userData: { id: chatUserId, name: chatUserName } as UserResponse,
userData: { id: chatUserId, name: chatUserName },
});

useEffect(() => {
Expand Down
4 changes: 2 additions & 2 deletions examples/ExpoMessaging/components/ChatWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { PropsWithChildren } from 'react';

import { UserResponse } from 'stream-chat';
import type { ClientUser } from 'stream-chat';
import {
Chat,
type LooseTranslationDictionary,
Expand Down Expand Up @@ -49,7 +49,7 @@ export const ChatWrapper = ({ children }: PropsWithChildren) => {
const { user } = useUserContext();
const chatClient = useCreateChatClient({
apiKey: STREAM_API_KEY,
userData: user as UserResponse,
userData: user as ClientUser,
tokenOrProvider: USER_TOKENS[user?.id as string],
});

Expand Down
39 changes: 39 additions & 0 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,45 @@ describe('Channel initial load useEffect', () => {
await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10));
});

describe('message page size', () => {
const mountChannel = async () => {
const mockedChannel = generateChannelResponse({ messages: [] });
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
const channel = chatClient.channel('messaging', mockedChannel.channel.id);
// Force the mount watch, so waiting for it proves the effect got past the page-size write.
channel.initialized = false;
const watchSpy = jest.fn();
channel.watch = watchSpy;

renderComponent({ channel, markReadOnMount: false });
await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));

return channel;
};

it('applies the SDK default when nothing was registered through client.config', async () => {
const channel = await mountChannel();

expect(channel.messagePaginator.pageSize).toBe(25);
});

it('keeps a page size registered under channel.messagePaginator', async () => {
chatClient.config.set({ channel: { messagePaginator: { pageSize: 40 } } });

const channel = await mountChannel();

expect(channel.messagePaginator.pageSize).toBe(40);
});

it('keeps a page size registered under the general messagePaginator key', async () => {
chatClient.config.set({ messagePaginator: { pageSize: 15 } });

const channel = await mountChannel();

expect(channel.messagePaginator.pageSize).toBe(15);
});
});

it('leaves targeting to the caller — <Channel> no longer jumps on its own', async () => {
// The `messageId` prop is gone. Jumping is the paginator's job, so nothing here should issue a
// jump: the integrator calls `paginator.jumpToMessage(...)` themselves, from a `useLayoutEffect`
Expand Down
15 changes: 14 additions & 1 deletion package/src/components/Channel/hooks/useChannelLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import { useNotificationApi } from '../../Notifications';

const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25;

/**
* Whether the integrator registered a message page size through `client.config` — under
* `channel.messagePaginator` or the general `messagePaginator` key, the two the paginator resolves its
* own `pageSize` from. The default is applied only when neither did: the `pageSize` setter is a runtime
* patch, which outranks every declarative layer and would silently discard the registration.
*/
const hasRegisteredMessagePageSize = (client: StreamChat) =>
client.config.getConfig('channel')?.messagePaginator?.pageSize !== undefined ||
client.config.getConfig('messagePaginator')?.pageSize !== undefined;

export type UseChannelLifecycleParams = Pick<TranslationContextValue, 't'> & {
channel: Channel;
client: StreamChat;
Expand Down Expand Up @@ -92,7 +102,10 @@ export const useChannelLifecycle = ({
return;
}

channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE;
// Keep the message-list page light; a page size registered through `client.config` wins.
if (!hasRegisteredMessagePageSize(client)) {
channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE;
}

if (
(!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { renderHook, waitFor } from '@testing-library/react-native';
import type { StreamChat } from 'stream-chat';

import { useCreateChatClient } from '../useCreateChatClient';

const mockConnectUser = jest.fn(() => Promise.resolve(undefined));
const mockDisconnectUser = jest.fn(() => Promise.resolve());

jest.mock('stream-chat', () => {
const actual = jest.requireActual('stream-chat');
class MockStreamChat extends actual.StreamChat {
constructor(...args: ConstructorParameters<typeof actual.StreamChat>) {
super(...args);
this.connectUser = mockConnectUser;
this.disconnectUser = mockDisconnectUser;
}
}
return { ...actual, StreamChat: MockStreamChat };
});

/**
* `userData` is handed straight to `client.connectUser`, so it must accept exactly what that does.
* In stream-chat v10 `OwnUserResponse` / `UserResponse` require server-owned fields (`role`,
* `created_at`, …), so typing it as those rejected the `{ id, name, image }` every guide shows.
*/
describe('useCreateChatClient userData', () => {
it('is typed as what client.connectUser accepts', () => {
// Type half — asserted by construction (this file IS typechecked, via tsconfig.test.json): each
// assignment fails the build if the two types drift apart in either direction.
type HookUserData = Parameters<typeof useCreateChatClient>[0]['userData'];
type ConnectUserData = Parameters<StreamChat['connectUser']>[0];
const toConnect: ConnectUserData | undefined = undefined as HookUserData | undefined;
const toHook: HookUserData | undefined = undefined as ConnectUserData | undefined;
expect([toConnect, toHook]).toEqual([undefined, undefined]);
});

it('connects with a minimal client-side user', async () => {
const userData = { id: 'jane', image: 'https://example.com/jane.png', name: 'Jane' };

const { unmount } = renderHook(() =>
useCreateChatClient({ apiKey: 'key', tokenOrProvider: 'token', userData }),
);

await waitFor(() => expect(mockConnectUser).toHaveBeenCalledWith(userData, 'token'));

const log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
unmount();
await waitFor(() => expect(mockDisconnectUser).toHaveBeenCalled());
log.mockRestore();
});
});
14 changes: 6 additions & 8 deletions package/src/components/Chat/hooks/useCreateChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import { useEffect, useState } from 'react';

import { StreamChat } from 'stream-chat';

import type {
DeepPartial,
InstanceConfigTree,
OwnUserResponse,
TokenOrProvider,
UserResponse,
} from 'stream-chat';
import type { ClientUser, DeepPartial, InstanceConfigTree, TokenOrProvider } from 'stream-chat';

import { netInfoStatusReporter } from './useConnectionLifecycle';

Expand Down Expand Up @@ -49,7 +43,11 @@ export const useCreateChatClient = ({
}: {
apiKey: string;
tokenOrProvider: TokenOrProvider;
userData: OwnUserResponse | UserResponse;
/**
* The user to connect, as `client.connectUser` accepts it: only `id` is required, so
* `{ id, name, image }` is enough. Server-owned fields (`role`, `created_at`, …) need not be set.
*/
userData: ClientUser;
options?: DeepPartial<InstanceConfigTree>;
}) => {
const [chatClient, setChatClient] = useState<StreamChat | null>(null);
Expand Down
Loading