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
69 changes: 69 additions & 0 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,75 @@ v10 SDK code hard-depends on `stream-chat` v10 APIs (`channel.messagePaginator`,

---

# Part I — stream-chat v10 type-surface (OpenAPI overhaul)

The v10 bump brings the OpenAPI type overhaul. Most of it is internal to the SDK (already
migrated); the items below are the ones that reach **integrator** code — custom components,
`<Channel>` override props, and any direct `stream-chat` client/channel usage.

## 17.1 Custom fields moved under `.custom`

v10 drops the custom-overlay pattern: custom fields live under a `.custom` bag, not at the object
root. Relevant only if you authored a custom component/override that reads them at the root — the
SDK's own reads are already migrated.

- `channel.data.name` / `channel.data.image` → `channel.data.custom?.name` / `.custom?.image`
- Attachment metadata: `attachment.mime_type` / `file_size` / `duration` / `originalFile` →
`attachment.custom?.<same>` (`duration` is now `voiceRecording`-only)

The RN SDK augments `CustomChannelData` (`name`, `image`) and `CustomAttachmentData`
(`originalFile`, `localId`). Add your own custom keys the same way (`declare module 'stream-chat'`).

## 17.2 `deleteMessage` options are snake_case

`useMessageOperations().deleteMessage(message, options)` (and the `MessageContext` value) now takes
the LLC's `DeleteMessageOptions` (`{ hard?, delete_for_me?, deleted_by? }`) instead of the v9 RN
shape (`{ hardDelete?, deleteForMe? }`). The `boolean` shorthand (`deleteMessage(msg, true)` = hard
delete) still works.

- `deleteMessage(msg, { hardDelete: true })` → `deleteMessage(msg, { hard: true })`
- `deleteMessage(msg, { deleteForMe: true })` → `deleteMessage(msg, { delete_for_me: true })`

## 17.3 `<Channel doUpdateMessageRequest>` override signature

The override now receives a request object, not a `LocalMessage`:

- v9: `doUpdateMessageRequest(channelId, localMessage, options)`
- v10: `doUpdateMessageRequest(channelId, { id, message }, options)` — `message` is a
`MessageRequest` (derived via `localMessageToNewMessagePayload`), matching the LLC's default
update path.

`doSendMessageRequest`'s `message` argument is now typed `MessageRequest` (rename only; no shape change).

## 17.4 `message.moderation_details` → `message.moderation`

v10 uses V2 moderation: `message.moderation` (`ChatModerationV2Response`, `action: 'remove' |
'bounce'`). `message.moderation_details` (V1) is gone. The SDK's `isBlockedMessage` /
`isBouncedMessage` read only V2 — **if your backend still emits V1 payloads, blocked/bounced
detection won't fire.**

## 17.5 Underlying `stream-chat` type changes (renames / Date / method sigs)

If you call the `stream-chat` client/channel directly or annotate with its types, see the LLC's own
guides in `stream-chat-js`: `v9-to-v10-migration-guide-{type-renames,other,sort,methods,logging,client-construction}.md`.
Highlights that hit integrator code:

- **Dates are `Date` objects** (not ISO strings) on response types (`created_at`, `updated_at`, …).
- **Type renames** — `EventTypes`→`EventType`, `Mute`→`UserMuteResponse`,
`PollOption`→`PollOptionResponseData`, `ReadResponse`→`ReadStateResponse`,
`AppSettingsAPIResponse`→`GetApplicationResponse`, `FormatMessageResponse`→`LocalMessage`,
`Role`→`RoleName`, `Logger`→`Sink`, `*SortBase`→`*Sort`, `TranslationLanguages`→`TranslationLanguage`.
- **`Event` is a discriminated union** — narrow with `EventPayload<'the.type'>`, or drop the `: Event`
annotation so `client.on('x', cb)` narrows automatically.
- **Method signatures collapsed to single objects** — `channel.sendReaction({ id, reaction, ... })`,
`deleteReaction({ id, type })`, `sendMessage({ message, ... })`, `queryChannels(request)`;
`client.uploadImage_(uri, name, type)` for RN image upload (the object-form `uploadImage` is
web/server-shaped); the `client` constructor is 1–2 args; `client.listeners` is a `Map`;
`createAbortControllerForNextRequest` moved to `client.api`.
- **Sort is `SortParamRequest[]`** — `{ last_message_at: -1 }` → `[{ field: 'last_message_at', direction: -1 }]`.

---

## 18. Verify

- Typecheck the customer app; removed symbols surface as "Property does not
Expand Down
18 changes: 9 additions & 9 deletions examples/SampleApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ PODS:
- React-utils
- ReactNativeDependencies
- Yoga
- React-Core-prebuilt (0.86.0):
- React-Core-prebuilt (0.86.2):
- ReactNativeDependencies
- React-Core/CoreModulesHeaders (0.86.2):
- hermes-engine
Expand Down Expand Up @@ -2355,9 +2355,9 @@ PODS:
- React-runtimeexecutor
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- ReactAppDependencyProvider (0.86.0):
- ReactAppDependencyProvider (0.86.2):
- ReactCodegen
- ReactCodegen (0.86.0):
- ReactCodegen (0.86.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -2857,7 +2857,7 @@ PODS:
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- stream-chat-react-native (9.7.1):
- stream-chat-react-native (9.7.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -3291,7 +3291,7 @@ SPEC CHECKSUMS:
GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d
hermes-engine: 38b6a452e40fa51e26047d362f132f386320507b
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Expand All @@ -3309,7 +3309,7 @@ SPEC CHECKSUMS:
React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f
React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92
React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948
React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c
React-Core-prebuilt: 39eb00d8ba4922167a3a70e2039b102fe42b6fe1
React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e
React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6
React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8
Expand Down Expand Up @@ -3379,8 +3379,8 @@ SPEC CHECKSUMS:
React-utils: 25a0beadc5eacbffeb965c7024826b1233dbedc6
React-viewtransitionnativemodule: 8f1d4895e081e0b2f0a98069c098eb40be032138
React-webperformancenativemodule: 5bddf6c7eecda8a3ce48b82609008965cc9d94c2
ReactAppDependencyProvider: dcdd0e1b9559a6d8d8aea05286f4ed085091978e
ReactCodegen: cd296de5e1c422deebafff34134d1721b5c29704
ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475
ReactCodegen: b4e2c5f0d9415b8fe2faf163fca6f40c62f9666a
ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8
ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf
RNCClipboard: 7a7d4557bfd3370b35c99dfecd92ae7b9fc4948a
Expand All @@ -3399,7 +3399,7 @@ SPEC CHECKSUMS:
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
stream-chat-react-native: b3a7450925e3744152f7a5ee008310274debd6c7
stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d
Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a
Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801

Expand Down
39 changes: 26 additions & 13 deletions package/src/__tests__/offline-support/offline-feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import { BetterSqlite } from '../../test-utils/BetterSqlite';
*/
const ChannelPreviewComponent = ({ channel }: { channel: ChannelLLC }) => (
<View accessibilityLabel='list-item' testID={channel.cid}>
<Text>{channel.data?.name}</Text>
<Text>{channel.data?.custom?.name}</Text>
<Text>{channel.messagePaginator?.headItems?.[0]?.text}</Text>
</View>
);
Expand Down Expand Up @@ -255,7 +255,7 @@ export const Generic = () => {
foo: 'bar',
type: 'messaging',
} as ChannelFilters;
const sort: ChannelSort = { last_updated: 1 };
const sort: ChannelSort = [{ direction: 1, field: 'last_updated' }];

const renderComponent = async () => {
const result = render(
Expand Down Expand Up @@ -535,15 +535,15 @@ export const Generic = () => {
const newMessages = [
generateMessage({
cid: targetChannel.cid,
user: chatClient.user,
user: chatClient.user as UserResponse,
}),
generateMessage({
cid: targetChannel.cid,
user: chatClient.user,
user: chatClient.user as UserResponse,
}),
generateMessage({
cid: targetChannel.cid,
user: chatClient.user,
user: chatClient.user as UserResponse,
}),
];

Expand Down Expand Up @@ -897,7 +897,7 @@ export const Generic = () => {
new Date(b.created_at as string | Date).getTime(),
);
// truncate at the middle
const truncatedAt = messages[Number(messages.length / 2)].created_at as string | undefined;
const truncatedAt = messages[Number(messages.length / 2)].created_at as Date | undefined;
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -942,7 +942,7 @@ export const Generic = () => {

const channelResponse = channels[getRandomInt(0, channels.length - 1)];
const channelToTruncate = channelResponse.channel;
const truncatedAt = new Date(0).toISOString();
const truncatedAt = new Date(0);
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -985,7 +985,7 @@ export const Generic = () => {
),
);
// truncate at the middle
const truncatedAt = new Date(latestTimestamp + 1).toISOString();
const truncatedAt = new Date(latestTimestamp + 1);
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -1608,7 +1608,13 @@ export const Generic = () => {

const targetChannel = channels[getRandomInt(0, channels.length - 1)];
const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)];
act(() => dispatchMemberRemovedEvent(chatClient, targetMember, targetChannel.channel));
act(() =>
dispatchMemberRemovedEvent(
chatClient,
targetMember as ChannelMemberResponse,
targetChannel.channel,
),
);

await waitFor(async () => {
const membersRows = await BetterSqlite.selectFromTable('members');
Expand All @@ -1631,7 +1637,13 @@ export const Generic = () => {
const targetChannel = channels[getRandomInt(0, channels.length - 1)];
const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)];
targetMember.role = 'admin';
act(() => dispatchMemberUpdatedEvent(chatClient, targetMember, targetChannel.channel));
act(() =>
dispatchMemberUpdatedEvent(
chatClient,
targetMember as ChannelMemberResponse,
targetChannel.channel,
),
);

await waitFor(async () => {
const membersRows = await BetterSqlite.selectFromTable('members');
Expand All @@ -1656,7 +1668,8 @@ export const Generic = () => {
await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());

const targetChannel = channels[getRandomInt(0, channels.length - 1)];
targetChannel.channel.name = uuidv4();
// `name` is a custom channel field in v10 (`channel.custom.name`), not a root field.
targetChannel.channel.custom = { ...targetChannel.channel.custom, name: uuidv4() };
act(() => dispatchChannelUpdatedEvent(chatClient, targetChannel.channel));

await waitFor(async () => {
Expand All @@ -1669,7 +1682,7 @@ export const Generic = () => {

const extraData = JSON.parse(matchingChannelsRows[0].extraData as string);

expect(extraData.name).toBe(targetChannel.channel.name);
expect(extraData.custom.name).toBe(targetChannel.channel.custom?.name);
});
});

Expand Down Expand Up @@ -1731,7 +1744,7 @@ export const Generic = () => {
const targetChannel = channels[getRandomInt(0, channels.length - 1)];
const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)];

chatClient.userID = targetMember.user!.id;
// `userID` is now a read-only getter derived from `user.id`; setting `user` is enough.
chatClient.user = targetMember.user;

const readTimestamp = new Date().toISOString();
Expand Down
21 changes: 11 additions & 10 deletions package/src/__tests__/offline-support/optimistic-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react-na

import type {
Channel as ChannelLLC,
ChannelAPIResponse,
ChannelMemberResponse,
LocalMessage,
ReactionResponse,
Expand Down Expand Up @@ -167,7 +166,7 @@ export const OptimisticUpdates = () => {
await SqliteClient.initializeDatabase();
await BetterSqlite.openDB();
await upsertChannels({
channels: [channelResponse] as unknown as ChannelAPIResponse[],
channels: [channelResponse] as unknown as Parameters<typeof upsertChannels>[0]['channels'],
isLatestMessagesSet: true,
});
chatClient.wsConnection = {
Expand Down Expand Up @@ -670,11 +669,13 @@ export const OptimisticUpdates = () => {
attachments: [
{
asset_url: localUri,
originalFile: generateFileReference({
name: 'edited-attachment.png',
type: 'image/png',
uri: localUri,
}),
custom: {
originalFile: generateFileReference({
name: 'edited-attachment.png',
type: 'image/png',
uri: localUri,
}),
},
type: 'file',
},
],
Expand Down Expand Up @@ -815,13 +816,13 @@ export const OptimisticUpdates = () => {
const localMessage = generateMessage({
status: MessageStatusTypes.SENDING,
text: 'offline resend',
user: chatClient.user,
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
});
const serverMessage = generateMessage({
id: localMessage.id,
text: localMessage.text,
user: chatClient.user,
user: chatClient.user as UserResponse,
user_id: chatClient.userID,
});

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';

import { AIState, Channel, Event } from 'stream-chat';
import { AIState, Channel } from 'stream-chat';

import { useChatContext } from '../../../contexts';

Expand Down Expand Up @@ -33,7 +33,7 @@ export const useAIState = (channel?: Channel): { aiState: AIState } => {
return;
}

const indicatorChangedListener = channel.on('ai_indicator.update', (event: Event) => {
const indicatorChangedListener = channel.on('ai_indicator.update', (event) => {
const { cid } = event;
const state = event.ai_state as AIState;
if (channel.cid === cid) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import { useCallback, useEffect, useRef } from 'react';

import type { Channel, Event, MessageResponse } from 'stream-chat';
import type { Channel, EventPayload, LocalMessage, MessageResponse } from 'stream-chat';

import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext';
import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { useAccessibilityAnnouncer } from '../useAccessibilityAnnouncer';

const MESSAGE_ANNOUNCEMENT_THROTTLE_MS = 1000;

const isAnnounceableIncomingMessage = (message: MessageResponse, ownUserId?: string): boolean => {
const isAnnounceableIncomingMessage = (
message: MessageResponse | LocalMessage,
ownUserId?: string,
): boolean => {
const messageUserId = message.user?.id;
if (!message.id || !messageUserId || messageUserId === ownUserId) return false;
// `status` only exists on `LocalMessage`, not on a bare `MessageResponse`.
const status = 'status' in message ? message.status : undefined;
return (
message.type !== 'deleted' &&
message.type !== 'ephemeral' &&
message.type !== 'error' &&
message.type !== 'system' &&
message.status !== 'failed' &&
message.status !== 'sending'
status !== 'failed' &&
status !== 'sending'
);
};

Expand Down Expand Up @@ -104,7 +109,7 @@ export const useIncomingMessageAnnouncements = ({
useEffect(() => {
if (!enabled || !announceNewMessages || !channel) return;

const handleMessageNew = (event: Event) => {
const handleMessageNew = (event: EventPayload<'message.new'>) => {
const message = event.message;
if (!message) return;
if (
Expand Down
Loading
Loading