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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,12 @@ knows it is not rendering a thread. Resolve the list's data with `useMessagePagi

`<Chat>` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider`.

**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-runs
each channel list's own first-page query and reloads whichever channels are `active` (`<Channel>`
marks its channel active on mount), then dispatches `connection.recovered`. So do not add a
**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-queries
every page each registered channel list has loaded, through the list's own query (up to 150 channels,
`ChannelPaginator.recoverLoadedItems()`), drops the loaded channels no page returned, and reloads
whichever channels are `active` (`<Channel>` marks its channel active on mount), then dispatches
`connection.recovered`. `ChannelList`'s pull-to-refresh is separate and still re-queries only the first
page. So do not add a
`connection.changed` listener that re-queries a list or re-watches a channel — that duplicates it.
Recovery is on by default and configured declaratively
(`client.config.set({ client: { connectionRecovery: { enabled: false } } })` turns it off); the old
Expand Down
3 changes: 3 additions & 0 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ means changed. Details in the linked section.
| `<Channel thread={thread} threadList><Thread /></Channel>` | `<Thread thread={thread}><MessageList /><MessageComposer /></Thread>`, no `<Channel>` around it | §10.1 |
| `threadList` (prop or context field) | `useThreadContext().thread` | §10.2 |
| `ThreadMessageComposer` slot / `additionalMessageComposerProps` | a `MessageComposer` child of `<Thread>` with its own props | §10.2 |
| `MessageList` slot | a `MessageList` (or `MessageFlashList`) child of `<Thread>`, or your own list component | §10.2 |
| `useMessageComposerContext().thread` (parent message; deep import only) | `thread` is now the `Thread`; parent on `thread.state` | §10.2 |
| `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | P.17 |
| `<MessageList targetedMessage / loadMoreThread / threadInstance …>` props | removed — list reads the `Thread` from `ThreadContext` + paginator internally; `thread` stays, retyped to `Thread \| null` | §12 |
Expand Down Expand Up @@ -819,6 +820,8 @@ registers a new one.
`parentMessagePreventPress` to `ThreadFooterComponent`.
- **`ThreadMessageComposer` slot**: removed from `WithComponents`. Render the composer you want as a
child of `<Thread>`.
- **`MessageList` slot**: removed from `WithComponents`. In v9 only `<Thread>` read it, to render the
thread's list; `<Thread>` now renders its children, so render the list you want as a child.
- **`threadList`**: removed everywhere - `ChannelContextValue.threadList`,
`MessageContextValue.threadList`, the `threadList` prop of `Message`, `MessageList`,
`MessageFlashList` and `MessageComposer`, and `useIncomingMessageAnnouncements({ threadList })`.
Expand Down
3 changes: 2 additions & 1 deletion ai-docs/i18n-v10-key-map.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$comment": "v9 -> v10 translation key map for stream-chat-react-native. The left-hand side is the old key, which was the English copy itself; the right-hand side is the stable dotted key that replaces it. `plural` entries live in the catalog as <key>_one / <key>_other, while call sites use the bare key and pass `count`. Keys not listed here have no v10 equivalent -- either they were dead in v9, or the component/notification that rendered them is gone; see the \"Removed keys\" table in i18n-v10-migration.md. Generated keys and their English copy: `yarn i18n:export`.",
"$comment": "v9 -> v10 translation key map for stream-chat-react-native. The left-hand side is the old key, which was the English copy itself; the right-hand side is the stable dotted key that replaces it. `plural` entries live in the catalog as <key>_one / <key>_other, while call sites use the bare key and pass `count`. An entry with `alsoKeys` was one v9 key that v10 split into several: apply the old translation to `key` and to each of `alsoKeys`. Keys not listed here have no v10 equivalent -- either they were dead in v9, or the component/notification that rendered them is gone; see the \"Removed keys\" table in i18n-v10-migration.md. Generated keys and their English copy: `yarn i18n:export`.",
"count": 389,
"keys": {
"+{{count}}": {
Expand Down Expand Up @@ -1429,6 +1429,7 @@
},
"a11y/Double tap to open": {
"key": "attachment.gallery.doubleTapToOpen.accessibilityLabel",
"alsoKeys": ["channelPreview.doubleTapToOpen.accessibilityLabel"],
"prose": true,
"plural": false
},
Expand Down
4 changes: 3 additions & 1 deletion ai-docs/i18n-v10-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ timestampFormatter(...) }}` breaks date rendering outright. Pass `--all` for the

## The old→new key table

Every old key maps to exactly one new key. The full table (389 rows) is
Every old key maps to one new key, except `a11y/Double tap to open`, which v10 split between the gallery and the
channel list row: its entry lists the second key under `alsoKeys`, and an old translation applies to both. The full
table (389 rows) is
[`i18n-v10-key-map.json`](./i18n-v10-key-map.json). Keys absent from it were dead in v9 and have been removed.
The exception is four formatter expressions the SDK never rendered but shipped in `en.json`, so apps called them
directly: `duration/Location end at`, `duration/Remind Me`, `timestamp/ReminderNotification` and
Expand Down
4 changes: 2 additions & 2 deletions ai-docs/v10-breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Docs impact (which getstream.io pages change) is in [`v10-docs-impact.md`](./v10
4. the `examples/` diff (the migration the team already did on its own apps), the deleted/rewritten tests, and a
cross-check against the `stream-chat` and `stream-chat-react` v10 breaking-change lists.
- Not breaking (checked): theme shape (`Theme` is identical), the `icons` map, `DEFAULT_COMPONENTS` slot names except
the removed `ThreadMessageComposer`, native modules, podspecs, the Expo config plugin, `registerNativeHandlers` and
the removed `ThreadMessageComposer` and `MessageList`, native modules, podspecs, the Expo config plugin, `registerNativeHandlers` and
the native handler interfaces.

---
Expand Down Expand Up @@ -420,7 +420,7 @@ message input).

## 9. Component slots, icons and theme

- One slot was removed: `ThreadMessageComposer`. Render the composer you want as a child of `<Thread>`. — §10.2 ·
- Two slots were removed: `ThreadMessageComposer` and `MessageList`, the ones `<Thread>` read in v9. Render the composer and the list you want as children of `<Thread>`. — §10.2 ·
#3867
- No other `DEFAULT_COMPONENTS` slot or icon was added, removed or renamed, and the `Theme` type is identical. What
changed is the **props and context** those slots receive (sections 3–4) — a custom slot written against v9 contexts
Expand Down
10 changes: 5 additions & 5 deletions examples/SampleApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2453,7 +2453,7 @@ PODS:
- ReactNativeDependencies
- RNFBApp
- Yoga
- RNGestureHandler (3.3.0):
- RNGestureHandler (3.4.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2815,7 +2815,7 @@ PODS:
- SDWebImage/Core (~> 5.17)
- SocketRocket (0.7.1):
- ReactNativeDependencies
- stream-chat-react-native (9.10.0):
- stream-chat-react-native (9.10.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -3392,7 +3392,7 @@ SPEC CHECKSUMS:
RNFastImage: 25f87dde869e11156daeef98b4cee2371023407e
RNFBApp: 21e4d8c20656c236a818ee2eff3f859557ceff33
RNFBMessaging: e3e31aec89cd0eb986c87ec8247f598190b61c36
RNGestureHandler: ddbdf3f293a498368803f8181ff157271ac05c48
RNGestureHandler: abdc2d49ac3970908acd127980b429d377927ee1
RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168
RNReactNativeHapticFeedback: a1bba076645e8fedcb65c8e70461b77cd5d6dd32
RNReanimated: 5c641cc430b7255eae87c8039204eb0f816ea1b8
Expand All @@ -3405,10 +3405,10 @@ SPEC CHECKSUMS:
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871
stream-chat-react-native: dc4a04f6903f9616ed0d58757e813e96bda1972f
stream-chat-react-native: 291a3f91fef6922e89ed97bf7a790bf4a786bab9
Teleport: 8a97ac5c0d76c04e20c36843d4d8f8fe5aa1aa73
Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e

PODFILE CHECKSUM: ac9682db83e42d63d399a8de4586161e86b3e751

COCOAPODS: 1.16.2
COCOAPODS: 1.15.2
14 changes: 7 additions & 7 deletions package/src/__tests__/offline-support/offline-feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ export const Generic = () => {
const readRows = await BetterSqlite.selectFromTable('reads');
const matchingMessageRows = messagesRows.filter((m) => m.id === newMessage.id);
const matchingReadRows = readRows.filter(
(r) => targetChannel.cid === r.cid && chatClient.userID === r.userId,
(r) => targetChannel.cid === r.cid && chatClient.userId === r.userId,
);

expect(matchingMessageRows.length).toBe(1);
Expand All @@ -505,7 +505,7 @@ export const Generic = () => {
await waitFor(async () => {
const readRows = await BetterSqlite.selectFromTable('reads');
const matchingReadRows = readRows.filter(
(r) => targetChannel.cid === r.cid && chatClient.userID === r.userId,
(r) => targetChannel.cid === r.cid && chatClient.userId === r.userId,
);

expect(matchingReadRows.length).toBe(1);
Expand Down Expand Up @@ -538,7 +538,7 @@ export const Generic = () => {
newMessages.some((newMessage) => newMessage.id === m.id),
);
const matchingReadRows = readRows.filter(
(r) => targetChannel.cid === r.cid && chatClient.userID === r.userId,
(r) => targetChannel.cid === r.cid && chatClient.userId === r.userId,
);

expect(matchingMessageRows.length).toBe(3);
Expand Down Expand Up @@ -567,7 +567,7 @@ export const Generic = () => {
await waitFor(async () => {
const readRows = await BetterSqlite.selectFromTable('reads');
const matchingReadRows = readRows.filter(
(r) => targetChannel.cid === r.cid && chatClient.userID === r.userId,
(r) => targetChannel.cid === r.cid && chatClient.userId === r.userId,
);

expect(matchingReadRows.length).toBe(1);
Expand Down Expand Up @@ -600,7 +600,7 @@ export const Generic = () => {
newMessages.some((newMessage) => newMessage.id === m.id),
);
const matchingReadRows = readRows.filter(
(r) => targetChannel.cid === r.cid && chatClient.userID === r.userId,
(r) => targetChannel.cid === r.cid && chatClient.userId === r.userId,
);

expect(matchingMessageRows.length).toBe(3);
Expand Down Expand Up @@ -955,7 +955,7 @@ export const Generic = () => {

const readsRows = await BetterSqlite.selectFromTable('reads');
const matchingReadRows = readsRows.filter(
(r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid,
(r) => r.userId === chatClient.userId && r.cid === channelToTruncate.cid,
);

expect(matchingMessagesRows.length).toBe(0);
Expand Down Expand Up @@ -1006,7 +1006,7 @@ export const Generic = () => {

const readsRows = await BetterSqlite.selectFromTable('reads');
const matchingReadRows = readsRows.filter(
(r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid,
(r) => r.userId === chatClient.userId && r.cid === channelToTruncate.cid,
);

const messagesLeft = messages.length / 2 - 1;
Expand Down
10 changes: 5 additions & 5 deletions package/src/__tests__/offline-support/optimistic-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ export const OptimisticUpdates = () => {
status: MessageStatusTypes.SENDING,
text: 'unsent across a restart',
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
user_id: chatClient.userId,
});

jest
Expand Down Expand Up @@ -1069,7 +1069,7 @@ export const OptimisticUpdates = () => {
status: MessageStatusTypes.SENDING,
text: 'no guard probe please',
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
user_id: chatClient.userId,
});

jest
Expand Down Expand Up @@ -1297,14 +1297,14 @@ export const OptimisticUpdates = () => {
status: MessageStatusTypes.SENDING,
text: 'offline resend',
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
user_id: chatClient.userId,
});
const serverMessage = generateMessage({
cid: channel.cid,
id: localMessage.id,
text: localMessage.text,
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
user_id: chatClient.userId,
});

jest
Expand Down Expand Up @@ -1412,7 +1412,7 @@ export const OptimisticUpdates = () => {
status: MessageStatusTypes.SENDING,
text: 'offline resend unresolved',
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
user_id: chatClient.userId,
});

jest
Expand Down
49 changes: 44 additions & 5 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AppState, AppStateStatus, View } from 'react-native';

import { act, cleanup, render, waitFor } from '@testing-library/react-native';
import type { Channel as ChannelType, StreamChat as StreamChatType } from 'stream-chat';
import { asTimestampNS, StreamChat } from 'stream-chat';
import { asTimestampNS, ChannelWatchStatus, StreamChat } from 'stream-chat';

import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext';
import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext';
Expand Down Expand Up @@ -153,7 +153,7 @@ describe('Channel', () => {
} as never);
draft.initialized = true;
const watchSpy = jest.spyOn(draft, 'watch');
const reloadSpy = jest.spyOn(draft.messagePaginator, 'reload');
const seedSpy = jest.spyOn(draft.messagePaginator, 'reload');

renderComponent({ channel: draft });
// let the mount effect run to its end
Expand All @@ -163,7 +163,7 @@ describe('Channel', () => {

expect(draft.isProvisional).toBe(true);
expect(watchSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
expect(seedSpy).not.toHaveBeenCalled();
});

it('should set an error if channel watch fails and render a LoadingErrorIndicator', async () => {
Expand Down Expand Up @@ -757,6 +757,45 @@ describe('Channel initial load useEffect', () => {
await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10));
});

describe('watch on mount for an initialized channel at the head', () => {
const mountLoadedChannel = async (watchStatus: ChannelWatchStatus) => {
const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) }));
const mockedChannel = generateChannelResponse({ messages });
useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
const channel = chatClient.channel('messaging', mockedChannel.channel.id);
await channel.watch();
jest.spyOn(channel.messagePaginator, 'isActiveIntervalAtHead', 'get').mockReturnValue(true);
channel.watchStatus = watchStatus;
const watchSpy = jest.fn();
channel.watch = watchSpy;
const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot');

renderComponent({ channel, markReadOnMount: false });
// The seed runs after the watch decision, so the mount effect has made it by then.
await waitFor(() => expect(seedSpy).toHaveBeenCalled());

return watchSpy;
};

it('watches a channel that was loaded without a watch, such as a search result', async () => {
const watchSpy = await mountLoadedChannel(ChannelWatchStatus.NotWatching);

expect(watchSpy).toHaveBeenCalledTimes(1);
});

it('does not watch a channel that is already watched', async () => {
const watchSpy = await mountLoadedChannel(ChannelWatchStatus.Watching);

expect(watchSpy).not.toHaveBeenCalled();
});

it('leaves a channel whose watch dropped with the connection to the connection recovery', async () => {
const watchSpy = await mountLoadedChannel(ChannelWatchStatus.WasWatching);

expect(watchSpy).not.toHaveBeenCalled();
});
});

describe('message page size', () => {
const mountChannel = async () => {
const mockedChannel = generateChannelResponse({ messages: [] });
Expand Down Expand Up @@ -979,7 +1018,7 @@ describe('Channel initial load useEffect', () => {
await channel.watch();

// Call through to the real reload so the resync actually reconciles (and we can assert it ran).
const reloadSpy = jest.spyOn(channel, 'reload');
const seedSpy = jest.spyOn(channel, 'reload');

renderComponent({ channel });

Expand Down Expand Up @@ -1007,7 +1046,7 @@ describe('Channel initial load useEffect', () => {
// The reload re-watches and reconciles, but preserves the failed (locally-unsent) messages —
// the 10 originals + 10 failed remain.
await waitFor(() => {
expect(reloadSpy).toHaveBeenCalled();
expect(seedSpy).toHaveBeenCalled();
expect(channel.messagePaginator.anchoredHeadItems.length).toBe(20);
});
});
Expand Down
5 changes: 4 additions & 1 deletion package/src/components/Channel/hooks/useChannelLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';

import { ChannelWatchStatus } from 'stream-chat';
import type { Channel, StreamChat } from 'stream-chat';

import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext';
Expand Down Expand Up @@ -107,7 +108,9 @@ export const useChannelLifecycle = ({
}

if (
(!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) &&
(!channel.initialized ||
!channel.messagePaginator.isActiveIntervalAtHead ||
channel.watchStatus === ChannelWatchStatus.NotWatching) &&
initializeOnMount
) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const Providers = ({ children }: PropsWithChildren) => (
}}
>
<ChatContext.Provider
value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never}
value={{ client: { notifications: new NotificationManager(), userId: 'me' } } as never}
>
{children}
</ChatContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const sectionElement = () => (
mutedUsers: [],
mutedUsersStore,
on: () => ({ unsubscribe: () => undefined }),
userID: 'me',
userId: 'me',
},
} as never
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const Providers = ({ children }: PropsWithChildren) => (
}}
>
<ChatContext.Provider
value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never}
value={{ client: { notifications: new NotificationManager(), userId: 'me' } } as never}
>
{children}
</ChatContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const renderSection = ({
mutedUsers: [],
notifications: new NotificationManager(),
on: () => ({ unsubscribe: () => undefined }),
userID: 'me',
userId: 'me',
},
} as never
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const Providers = ({ children }: PropsWithChildren) => (
}}
>
<ChatContext.Provider
value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never}
value={{ client: { notifications: new NotificationManager(), userId: 'me' } } as never}
>
{children}
</ChatContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const buildChannel = () =>
({
cid: 'messaging:test',
data: {},
getClient: () => ({ userID: OWN_USER_ID }),
getClient: () => ({ userId: OWN_USER_ID }),
on: () => ({ unsubscribe: () => undefined }),
}) as unknown as Channel;

Expand All @@ -45,7 +45,7 @@ const renderProfile = ({ channel = buildChannel() }: { channel?: Channel } = {})
userLanguage: 'en',
}}
>
<ChatProvider value={{ client: { userID: OWN_USER_ID } } as never}>
<ChatProvider value={{ client: { userId: OWN_USER_ID } } as never}>
<ChannelDetailsContextProvider channel={channel}>
<ChannelDetailsProfile />
</ChannelDetailsContextProvider>
Expand Down
Loading
Loading