Upgrade to v10
This page lists what changed between v9 and v10 of Stream Chat React Native, and what to change in your app. Each change links to the v10 page that explains how the new version works. The samples import from stream-chat-react-native. In an Expo app, import the same names from stream-chat-expo.
This page starts from v9. Upgrade an app on v8 to v9 first with the "Upgrading from v8" guide, which you open by picking v9 in the version selector, and then follow this page.
v10 needs stream-chat 10, version 10.0.0-rc.26 or later. Upgrade both packages together, as Installation describes. Many of the changes below come from stream-chat 10. This page covers the ones a React Native app meets. The complete list of client changes is stream-chat v9 to v10 breaking changes in the stream-chat repository.
Quick migration checklist
- Upgrade React Native to 0.79 or later and Expo to SDK 53 or later, then install the SDK and
stream-chatfrom therctag. See Dependencies. - Move the options you passed to
useCreateChatClientornew StreamChat()underclient. See Creating the client. - Update how you read data. Your custom fields are under
custom, server timestamps are nanosecond numbers, and sorts are arrays. See Data shapes. - Rewrite every
ChannelList. Create aChannelPaginator, register it onclient.channelManager, and pass it as thepaginatorprop. See Channel lists. - Render
Threadnext toChannelinstead of inside it, pass it aThreadobject, and render the thread's list and composer as its children. See Threads. - Move the
Channelprops that customized requests, throttling, uploads and pruning toclient.config. See Channel props that moved. - Replace
TypingContext,PaginatedMessageListContextand the removed context fields with reads from the channel's stores. See Reading channel state. - Call message operations through
useMessageOperations()instead ofuseMessagesContext(). See Messages. - Replace
isOnlineandconnectionRecoveringfromuseChatContext(), and anyconnection.changedlistener. See Connection status. - Rename your translation keys, and supply any language other than English yourself. See Localization.
- Expect the offline database to be rebuilt on the first launch after the upgrade. See Offline storage.
What's new
- How the SDK is built follows one sent message through the state layer in
stream-chatand the UI layer in the SDK. - Configuration covers
client.config, the one place to configure the client and the objects it creates. - Channel list paginators and Channel manager and ChannelPaginator describe channel lists as objects, with
client.channelManagerkeeping several lists current. - Message paginators and Message paginator cover message intervals, jumping to a message, and unread tracking.
- Message operations covers the optimistic send, retry, update and delete, and the request handlers that customize them.
- Connection describes the device's network and the WebSocket as two separate reactive services.
- Reading state in components explains
useStateStore, which hook reads which store, and the selector rules. - Thread UI and Navigation show threads on their own screens.
Dependencies
| Package | v9 | v10 |
|---|---|---|
stream-chat |
^9 |
^10.0.0-rc.26 |
react-native (peer) |
>=0.76 |
>=0.79 |
expo (peer, Expo only) |
>=52 |
>=53 |
dayjs |
1.11.13, pinned |
^1.11.23 |
i18next |
a direct dependency | none. It comes through @stream-io/i18n |
@stream-io/i18n |
none | a new dependency, for the translation runtime |
Upgrade React Native or Expo first. stream-chat and @stream-io/i18n resolve their files through package exports, which Metro reads by default from the version that React Native 0.79 uses.
stream-chat 10 requires Node 22.18 or later in its engines field. Node is not your app's runtime, but it runs your install and your CI. Yarn 1 refuses to install on an older Node, and npm and pnpm print a warning.
Your app imports from stream-chat too, so add it at the version the SDK uses. Exactly one copy must resolve. The same goes for dayjs. If your app declares it, use a range that matches ^1.11.23, or a second copy installs and the dayjs locales you import never reach the dates the SDK formats. Don't declare i18next.
StateStore and Unsubscribe are no longer exported from stream-chat. If you build your own stores, import them from @stream-io/state-store, add that package to your app, and keep one copy of it installed. A second copy makes your stores unassignable to useStateStore.
- import { StateStore, type Unsubscribe } from "stream-chat";
+ import { StateStore, type Unsubscribe } from "@stream-io/state-store";Localization
If you use the SDK in English and never customized translations, there is nothing to do here. The v10 behavior is on Localization. This section is the migration.
English is the only bundled language
The ar, es, fr, he, hi, it, ja, ko, nl, pt-BR, ru and tr dictionaries are removed, together with their dayjs calendar formats. So are the exports enTranslations, esTranslations, frTranslations, heTranslations, hiTranslations, itTranslations, jaTranslations, koTranslations, nlTranslations, ptBRTranslations, ruTranslations and trTranslations. Metro doesn't drop unused code, so every app bundled all twelve languages, even one that only rendered English.
If you rendered one of them, supply it yourself. The last published files are in the SDK's git history:
git show v9.10.1:package/src/i18n/nl.json > nl.jsonIts keys are the old ones, so rename them before you register it, as the next section describes. You also need the dayjs locale and its calendar formats, which Localization shows.
Translation keys are dotted identifiers
A key used to be the English copy itself. It's now a stable dotted identifier, and the SDK passes the English copy at each call as i18next's defaultValue. The v9 key a11y/Send message became messageInput.sendMessage.accessibilityLabel. The a11y/ prefix is gone, and what the text is for is the last segment of the key.
Renaming your keys isn't optional, and a missed one fails quietly. An old key never matches, so your translation stops applying and the SDK's English copy renders instead, with no error.
The table that maps every v9 key to its v10 key, 389 rows, is ai-docs/i18n-v10-key-map.json in the SDK repository. A few rows:
| v9 key | v10 key |
|---|---|
Cancel |
common.cancel.label |
Reply |
message.reply.label |
Send a message |
autoCompleteInput.placeholder |
a11y/Send message |
messageInput.sendMessage.accessibilityLabel |
{{count}} new messages |
messageList.newMessages.text, a plural key |
A German dictionary in v9:
i18n.registerTranslation("de", {
Cancel: "Abbrechen",
"a11y/Send message": "Nachricht senden",
"{{count}} new messages": "{{count}} neue Nachrichten",
});The same dictionary in v10:
import { Streami18n } from "stream-chat-react-native";
import type { TranslationDictionary } from "stream-chat-react-native";
const de: TranslationDictionary = {
"common.cancel.label": "Abbrechen",
"messageInput.sendMessage.accessibilityLabel": "Nachricht senden",
"messageList.newMessages.text_one": "{{count}} neue Nachricht",
"messageList.newMessages.text_other": "{{count}} neue Nachrichten",
};
export const i18n = new Streami18n();
i18n.registerTranslation("de", de);Typing the dictionary as TranslationDictionary turns a missed key into a compile error. Streami18n.registerTranslation() and translationsForLanguage take that type, so a dictionary written inline is checked without the annotation.
Five count strings that took {{count}} but weren't plural in v9 are plural keys now: messageList.newMessages.text, messageList.scrollToBottom.withCount.accessibilityLabel, channelPreview.unreadCount.accessibilityLabel, message.reactionList.more.accessibilityLabel and avatar.channel.group.accessibilityLabel. Provide their _one and _other forms, and the other plural forms your language needs. A translation of the bare key no longer applies.
One v9 key became two. a11y/Double tap to open was the screen reader hint of both an image gallery item and a channel list row. In v10 they're attachment.gallery.doubleTapToOpen.accessibilityLabel and channelPreview.doubleTapToOpen.accessibilityLabel, so a translation of one no longer changes the other. Translate both. The key map lists the second one under alsoKeys.
Your own keys
t() from useTranslationContext() is typed StreamTFunction, and accepts only the SDK's keys. To translate your app's own copy on the same Streami18n instance, register it in a LooseTranslationDictionary, and wrap the key in asDynamicKey() when you read it:
import { Text } from "react-native";
import {
asDynamicKey,
Streami18n,
useTranslationContext,
} from "stream-chat-react-native";
import type { LooseTranslationDictionary } from "stream-chat-react-native";
const appKeys: LooseTranslationDictionary = {
"myApp.settings.title": "Einstellungen",
};
export const i18n = new Streami18n({ language: "de" });
i18n.registerTranslation("de", appKeys);
export const SettingsTitle = () => {
const { t } = useTranslationContext();
return <Text>{t(asDynamicKey("myApp.settings.title"), "Settings")}</Text>;
};Nothing checks the SDK's keys inside a LooseTranslationDictionary, so keep those in a TranslationDictionary.
Keys whose value is not copy
The timestamp/* and duration/* keys became timestamp.* and duration.*. Their values are dayjs and i18next expressions. The SDK ships them as defaults that every dictionary you register is merged over, so you list one only to change it. Two of them, timestamp.ChannelPreviewStatus and timestamp.ThreadListItem, put English day words such as "Yesterday" in their own calendarFormats argument. To translate those words, override the two keys. Localization has the expressions.
Four formatter keys that v9 shipped but never rendered are gone from the catalog, with no replacement key: duration/Location end at, duration/Remind Me, timestamp/ReminderNotification and timestamp/MessageEditedTimestamp. If your app called them, register the expressions under keys of your own, for every language you register, English included:
import { Streami18n } from "stream-chat-react-native";
import type { LooseTranslationDictionary } from "stream-chat-react-native";
const appFormatterExpressions: LooseTranslationDictionary = {
"duration.locationEndAt":
"{{ milliseconds | durationFormatter(withSuffix: false) }}",
"duration.remindMe":
"{{ milliseconds | durationFormatter(withSuffix: true) }}",
"timestamp.reminderNotification":
"{{ timestamp | timestampFormatter(calendar: true) }}",
};
export const i18n = new Streami18n();
i18n.registerTranslation("en", appFormatterExpressions);The relativeCompactDateFormatter formatter is removed. In an expression you override, write {{ timestamp | timestampFormatter(relativeCompact: true) }} instead. An unknown formatter doesn't throw, so a leftover one renders the timestamp wrong.
Convert a server timestamp with convertTimestampToDate() from stream-chat before you pass it to t("timestamp.X", { timestamp }). The interpolation values aren't type-checked, and a raw nanosecond timestamp renders as "Invalid Date". See Timestamps are nanosecond numbers.
Notifications and poll errors
The SDK translates a notification by its notification.type, not by its English message. A dictionary keyed by the English notification text no longer applies.
The errors in the poll composer's state are PollComposerValidationError objects instead of English strings. If you render them yourself, choose your copy by error.code and fall back to error.message.
The translation runtime
Streami18n is built on the @stream-io/i18n package, shared with the React SDK. You still import it from stream-chat-react-native. Code that calls the instance directly needs these changes:
| v9 | v10 |
|---|---|
new Streami18n(options, i18nextConfig) |
new Streami18n({ ...options, i18nextConfigOverrides: i18nextConfig }) |
await i18n.getTranslators() |
await i18n.init(), which resolves to the same value |
const t = await i18n.setLanguage("de") |
await i18n.setLanguage("de") resolves to nothing. Read t from i18n.state |
i18n.addOnLanguageChangeListener(listener) |
i18n.state.subscribeWithSelector(({ t }) => ({ t }), ({ t }) => listener(t)) |
i18n.addOnTFunctionOverrideListener(listener) |
subscribe to i18n.state |
i18n.geti18Instance() |
i18n.i18nInstance |
i18n.getAvailableLanguages() |
i18n.registeredLanguages, a read-only Set |
i18n.getTranslations() |
removed. Call i18n.t(key) to read a translation |
i18n.isCustomDateTimeParser, i18n.Dayjs |
removed |
Streami18n.registerTranslation() merges the dictionary into the language's existing one, so repeated calls add up, and a partial dictionary keeps the bundled formatter keys. Its third argument is a DayjsLocaleConfig.
The date helpers were deep imports in v9 and are exported from the package root in v10. getDateString({ date }) takes messageCreatedAt instead of date, and returns null instead of undefined when there is nothing to render. getDateStringForA11y is now getCalendarDateStringForA11y. The name getDateStringForA11y still exists, but it formats the date in full, as the React SDK does.
Every component and hook that takes or returns t types it as StreamTFunction. useTranslationContext().userLanguage is a string, and DEFAULT_USER_LANGUAGE is "en". stream-chat's translation API takes a closed set of languages, so narrow userLanguage before you pass it there. The values of SCROLL_TO_BOTTOM_ACCESSIBILITY_LABEL_KEY and SCROLL_TO_BOTTOM_WITH_COUNT_ACCESSIBILITY_LABEL_KEY are the dotted keys. isDayOrMoment comes from @stream-io/i18n, and narrows to DateTimeLike. TDateTimeParserInput and TDateTimeParserOutput are typed by @stream-io/i18n too.
Creating the client
stream-chat 10 builds the client from one configuration tree, the same tree client.config.set() takes. The options v9 took at the top level now go under client. useCreateChatClient passes its options to the client, so they move too.
In v9:
const client = useCreateChatClient({
apiKey,
options: { baseURL: "https://chat.example.com", timeout: 6000 },
tokenOrProvider: userToken,
userData: { id: userId },
});In v10:
import { Text } from "react-native";
import { Chat, useCreateChatClient } from "stream-chat-react-native";
const apiKey = "YOUR_API_KEY";
const userId = "YOUR_USER_ID";
const userToken = "YOUR_USER_TOKEN";
export const App = () => {
const client = useCreateChatClient({
apiKey,
options: {
client: {
axiosRequestConfig: { timeout: 6000 },
baseURL: "https://chat.example.com",
},
},
tokenOrProvider: userToken,
userData: { id: userId },
});
if (!client) return <Text>Connecting...</Text>;
return <Chat client={client}>{/* your app */}</Chat>;
};A client you create yourself takes the same tree, new StreamChat(apiKey, { client: { baseURL } }), and so does StreamChat.getInstance(). The secret argument is gone.
Where the common v9 options went:
| v9 option | v10 setting |
|---|---|
timeout, headers and the other top-level axios options |
client.axiosRequestConfig |
baseURL |
client.baseURL |
persistUserOnConnectionFailure |
client.persistUserOnConnectionFailure |
enableWSFallback |
client.wsConnection.enableWSFallback |
recoverStateOnReconnect |
client.connectionRecovery.enabled |
isLocalUnreadCountEnabled |
channel.readEvents.localUnreadCountEnabled |
logger |
removed. Configure logging with chatLoggerSystem from stream-chat |
device |
removed. Call client.createDevice() after connecting |
userData is typed ClientUser, the type client.connectUser() takes. Your own user fields go under custom. A UserResponse no longer fits there, because it requires fields the server sets, such as created_at.
Chat writes persistUserOnConnectionFailure from its enableOfflineSupport prop when it mounts, and overwrites a value you set. To keep it true without offline support, set it again after Chat mounts. Chat no longer turns off the client's own reconnect recovery, as v9 did, and no longer sets client.preventThreadCleanup. See Connection status.
Configuration explains the tree, which settings can change after the client is created, and setup functions.
Data shapes
stream-chat 10 types its data from the API's specification. Three changes reach almost every app.
Custom fields are under custom
Your own fields on a message, a channel, a user, an attachment or another object are no longer spread at the top level. They're in the object's custom field:
| v9 | v10 |
|---|---|
message.priority |
message.custom?.priority |
message.ai_generated |
message.custom?.ai_generated |
channel.data?.name |
channel.data?.custom?.name |
channel.data?.image |
channel.data?.custom?.image |
attachment.mime_type, file_size |
attachment.custom?.mime_type, attachment.custom?.file_size |
Channel.sendMessage({ text, priority }) |
Channel.sendMessage({ message: { text, custom: { priority } } }) |
client.channel(type, id, { name }) |
client.channel(type, id, { custom: { name } }) |
customDataManager.setMessageData() sends its fields under custom too.
In filters, custom fields are "custom.<field>" keys, and each key takes one operator. To type your fields, extend the Custom*Data interfaces of stream-chat, as Data conventions shows.
message.moderation_details is replaced by message.moderation. isBlockedMessage and isBouncedMessage read only message.moderation, so if your backend still sends the older moderation payload, they return false.
Timestamps are nanosecond numbers
Every date the server sends, such as created_at, is a TimestampNS, a number that holds Unix time in nanoseconds. In v9 messages carried Date objects and other data carried ISO strings.
- Compare and sort timestamps as numbers.
- Convert where a timestamp meets a
Date, a date library orDate.now(), withconvertTimestampToDate(),nsToDate()ornsToMs()fromstream-chat. To create one, usenowNs(),msToNs(),dateToNs()orasTimestampNS(). new Date(message.created_at)is marked deprecated, and its result has none of the methods of aDate, so code that uses it as a date, such asnew Date(message.created_at).getTime(), doesn't compile.dayjs(message.created_at)compiles, becauseTimestampNSis a number, but reads nanoseconds as milliseconds. The SDK'sgetDateStringturns the result intonull, so a missed conversion shows as a blank timestamp.
Dates you send in a request, such as remind_at, are still Date objects. The components that render a date, such as MessageFooter, still take a Date or a string.
Sorts are arrays
Every sort is an array of { field, direction } objects, typed SortParamRequest[]. The object form and the per-endpoint sort types, such as ChannelSort, are removed:
import type { SortParamRequest } from "stream-chat";
// v9: { pinned_at: -1, last_message_at: -1 }
export const sort: SortParamRequest[] = [
{ direction: -1, field: "pinned_at" },
{ direction: -1, field: "last_message_at" },
];The first field sorts first. ChannelListEventListenerOptions.sort and the sort of UseFetchReactionParams take the same type.
Other client changes
These come up less often. The complete list has each one in detail.
client.queryChannels()is a plain request that returns the response and stores no channels. The v9 behavior isclient.queryChannelsAndHydrate({ filter_conditions, sort, ...options }), which returnsChannel[].- Methods take request objects, with path parameters first, such as
Channel.sendMessage({ message }),client.search({ payload }),client.queryUsers({ payload }),Channel.queryMembers({ payload }), andChannel.sendEvent({ event: { type, custom } }). Channel.update({ name })isChannel.updatePartial({ set: { name } }), orChannel.update({ data: { custom: { name } } }). Members are objects, as inmembers: [{ user_id }].client.addDevice(),client.getDevices()andclient.removeDevice()areclient.createDevice({ id, push_provider, push_provider_name }),client.listDevices()andclient.deleteDevice({ id }).client.setLocalDevice()and thedeviceoption are removed.- Moderation calls moved to
client.moderationand take request objects.client.muteUser(id)isclient.moderation.mute({ target_ids: [id] }),client.banUser(id)isclient.moderation.ban({ target_user_id: id }),client.flagMessage(id)isclient.moderation.flagMessage({ message_id: id }), andclient.flagUser(id)isclient.moderation.flagUser({ user_id: id }). Channel.addMembers(members)isChannel.addMembers({ members }).client.pinMessage(message, expiration)isclient.pinMessage(message, { pin_expires, pinned_at }): the first argument is anything with anid, and the expiration moves into an options object.client.unpinMessage(message)is unchanged.client.userIDandclient.clientIDare deprecated. Useclient.userIdandclient.clientId.- The paginators in
stream-chat10, such aschannel.messagePaginatorandChannelPaginator, have nonext(),prev(),nextDebounced(),prevDebounced(),hasNextorhasPrev. Usepaginator.toTail(),paginator.toHead(),paginator.toTailDebounced(),paginator.toHeadDebounced(),hasMoreTailandhasMoreHead. A paginator's state has nohasNextandhasPreveither, so a selector reading them getsundefined. SelecthasMoreTailandhasMoreHead. A cursor'snextandprevaretailwardandheadward. Head and tail explains both ends. client.reminders.upsertReminder({ message_id }, { remind_at })takes aDate.Channel.stopLiveLocationSharing({ message_id })takes the message id. CallLiveLocationManager.dispose()on a live location manager you created when you're done with it.client.uploadFile()andclient.uploadImage()take{ file: { uri, name, type } }, and replaceChannel.sendFile()andChannel.sendImage()too.Channel.lastMessage()is removed. See The channel's latest message.client.activeChannels,client.getChannelById()andclient.getChannelByMembers()are removed. Useclient.channelManager.get(cid),client.channelManager.values()andclient.channel(). Channels stay in the client untilclient.channelManager.releaseUnusedChannels()releases the ones nothing uses.client.hydrateActiveChannels()isclient.hydrateChannels().Channel.getConfig()is thechannel.serverConfiggetter, andclient.configs[cid]isclient.channelServerConfigs[cid]. What the client enforces ischannel.config, the server's settings combined with yours.client.setMessageComposerSetupFunction(fn)is deprecated. Useclient.config.setSetupFunction("messageComposer", fn).Eventis a union of event types. Fields such asevent.meexist only on the event types that carry them, so narrow withEventPayload<"...">, or letclient.on("health.check", ...)narrow it.- Many types were renamed, among them
MutetoUserMuteResponse,ReminderResponsetoReminderResponseData,SharedLocationResponsetoSharedLocationResponseData,MessageResponseBasetoMessageResponse,MessageRequesttoSendMessageRequest, andDeliveredMessageConfirmationtoDeliveredMessagePayload.DraftFilters,DraftSortandPagerare replaced byQueryDraftsRequest. Search results areSearchResultMessage, with an optionalmessage. Some names stayed with a different meaning, such asUser,Reaction,PollResponseandChannelConfig, so check what an annotation resolves to. - API errors are
StreamAPIError, which replacesErrorFromResponse. - Logging goes through
chatLoggerSystemfromstream-chat, and is on by default, to the console atinfolevel. Turn it down withchatLoggerSystem.configureLoggers({ default: { level: "warn" } }).
Connection status
In v9, "online" meant that the WebSocket was up. stream-chat 10 reports two things separately, whether the device has a network, in client.networkConnection, and whether the socket is up, in client.wsConnection. The v10 behavior is on Connection.
useChatContext() no longer returns isOnline or connectionRecovering. Read the status with these hooks:
| Hook | What it reports |
|---|---|
useNetworkConnectionState() |
The device's network. Its isOnline is undefined until the network is first reported, so test it with === false |
useWSConnectionState() |
The socket's raw state, such as isHealthy and lastHealthyAt |
useSettledWSConnectionHealth() |
Whether the socket counts as healthy for UI. A drop counts once it lasts offlineNotificationDisplayDelayMs, 5 seconds by default |
In v9:
const { isOnline } = useChatContext();
if (!isOnline) return <OfflineBanner />;In v10:
import { Text } from "react-native";
import {
useNetworkConnectionState,
useSettledWSConnectionHealth,
} from "stream-chat-react-native";
export const ConnectionBanner = () => {
const isDeviceOffline = useNetworkConnectionState()?.isOnline === false;
const isSocketHealthy = useSettledWSConnectionHealth();
if (isDeviceOffline) return <Text>Waiting for network...</Text>;
if (!isSocketHealthy) return <Text>Reconnecting...</Text>;
return null;
};Change the delay with client.config.set({ client: { wsConnection: { offlineNotificationDisplayDelayMs: 3000 } } }).
Other changes around the connection:
useIsOnline(client)is renameduseConnectionLifecycle(client), and returns nothing. It installs the network reporter and closes the socket while the app is in the background.Chatalready calls it.ChatregistersnetInfoStatusReporter, built on@react-native-community/netinfo, when it mounts, unless you registered astatusReporteron the client yourself. A client you build yourself can take the reporter at construction, withnew StreamChat(apiKey, { client: { networkConnection: { statusReporter: netInfoStatusReporter } } }).- The
connection.changedevent is no longer sent. A listener for it compiles and never runs. Subscribe toclient.wsConnection.stateorclient.networkConnection.stateinstead. NetworkDownIndicatorshows "Waiting for network..." when the device is offline, and "Reconnecting..." when the socket has been down longer than the delay. It no longer shows an error when messages fail to load.ChannelListHeaderNetworkDownIndicatorandNotificationAnnouncerfollow the same two signals.MessageComposer's andOutputButtons'isOnlineprop is aboolean, the settled socket health, instead ofboolean | null.
Recovery after a reconnect
The client recovers after a reconnect by itself. It reloads each registered channel list, every active channel and every active thread, and then sends connection.recovered. A channel list reloads every page it has loaded, up to 150 channels, and drops the channels no page returned, such as ones deleted or hidden while the app was offline. This runs on every reconnect, including the one after the app returns from the background, which v9 missed. Channel, Thread and ChannelList no longer resync on their own. Delete any listener of yours that re-queries a list or re-watches a channel after a reconnect, because it now doubles the work.
recoverStateOnReconnectis removed. To turn recovery off, setclient.config.set({ client: { connectionRecovery: { enabled: false } } }). v9'sChatsetclient.recoverStateOnReconnect = falsefor you, and v10's doesn't.client.recoverState()isclient.connectionRecovery.recover().- A listener for
connection.recoverednow also runs after the app returns from the background. If it's expensive, check it. - Recovery reloads only active channels and threads.
ChannelandThreadactivate theirs while they're mounted. A screen of your own that shows a channel or a thread without them must callChannel.activate()orThread.activate(), and call the function it returns when the screen goes away. There is noChannel.deactivate()orThread.deactivate().
A request that watches a channel or asks for presence, such as Channel.watch(), queryChannels and queryUsers with presence, waits for the socket's connection id. v9 sent it without one, and the server registered nothing. With no socket open or opening, it throws, so connect the user first. See Troubleshooting.
Channels
Channel props that moved
Props that customized requests and the message list moved to client.config, or to the component that uses them:
v9 prop on Channel |
v10 |
|---|---|
doSendMessageRequest, preSendMessageRequest |
client.config.set({ channel: { requestHandlers: { sendMessageRequest, retrySendMessageRequest } } }) |
doUpdateMessageRequest |
client.config.set({ channel: { requestHandlers: { updateMessageRequest } } }) |
doMarkReadRequest |
client.config.set({ channel: { requestHandlers: { markReadRequest } } }) |
doFileUploadRequest |
client.config.set({ messageComposer: { attachments: { doUploadRequest, customCdn: true } } }) |
stateUpdateThrottleInterval, newMessageStateUpdateThrottleInterval |
client.config.set({ channel: { messagePaginator: { stateThrottleMs } } }) |
maximumMessageLimit |
client.config.set({ channel: { messagePaginator: { maxLoadedItems } } }) |
allowSendBeforeAttachmentsUpload |
client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled } } }). See Composer |
enableOfflineSupport |
removed. Set it on Chat |
isMessageAIGenerated |
removed. Set it on Chat, which always overrode it |
messageId |
removed. See Opening a channel at a message |
thread, threadList, allowThreadMessagesInChannel, shouldSyncChannel |
removed. Render Thread next to Channel. See Threads |
messages, loadingMore, loadingMoreRecent, threadMessages, setThreadMessages |
removed. The lists read the message paginators |
isOnline |
removed. It was never read |
The request handlers change shape as well as place. They take one params object instead of positional arguments, plus the default request as a second argument, and resolve to { message }. They're registered once per client, not per mounted Channel, so to treat channels differently, branch inside the handler on params.localMessage.cid. Register them before the client reaches Chat, not from a component's effect.
In v9:
<Channel
channel={channel}
doSendMessageRequest={(channelId, message, options) =>
mySend(channelId, message, options)
}
doUpdateMessageRequest={(channelId, message, options) =>
myUpdate(channelId, message, options)
}
/>In v10:
import { StreamChat } from "stream-chat";
import type {
DefaultOperationRequest,
OperationParams,
OperationRequestFn,
} from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
// Generic over "send" and "retry", so the same function fits both handlers.
const sendMessageRequest = async <K extends "send" | "retry">(
params: OperationParams<K>,
defaultRequest: DefaultOperationRequest<K>,
) => {
// Inspect or change the message here, then send it the default way.
console.log("Sending to", params.localMessage.cid);
return defaultRequest(params);
};
const updateMessageRequest: OperationRequestFn<"update"> = async (
params,
defaultRequest,
) => defaultRequest(params);
client.config.set({
channel: {
requestHandlers: {
retrySendMessageRequest: sendMessageRequest,
sendMessageRequest,
updateMessageRequest,
},
},
});sendMessageRequestruns for the first send, and for a retry when noretrySendMessageRequestis registered. A registeredretrySendMessageRequesttakes over retries.sendMessageRequestreplacespreSendMessageRequesttoo, but runs later. It runs after the message appears in the list and after its attachments finish uploading. If it throws, the message is marked failed, wherepreSendMessageRequestleft it pending.- A handler that resolves without a
messagesends nothing more, as in v9. Themessage.newevent updates the message. - Thread replies go through the same
channelhandlers. Thethreadkey takes onlymarkReadRequest. customCdn: truesays your function sends files to storage Stream doesn't host, so the channel type'suploadsflag and theupload-filecapability stop gating the composer's uploads. Leave it out if your function uploads to Stream. The attach button still shows only to users withupload-file.
What Channel does on mount
Channel still watches its channel when it mounts, loads the first page of messages, and marks the channel read. Several details changed:
- The first page has 25 messages, as in v9, where it was the server's default.
stream-chat10's own default is 100, soChannelsets 25 unless you register a page size. To change it, register a page size withclient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }). Settingchannel.messagePaginator.pageSizeafter mount doesn't last. Channelno longer listens to the channel's events, resyncs after a reconnect, or renders nothing when the channel is deleted. See A deleted channel keeps rendering.- A failed
Channel.watch()no longer shows the full-screen error.ChannelrendersLoadingErrorIndicatorwhen the channel's message paginator has an error and no messages to show. initializeOnMount={false}skipsChannel.watch(), but still sends a query that doesn't watch when the paginator has no messages. To send no request at all, load the paginator before you mountChannel.- A channel created from members only, without an id, that your code has already marked
initialized, for example to show a conversation before it exists, isn't queried on mount. Its first message creates it. A members-only channel that isn't markedinitializedis watched on mount, which creates it. - When
channel.supersededByis set,Channelrenders that channel instead of the one you passed. InsideChannel, read the channel fromuseChannelContext()oruseChannel(), not from your own prop. client.options.isLocalUnreadCountEnabledis no longer read. Useclient.config.set({ channel: { readEvents: { localUnreadCountEnabled: true } } }).- Sending a message no longer removes
type: "error"messages from the list. Remove one withuseMessageOperations().removeMessage(message).
watch() resolves with the channel
Channel.watch() loads and watches a channel only when it needs to. On a channel that's already watched and loaded, such as one from a channel list, it sends no request, unless you pass options the current watch wasn't made with, such as presence: true. A call made while the same watch is in flight joins it instead of sending another. It resolves with the Channel to use from then on: this one, or the one that superseded it. v9's Channel.watch() sent a query on every call and resolved with the server response.
- Code that read the response, such as
const { messages } = await channel.watch(), reads the channel instead: its messages fromchannel.messagePaginator, and its members and read state fromchannel.state. See Reading channel state. - Calling
Channel.watch()again no longer refreshes the channel. CallChannel.reload(). To load other messages, useMessagePaginator.toTail(),MessagePaginator.toHead()orMessagePaginator.jumpToMessage(). - Keep using the channel
Channel.watch()resolves with. A channel created from its members gets its realcidfrom its first watch, and the object you hold may have been superseded. See Superseded channels. - Earlier v10 release candidates had
Channel.ensureWatched(). It's removed, andChannel.watch()does what it did.
Opening a channel at a message
The messageId prop is removed. Jump to the message through the channel's message paginator, from a layout effect, so the jump starts before the list picks its first scroll position. Turn off initialScrollToFirstUnreadMessage while you do, or the two jumps race:
import { useLayoutEffect } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
DEFAULT_HIGHLIGHT_DURATION,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
type Props = { channel: StreamChannel; messageId?: string };
export const ChannelScreen = ({ channel, messageId }: Props) => {
useLayoutEffect(() => {
if (!messageId) return;
channel.messagePaginator.jumpToMessage(messageId, {
focusReason: "jump-to-message",
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
}, [channel, messageId]);
return (
<Channel channel={channel} initialScrollToFirstUnreadMessage={!messageId}>
<MessageList />
<MessageComposer />
</Channel>
);
};A deleted channel keeps rendering
v9's Channel rendered nothing once its channel was deleted. v10's keeps rendering it, as the React SDK does, and what to show is up to your app. A channel object is gone for good when the channel is deleted, when the user is removed from it, or when the client disconnects. Its pendingDisposal state says so:
import type { PropsWithChildren } from "react";
import type {
ChannelLifecycleState,
Channel as StreamChannel,
} from "stream-chat";
import { Channel, useStateStore } from "stream-chat-react-native";
const pendingDisposalSelector = (state: ChannelLifecycleState) => ({
pendingDisposal: state.pendingDisposal,
});
export const ChannelIfPresent = ({
channel,
children,
}: PropsWithChildren<{ channel: StreamChannel }>) => {
const { pendingDisposal } = useStateStore(
channel.state,
pendingDisposalSelector,
);
// Or render your own "this channel is gone" view.
if (pendingDisposal) return null;
return <Channel channel={channel}>{children}</Channel>;
};channel.disconnected is renamed channel.pendingDisposal, with no alias. A disposed channel object never comes back. To open the same conversation again, get a new object with client.channel(type, id).
Reading channel state
In stream-chat 9, channel.state was a plain object, and the SDK copied parts of it into contexts. In stream-chat 10, channel.state is a store, and the SDK no longer copies it. Read it with useStateStore and a selector declared outside the component. Changing a field of channel.state or channel.data in place notifies no one. To change channel.data, assign a new object. The v10 behavior is on Reading state in components.
TypingContext and PaginatedMessageListContext are removed, with their providers, their use...Context hooks, their value types, and useCreateTypingContext and useCreatePaginatedMessageListContext. So is DebugContext, which fed the Flipper plugin, with nothing in its place.
In v9:
const { typing } = useTypingContext();
const { messages, hasMore, loadMore } = usePaginatedMessageListContext();
const { members, watcherCount } = useChannelContext();In v10:
import { Button, Text, View } from "react-native";
import type { ChannelStateData, MessagePaginatorState } from "stream-chat";
import {
useChannel,
useMessagePaginator,
useStateStore,
} from "stream-chat-react-native";
const channelSelector = (state: ChannelStateData) => ({
memberCount: state.memberCount,
typing: state.typing,
watcherCount: state.watcherCount,
});
const messagesSelector = (state: MessagePaginatorState) => ({
hasMore: state.hasMoreTail,
messages: state.items,
});
export const ChannelSummary = () => {
const channel = useChannel();
const paginator = useMessagePaginator();
const { memberCount, typing, watcherCount } = useStateStore(
channel.state,
channelSelector,
);
const { hasMore, messages } = useStateStore(
paginator.state,
messagesSelector,
);
return (
<View>
<Text>
{messages?.length ?? 0} messages, {memberCount} members, {watcherCount}{" "}
online, {Object.keys(typing).length} typing
</Text>
{hasMore ? (
<Button onPress={() => paginator.toTail()} title="Load older" />
) : null}
</View>
);
};useChannel() returns the channel of the surrounding Channel, or the thread's channel inside a Thread. useMessagePaginator() returns the thread's message paginator inside a Thread, and the channel's elsewhere. In a message list, the tail is where older messages load, and the head is the newest message.
Where the removed context fields went:
| v9 | v10 |
|---|---|
typing from useTypingContext() |
typing in channel.state |
messages from usePaginatedMessageListContext() |
items in useMessagePaginator().state |
hasMore, hasMoreNewer, loadingMore, loadingMoreRecent |
hasMoreTail (older), hasMoreHead (newer) and isLoading in the same state |
setLoadingMore, setLoadingMoreRecent |
removed. The paginator sets isLoading itself |
viewabilityChangedCallback |
viewabilityChangedCallback from useMessageList({ paginator }). It calls MessagePaginator.setPruningSuspended(), which holds off pruning while the user is near the messages it would remove |
loadMore(), loadMoreRecent(), loadLatestMessages() |
paginator.toTail(), paginator.toHead() and paginator.jumpToTheLatestMessage() |
members, read, watchers, watcherCount from useChannelContext() |
the same keys in channel.state |
members from useMessageContext() |
members in channel.state |
markRead from useChannelContext() |
useMarkRead(channel), or Channel.markReadViaReporter(). See Read state |
loadChannelAroundMessage({ messageId }) |
paginator.jumpToMessage(messageId, { focusReason: "jump-to-message" }) |
loadChannelAtFirstUnreadMessage() |
paginator.jumpToTheFirstUnreadMessage() |
reloadChannel() |
paginator.jumpToTheLatestMessage() |
loading |
isLoading in paginator.state |
error |
lastQueryError in paginator.state |
targetedMessage, setTargetedMessage, highlightedMessageId, and useTargetedMessage |
paginator.jumpToMessage(id, ...) to set, useIsTargetedMessage(id) to read |
channelUnreadStateStore, setChannelUnreadState |
paginator.unreadStateSnapshot |
isChannelActive |
!useThreadContext().thread |
maximumMessageLimit |
maxLoadedItems in the paginator's configuration |
uploadAbortControllerRef |
removed. Remove an attachment from the composer to cancel its upload |
scrollToFirstUnreadThreshold |
removed. It was always 0 |
useSelectedChannelState({ channel, selector }) |
useStateStore(channel.state, selector) |
useChannelContext().error was the error of the last failed query. Read lastQueryError from the paginator of the list you render, which useMessagePaginator() returns. NetworkDownIndicator no longer shows it, so render your own banner from it if you want one.
Most keys of channel.state keep their v9 names. watcher_count is watcherCount, and member_count is memberCount. The state also holds values that lived elsewhere, such as data, membership, muteStatus, ownCapabilities, initialized, pendingDisposal, watchStatus and aiState. The getters, such as channel.state.members, still work, but they return the value at the moment you read them, and don't tell you when it changes, so they re-render nothing. What channels and threads hold lists the fields.
The messages moved off channel.state too:
| v9 | v10 |
|---|---|
channel.state.messages |
items in channel.messagePaginator.state |
channel.state.findMessage(id) |
channel.messagePaginator.getItem(id) |
channel.state.loadMessageIntoState(id) |
channel.messagePaginator.jumpToMessage(id) |
channel.state.isUpToDate |
channel.messagePaginator.isActiveIntervalAtHead |
channel.state.pinnedMessages |
channel.pinnedMessagesPaginator |
channel.state.threads[id] |
client.threads.get(id)?.messagePaginator |
channel.state.mutedUsers |
useMutedUsers() |
Channel.query({ messages: { id_lt } }) |
channel.messagePaginator.toTail() |
Channel.query() returns a page and no longer puts it in channel.messagePaginator. Its second argument, the message set to add the messages to, is removed, and the second argument is now the request options, such as an abort signal. Open a channel with Channel.watch(), and load more messages with MessagePaginator.toTail(), MessagePaginator.toHead() or MessagePaginator.jumpToMessage().
The channel's latest message
Channel.lastMessage() and channel.state.latestMessages are removed. The channel's newest message is in its message paginator's aggregateState, whichever interval of messages is on screen. channel.messagePaginator.latestMessage reads it once, and is null when there is none.
import { Text } from "react-native";
import type { MessagePaginatorAggregateState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react-native";
const latestMessageSelector = (state: MessagePaginatorAggregateState) => ({
latestMessage: state.latestMessage,
});
export const LatestMessageText = () => {
const channel = useChannel();
const { latestMessage } = useStateStore(
channel.messagePaginator.aggregateState,
latestMessageSelector,
);
return <Text>{latestMessage?.text}</Text>;
};ChatContext holds the client and two functions
useChatContext() returns client, getAppSettings and isMessageAIGenerated. The other fields are removed:
v9 useChatContext() |
v10 |
|---|---|
appSettings |
await getAppSettings(). The first call fetches the settings, and later calls reuse them |
mutedUsers |
useMutedUsers() |
channel, setActiveChannel |
keep the open channel in your navigation state, and pass it to Channel |
enableOfflineSupport |
client.offlineDb !== undefined |
isOnline, connectionRecovering |
the connection hooks. See Connection status |
useAppSettings and useClientMutedUsers are removed. Use getAppSettings() and useMutedUsers(). useMutedUsers() takes no argument, and returns UserMuteResponse[]. useMutedChannels() takes no argument either. For one channel's mute state, use useIsChannelMuted(channel), which returns a ChannelMuteStatus with nanosecond timestamps.
Channel lists
ChannelList no longer creates its query. Its data comes from a ChannelPaginator that your app creates and registers on client.channelManager, and the list renders what that paginator holds. The v10 behavior is on Channel list paginators.
In v9:
<ChannelList
filters={{ members: { $in: [userId] }, type: "messaging" }}
onSelect={onSelect}
options={{ presence: true, state: true, watch: true }}
sort={{ last_message_at: -1 }}
/>In v10:
import { useEffect, useMemo } from "react";
import { ChannelPaginator } from "stream-chat";
import type { Channel, ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react-native";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
type Props = { onSelect: (channel: Channel) => void; userId: string };
export const MyChannelList = ({ onSelect, userId }: Props) => {
const { client } = useChatContext();
const paginator = useMemo(() => {
const filters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
return new ChannelPaginator({
client,
filters,
id: "channels:default",
paginatorOptions: { pageSize: 20 },
requestOptions: { presence: true, state: true, watch: true },
sort,
});
}, [client, userId]);
useEffect(() => {
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
paginator.dispose();
};
}, [client, paginator]);
return <ChannelList onSelect={onSelect} paginator={paginator} />;
};What to know about the paginator:
- Register it.
client.channelManagerroutes events into the lists it holds. A paginator that's only passed as a prop shows its first page, and then never adds, moves or removes a channel. - The list never removes or disposes the paginator. Your app does, when nothing shows it any more, for example when the user logs out.
client.disconnectUser()empties the registered paginators but keeps them registered. - The list loads a first page only when the paginator has none, which happens on the first mount, after
client.disconnectUser()empties it, and when you pass a different paginator. A list that mounts again with the same paginator shows its channels without a query. To keep the channels while the list's screen is unmounted, create the paginator above the navigator, with the user. - Give each paginator a unique
id. The manager keys its lists by it. - The query options split in two. Options of the request, such as
presence,state,watchandmessage_limit, go inrequestOptions. The page size goes inpaginatorOptions.pageSize.offsetandlimitaren't accepted, because the paginator owns them.
Where the removed props went:
v9 prop on ChannelList |
v10 |
|---|---|
filters, sort, options |
the filters, sort and requestOptions of the ChannelPaginator. options.limit is paginatorOptions.pageSize |
lockChannelOrder |
paginatorOptions.lockItemOrder |
queryChannelsOverride |
paginatorOptions.doRequest. See below |
onNewMessage, onNewMessageNotification, onAddedToChannel, onRemovedFromChannel, onChannelUpdated, onChannelDeleted, onChannelHidden, onChannelVisible, onChannelTruncated, onChannelMemberUpdated |
the paginator's filters and sort decide membership and order. For anything else, client.channelManager.addEventHandler(...) or client.on(...) |
A prop change no longer queries again. To change the query, set staticFilters or sort on the paginator and call paginator.reload(), or pass a new paginator with a new id. useChannelUpdated is removed, because the channel manager handles channel.updated.
queryChannelsOverride returned Channel[]. Its replacement, doRequest, receives the request the paginator would send and resolves to { items }. Call client.queryChannelsAndHydrate() inside it, so the client stores the channels:
import { ChannelPaginator } from "stream-chat";
import type { StreamChat } from "stream-chat";
export const createChannelPaginator = (client: StreamChat) =>
new ChannelPaginator({
client,
id: "channels:custom-query",
paginatorOptions: {
doRequest: async ({ stateOptions, ...request }) => ({
items: await client.queryChannelsAndHydrate(request, stateOptions),
}),
},
});The list follows its filters and sort
The list is a projection of its paginator's filters and sort, which every query and every event re-applies:
- A channel moves by its sort key when an event arrives. Nothing moves a channel to the top by default, so a pinned-first sort keeps pinned channels first. To move a channel to the top for a while, call
paginator.boost(cid). - A channel that stops matching the filters after an event leaves the list.
- An empty
sort, and a paginator created without one, mean the backend's default order, by the latest message. - After a reconnect, the client reloads every loaded page of each registered list, up to 150 channels, and removes the channels no page returned.
ChannelListhas no reconnect listener of its own. Pulling down on the list still reloads only the first page. - The channel preview's typing indicator ignores typing inside threads.
ChannelsContext holds the paginator
useChannelsContext() no longer carries the list's state. It holds the list's configuration, the paginator, and the loadNextPage, refreshList and reloadList functions:
v9 useChannelsContext() |
v10 |
|---|---|
channels, hasNextPage, error |
items, hasMoreTail and lastQueryError in paginator.state |
loadingChannels, channelListInitialized |
items === undefined in paginator.state |
loadingNextPage, refreshing |
props of ChannelListView |
forceUpdate |
removed. It was always 0 |
loadNextPage(queryType?, retryCount?) |
loadNextPage(), which does nothing until the first page has loaded |
import { Text } from "react-native";
import type { ChannelPaginatorState } from "stream-chat";
import { useChannelsContext, useStateStore } from "stream-chat-react-native";
const channelsSelector = (state: ChannelPaginatorState) => ({
channels: state.items,
});
export const LoadedChannelCount = () => {
const { paginator } = useChannelsContext();
const { channels } = useStateStore(paginator.state, channelsSelector);
return <Text>{channels?.length ?? 0} channels</Text>;
};ChannelListView takes the list's state as required props: channels, channelListInitialized, hasNextPage, loadingChannels, loadingNextPage and refreshing. Its loadNextPage is () => Promise<void>, and its forceUpdate is removed. ChannelPreview lost forceUpdate too, and useChannelPreviewData(channel, client) lost its third argument. Its lastMessage is a LocalMessage or undefined.
A channel row re-renders only when its own channel changes. A custom ChannelPreview that reads channel.data or channel.state without subscribing no longer picks up changes through other rows' updates. Subscribe with useStateStore, or with hooks such as useChannelName(channel).
Threads
Thread is a sibling of Channel
A thread is a Thread object from stream-chat. Thread takes it as a required thread prop, renders next to Channel instead of inside it, and renders the children you pass. Opening and closing a thread is up to your app, typically a screen of its own. The v10 behavior is on Thread UI.
In v9:
<Channel channel={channel} thread={thread} threadList>
<Thread />
</Channel>In v10:
import type { Thread as StreamThread } from "stream-chat";
import { MessageComposer, MessageList, Thread } from "stream-chat-react-native";
type Props = { onClose: () => void; thread: StreamThread };
export const ThreadScreen = ({ onClose, thread }: Props) => (
<Thread onThreadDismount={onClose} thread={thread}>
<MessageList />
<MessageComposer />
</Thread>
);MessageList inside a Thread renders the thread's replies, with the parent message above them. Thread takes the same configuration props as Channel, such as keyboardVerticalOffset, messageActions and audioRecordingEnabled, so pass it what you pass Channel. allowThreadMessagesInChannel moved from Channel to Thread.
onThreadSelect on MessageList, MessageFlashList and ThreadList receives the Thread:
- <MessageList onThreadSelect={(parentMessage) => setThread(parentMessage)} />
+ <MessageList onThreadSelect={(thread) => setThread(thread)} />
- <ThreadList onThreadSelect={({ thread, threadInstance }, channel) => open(threadInstance, channel)} />
+ <ThreadList onThreadSelect={(thread) => open(thread, thread.channel)} />Where you have only the parent message, for example from a push notification or a deep link, get the thread with client.threads.ensure({ channel, parentMessage }). It returns the Thread the client holds for that message, or creates one. client.threads.get(id) returns a thread the client holds. The ThreadType type is removed.
Where the removed props went:
v9 prop on Thread |
v10 |
|---|---|
thread, the parent message |
thread, the Thread object, now required |
threadInstance |
thread |
additionalMessageListProps, additionalMessageFlashListProps, shouldUseFlashList |
render the list you want as a child, with its props |
additionalMessageComposerProps, autoFocus, disabled |
render MessageComposer as a child, with its props |
closeThreadOnDismount |
onThreadDismount |
parentMessagePreventPress |
a prop of ThreadFooterComponent, true by default |
client, closeThread, loadMoreThread, reloadThread |
removed |
The ThreadMessageComposer and MessageList slots are removed from WithComponents. Render the composer and the list you want as children of Thread. They're the only slots v10 removed.
ThreadContext holds the Thread
useThreadContext() returns { thread, allowThreadMessagesInChannel }. thread used to be the parent message. It's now the Thread, or undefined outside a Thread. useThreadContext() no longer throws outside a Thread, and only Thread provides it, so !useThreadContext().thread tells a component that it isn't rendering a thread.
thread.id is the parent message's id, as before. Message fields such as thread.text are in thread.state, as parentMessage. The same applies to the thread of useMessageComposerContext().
v9 useThreadContext() |
v10 |
|---|---|
thread, the parent message |
parentMessage in thread.state |
threadMessages |
items in thread.messagePaginator.state |
loadMoreThread(), loadMoreRecentThread() |
thread.messagePaginator.toTail() and thread.messagePaginator.toHead() |
threadHasMore, threadLoadingMore, threadLoadingMoreRecent |
hasMoreTail and isLoading in thread.messagePaginator.state |
reloadThread() |
Thread.reload() |
threadInstance |
thread |
openThread, closeThread, setThreadLoadingMore |
removed. Your navigation opens and closes threads |
parentMessagePreventPress |
a prop of ThreadFooterComponent |
onAlsoSentToChannelHeaderPress |
moved to useMessagesContext(). Its payload has thread instead of parentMessage |
threadList, the flag that told a component it rendered a thread, is removed from useChannelContext(), useMessageContext() and the props of Message, MessageContent, MessageReplies, MessageList, MessageFlashList and MessageComposer. Read useThreadContext().thread instead. MessageItemView, SentToChannelHeader and ShowThreadMessageInChannelButton take a thread prop instead. MessageReplies no longer hides itself inside a thread. MessageItemView hides it there, so a custom layout that renders MessageReplies directly checks useThreadContext().thread itself.
ThreadFooterComponent reads the thread from the context, and its only prop is parentMessagePreventPress.
The thread state in stream-chat moved too. thread.state.replies, Thread.loadNextPage() and Thread.loadPrevPage() are thread.messagePaginator. The thread list's client.threads.state and client.threads.loadNextPage() are client.threads.paginator, and threadsById is client.threads.get(id).
What Thread does
Threaddoesn't need aChannelabove it. It usesthread.channel, and watches that channel once if nothing has queried it.- Opening a thread, or pressing the scroll-to-bottom button in one, no longer marks the channel read. The thread marks itself read while it's open, and never marks a thread with no replies.
- When the first page of replies fails to load,
ThreadrendersLoadingErrorIndicator, with a retry, instead of the list and the composer.
Message lists
MessageList and MessageFlashList read their own paginator, the channel's or, inside a Thread, the thread's. The props that Channel used to fill in are removed:
loadMore,loadMoreRecent,hasMore,loadingMore,loadingMoreRecent,threadHasMore,loadMoreThread,loadMoreRecentThread,loadChannelAroundMessage,loading,reloadChannelandthreadInstance.targetedMessage,setTargetedMessage, andhighlightedMessageIdonMessageFlashList.channelUnreadStateStoreandsetChannelUnreadState.maximumMessageLimit,scrollToFirstUnreadThresholdandthreadList.allowSendBeforeAttachmentsUpload, which ispendingUploadsEnablednow.shouldShowUnreadUnderlay, which was never read. Set it onChannel.FlatListandinvertedonMessageFlashList, which were never read.
thread is still a prop, typed Thread | undefined instead of the parent message. Inside a Thread you can leave it out. onThreadSelect receives a Thread. To change how a page is fetched, configure the paginator through client.config. useMessageList() takes { paginator } instead of { threadList }.
The component at the older end of the list, FooterComponent on MessageList and HeaderComponent on MessageFlashList, receives loadingMore as a prop. It used to read it from PaginatedMessageListContext:
import { ActivityIndicator } from "react-native";
import { MessageList } from "stream-chat-react-native";
const OlderMessagesSpinner = ({ loadingMore }: { loadingMore?: boolean }) =>
loadingMore ? <ActivityIndicator /> : null;
export const MyMessageList = () => (
<MessageList FooterComponent={OlderMessagesSpinner} />
);The component at the newer end gets no props, and nothing gives it a loading flag for newer messages. Read isLoading from the paginator there. InlineLoadingMoreIndicator and InlineLoadingMoreRecentIndicator take loadingMore and loadingMoreRecent as props, and render nothing without them.
Jumping and highlighting
useTargetedMessage is removed. Jump with the paginator, which loads the message if it isn't loaded and highlights it for focusSignalTtlMs. Read whether a row is the highlighted one with useIsTargetedMessage(messageId):
import { Button } from "react-native";
import {
DEFAULT_HIGHLIGHT_DURATION,
useMessagePaginator,
} from "stream-chat-react-native";
export const JumpToMessageButton = ({ messageId }: { messageId: string }) => {
const paginator = useMessagePaginator();
return (
<Button
onPress={() =>
paginator.jumpToMessage(messageId, {
focusReason: "jump-to-message",
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
})
}
title="Show message"
/>
);
};Read state and unread counts
The SDK marks a channel read on its own, when the user is at the newest message. Most apps can delete their own Channel.markRead() calls. Where you still need one:
useMarkRead(channel)returns amarkRead()function, the replacement for themarkReadthatuseChannelContext()returned.Channel.markReadViaReporter()marks the channel read outside React. It also calls yourmarkReadRequesthandler.Channel.markRead()is the direct request instream-chat10. It skips your handler, and throws when read events are turned off.
The unread state moved to channel.messagePaginator.unreadStateSnapshot, a store of UnreadSnapshotState. The ChannelUnreadState type is removed. ScrollToBottomButton lost unreadCount, and UnreadMessagesNotification lost channelUnreadStateStore and unreadCount. Both count unread messages themselves. UnreadMessagesNotification marks the channel read through a new markRead prop, so a custom component that wraps it must pass markRead on, or dismissing it stops marking the channel read.
TypingIndicatorContainer takes threadId instead of thread. See Read state, unread and receipts.
Pruning the list
maximumMessageLimit is now maxLoadedItems in the message paginator's configuration. It caps the number of loaded messages, as before:
import { StreamChat } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
// Every channel's message list. Use `thread` for thread replies, or `messagePaginator` for both.
client.config.set({ channel: { messagePaginator: { maxLoadedItems: 200 } } });Messages
Message operations
sendReaction, deleteReaction, deleteMessage, removeMessage, retrySendMessage and updateMessage are removed from useMessagesContext(). useMessageOperations() returns them, with the same names:
import { Button } from "react-native";
import type { LocalMessage } from "stream-chat";
import { useMessageOperations } from "stream-chat-react-native";
export const MessageTools = ({ message }: { message: LocalMessage }) => {
const { deleteMessage, sendReaction } = useMessageOperations();
return (
<>
<Button onPress={() => sendReaction("like", message.id)} title="Like" />
<Button
onPress={() => deleteMessage(message, { hard: true })}
title="Delete"
/>
</>
);
};deleteMessage's options are{ hard, delete_for_me }instead of{ hardDelete, deleteForMe }.updateMessagetakes one argument, the message.- A custom
Messagecomponent still receives the six functions as props. - Outside React,
channel.messageOperationsandthread.messageOperationsrun the same optimistic updates.stream-chat10 has no*WithLocalUpdatemethods, andChannel.sendMessage()andChannel.sendReaction()are plain requests that update nothing locally.
The behavior changed with the move. See Message operations.
- Reactions are always optimistic. v9 applied them optimistically only with offline support. A reaction that fails for good is rolled back. Remove any optimistic reaction logic of your own, or it applies twice.
- Edits and deletes show at once. A delete that fails for good is reverted. A hard delete removes the message from the list.
- Deleting a message whose edit failed deletes it on the server. v9 removed every failed message locally only.
- A failed send rejects the promise, where v9 swallowed the error. Code of yours that sends must handle the rejection. The built-in composer shows a notification.
Message actions
useMessageActions() and useMessageActionHandlers() take no arguments. They read the contexts of the message they're called in, so call them inside a message, such as in a custom Message or MessageActionList. MessageActionsHookProps is removed.
- const actions = useMessageActions({ channel, client, message, t /* ... */ });
- const handlers = useMessageActionHandlers({ channel, client, message /* ... */ });
+ const actions = useMessageActions();
+ const handlers = useMessageActionHandlers();useMessageActionHandlers() returns the message's MessageActionHandlerCallbacks, which useMessageContext() also holds as messageActionHandlers.
Message no longer declares handleBan, handleBlockUser, handleCopy, handleDelete, handleDeleteForMe, handleEdit, handleFlag, handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, handleRetry, messageActions and supportedReactions. Passed to a Message, they never reached the action menu. Set them on Channel or Thread. To change the actions of one message, give messageActions a function. It now runs when a message's menu opens, not on every render of every row. The updateMessage it receives takes one argument. See Actions.
MessageActionList lost dismissOverlay, which it never read. A custom list that needs it reads useMessageContext().dismissOverlay.
Read and delivery receipts
readBy and deliveredToCount are removed from useMessageContext() and from the props of Message and MessageStatus. MessageStatus takes the booleans read and delivered, and works them out itself when you pass neither:
import { Text } from "react-native";
import {
useIsMessageDeliveredToOthers,
useIsMessageReadByOthers,
useMessageContext,
} from "stream-chat-react-native";
export const ReceiptText = () => {
const { message } = useMessageContext();
const isRead = useIsMessageReadByOthers({ message });
const isDelivered = useIsMessageDeliveredToOthers({ message });
return <Text>{isRead ? "Read" : isDelivered ? "Delivered" : "Sent"}</Text>;
};For the counts, use the length of useMessageReadData({ message }) or useMessageDeliveredData({ message }). All four hooks need a channel above them.
Other message changes
-
isMessageAIGeneratedis a prop ofChatonly, anduseChatContext()holds it. It's removed fromChannel,useMessagesContext(),useMessageContext(), and the props ofMessageandMessageContent. The AI flag ismessage.custom?.ai_generated:- <Channel channel={channel} isMessageAIGenerated={(message) => !!message.ai_generated}> + <Chat client={client} isMessageAIGenerated={(message) => !!message.custom?.ai_generated}> -
Messagelostmembers,openThreadandchatContext, anduseMessageContext()lostmembers.onThreadSelectonMessageand inuseMessageContext()receives aThread.MessageFooterandMessageItemViewlostmembers.MessageFootershows the sender's name on other users' messages whenever the channel'smemberCountisn't exactly two, where v9 counted the loaded members and needed more than two. -
useMessagesContext()losttargetedMessage,initialScrollToFirstUnreadMessageandquotedMessage. Read the quoted message from the composer's state. -
useMessageContext()has a newisVeryLastMessage, which marks the newest message of the list it renders in. -
The
attachmentofGiphy,GiphyImage,URLPreviewandURLPreviewCompactisstream-chat'sAttachment, with your fields undercustom.giphyVersionis typedkeyof Images. -
AIStatesis an object of literal values, so[AIStates.Thinking, AIStates.Generating].includes(aiState)no longer compiles. Compare with===. -
In
ImageGalleryStateStore'sassets,created_atis aDateorundefined,channelIdastring, anduseraUserResponse.
Composer
Sending goes through the message composer
useMessageInputContext().sendMessage() submits the channel's message composer. It sends a new message, or saves the edit while a message is being edited. editMessage is removed from the context. A custom MessageInputProvider value can no longer carry its own sendMessage or editMessage. Register sendMessageRequest and updateMessageRequest handlers, as Channel props that moved shows.
The SDK used to rewrite /mute @Jane Doe and the other moderation commands into user ids. It no longer does, because the server reads the target from the message's mentioned users, and patchMessageTextCommand is removed. To change a message before it's sent, add a composition middleware in a setup function, client.config.setSetupFunction("messageComposer", fn).
A failed send or edit shows as a client.notifications notification of the type messageSendFailed or messageUpdateFailed, from CORE_NOTIFICATION_TYPE.
Pending uploads
allowSendBeforeAttachmentsUpload is now stream-chat's composer setting messageComposer.attachments.pendingUploadsEnabled. It's removed from Channel and useMessageInputContext(). Read it with usePendingUploadsEnabled(). MessageList and MessageFlashList take it as a pendingUploadsEnabled prop. useMessageInputContext() lost doFileUploadRequest, which is messageComposer.attachments.doUploadRequest now.
With enableOfflineSupport, Chat turns it on when nothing else has set it, as v9's prop default did. It does so once, and doesn't turn it off again when offline support goes off. To set it for every channel, register it before the client reaches Chat. To set it for some channels, use a setup function:
import { StreamChat } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
// Every channel.
client.config.set({
messageComposer: { attachments: { pendingUploadsEnabled: false } },
});
// Livestream channels only. One setup function per key: add this to yours if you have one.
client.config.setSetupFunction("messageComposer", ({ composer }) => {
if (composer.channel.type !== "livestream") return;
composer.updateConfig({ attachments: { pendingUploadsEnabled: true } });
});See Sending with pending uploads.
Attachments that are still uploading
v9 wrote a local file's URI into image_url or asset_url while the file uploaded, and kept the upload's id and file in custom.localId and custom.originalFile. In v10 such an attachment has no URL until its upload finishes. It carries localMetadata instead, with id, file, previewUri and uploadState. A custom component that renders attachments uses the helpers:
import { getAttachmentPreviewUrl, isLocalUploadAttachment } from "stream-chat";
import type { Attachment } from "stream-chat";
import { getPlayableVideoUrl } from "stream-chat-react-native";
export const describeAttachment = (attachment: Attachment) => ({
// The upload's id while it uploads.
localId: isLocalUploadAttachment(attachment)
? attachment.localMetadata.id
: undefined,
// The uploaded URL, or the local preview while it uploads. For images, files and audio.
previewUrl: getAttachmentPreviewUrl(
attachment,
attachment.asset_url,
attachment.image_url,
),
// What a video player plays. For videos, the preview is a thumbnail image.
videoUrl: getPlayableVideoUrl(attachment),
});createAttachmentsCompositionMiddleware, createDraftAttachmentsCompositionMiddleware and localAttachmentToAttachment are removed. The composer installs stream-chat's createAttachmentsCompositionMiddleware by default. A draft keeps only the attachments that finished uploading. DefaultAttachmentData is empty, and AudioUpload and LocalAudioAttachmentType lost localId, originalFile, latitude and longitude. In AudioAttachmentType, AudioUpload and LocalAudioAttachmentType, waveform_data, mime_type, file_size and duration moved under custom.
isLocalUrl() checks the URL's scheme, and returns false for undefined. makeImageCompatibleUrl() returns undefined for undefined. Both threw on undefined in v9.
Other composer changes
- The composer loads link previews by default where the channel type enriches URLs. To turn them off, set
client.config.set({ messageComposer: { linkPreviews: { enabled: false } } }). - The poll button follows
messageComposer.config.polls.enabled, which istrueuntil the server's settings arrive. Hide it withclient.config.set({ messageComposer: { polls: { enabled: false } } }). - The input's
maxLengthfollowsmessageComposer.config.text.maxLengthOnSend, so a smaller value you register caps the input too. AutoCompleteInput's andInputView'stis aStreamTFunction.- In
CommandSuggestionItem,args,description,nameandsetare required strings, andcreated_atandupdated_atare nanosecond timestamps. - A composer's
contextTypeis'channel','thread'or'message'.'legacy_thread'is removed, withLocalMessageWithLegacyThreadIdand itslegacyThreadId. A thread's composer is always itsThread'smessageComposer, with thecontextType'thread', so a setup function or middleware that checked for'legacy_thread'checks for'thread'.
Offline storage
The offline database's version, SqliteClient.dbVersion, is 18 in v10, up from 16. The first launch after the upgrade deletes and rebuilds the database. Channels and messages load again from the server. Actions the user queued while offline in v9, such as unsent messages, are lost with it. Date columns that hold server data store nanosecond integers instead of ISO strings. See Offline support.
The rest affects only a custom AbstractOfflineDB, a subclass of OfflineDB, or code that calls OfflineStoreApis or reads the database:
getChannelsForQuerytakes{ userId, options }, with the filters inoptions.filter_conditionsand the sort inoptions.sort. It returns{ channels, predefinedFilter }instead of an array.upsertCidsForQuerytakes apredefinedFilterto store with the query.OfflineDBlosthandleOfflineFailedUpdateMessagePendingTaskandmergeFailedMessageUpdateIntoPendingSendMessage. Move overrides of them tohandleUpdateMessagePendingTask.hardDeleteMessages,upsertMessageBehindChannelGuardandqueriesBehindChannelGuardare new. Its event handlers and upserts takestream-chat10 types.client.offlineDb.syncManager.syncStatusisisSynced.OfflineStoreApisfunctions takeuserIdinstead ofcurrentUserId.deleteReactionsForMessageis removed.getChannelsForFilterSortreturns{ channels, predefinedFilter }, sorts areSortParamRequest[], andgetAppSettingsreturns aGetApplicationResponse.deleteMessagesForChanneltakestruncated_atas a nanosecond timestamp.insertReactionandupdateReactionskip a reaction whose message isn't stored, where v9's write failed.channelExistsanswers correctly. In v9 it was alwaystrue.SqliteClient.loggeris astream-chatSink, called as before.- A timestamp you read from the tables yourself is already in nanoseconds. Brand it with
asTimestampNS(), notmsToNs().
Other changes
These come up less often:
getLastReceivedMessage(messages)is removed. It did whatmessages.find((m) => m.status !== "failed")does.reduceMessagesToString,findInMessagesByIdandfindInMessagesByDateare removed, and replaced bychannel.messagePaginator.getItem(id)andchannel.messagePaginator.jumpToMessage(id).useQueryReminders()returnsReminderResponseData[], with nanosecond timestamps.usePollState,usePollStateStore,usePollAnswersPaginationandusePollOptionVotesPaginationuse the renamed poll types.addComment()resolves to aPollVoteResponse, andendVote()to aPollResponseorundefined. In the poll components,PollOptionisPollOptionResponseData,PollAnswerandPollVotearePollVoteResponseData, andCreatePollOption.erroris aPollComposerValidationError.useMessagePaginator()returns aMessagePaginator, neverundefined.useCreateMessagesContext,useCreateThreadContext,useCreateMessageInputContextanduseCreateMessageContexttake the new, smaller context shapes.LoadingErrorIndicator'serroris anError.error={true}no longer compiles.useInAppNotificationsState, deprecated since v9, still reads a store of its own that no SDK component shows. Hooks left out of this reference maps it to the notification hooks.ProgressControllostwidth, which it never read.defaultTranslatorFunctionreturns the English copy instead of the key.LastMessageTypeis stillLocalMessage | MessageResponse, but both types changed withstream-chat10.- Imports from
stream-chat-react-native-core/src/...orlib/...of modules that were never exported from the package root may fail.ChannelsStateProvider,useChannelsStateContext,useChannelState,ActiveChannelsRefContext,useChannelDataState,ChannelUnreadStateStore,MessagePreviousAndNextMessageStore, the channel list's pin and archive sort helpers,useCreateMessageComposerand theutils/i18n/*modules are deleted. The functions ofutils/i18n/*are exported from the package root. filterTypingUsers, a deep import, takesthreadIdinstead ofthread.- No other
WithComponentsslot or icon was added, removed or renamed, and the theme's shape is the same. The props and contexts that the slots receive did change, as the sections above describe, so check each custom component you register against them.