Upgrade to v15
This page lists what changed between v14 and v15, and what to change in your app. Each change links to the v15 page that explains how the new version works.
v15 needs stream-chat 10, version 10.0.0-rc.20 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 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 both packages:
npm install stream-chat@rc stream-chat-react@rc. - Move client options under
client.useCreateChatClienttakesconfiginstead ofoptions. See Creating the client. - Update how you read data. Your custom fields are under
custom, so a channel's name ischannel.data?.custom?.name. 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. - Watch a channel before you pass it to
Channel.Channelno longer queries. UseChannel.watch(). See Channel no longer queries the channel. - Remove
Window, and passthreadtoThread. Render the thread's header, list and composer as its children. See Window is gone and Thread takes a thread and renders its children. - Replace
useChannelStateContextanduseChannelActionContext. Both contexts are removed. Read the channel's stores withuseChannel()anduseStateStore, and call methods on the channel. See Removed contexts. - Replace
setActiveChannel. Open a channel withuseWorkspaceNavigation().openChannel(channel). See ChatContext has no active channel. - Import
ChatViewfromstream-chat-react/slot-layout, and replaceChatView.Channels,ChatView.ThreadsandChatView.ThreadAdapterwith views and slots. See ChatView is a slot layout. - Move the
do*Requestprops toclient.config. See Channel props that moved. - Rename your translation keys. See Localization.
- Rename
Messageoverrides toMessageUI, anddisableDateSeparatortowithDateSeparator. See Components. - Replace
connection.changedlisteners. See Connection status is two facts now.
What's new
- How the SDK is built: the state layer in
stream-chatand the UI layer instream-chat-react, followed through one sent message. - Configuration:
client.config, the one place to configure the client and the objects it creates. - Channel list paginators and Channel manager and ChannelPaginator: channel lists as objects, with
ChannelManagerkeeping several lists current. - Message paginators and Message paginator: message intervals, jumping to a message, and unread tracking.
- Message operations: the optimistic send, retry, update and delete, and the request handlers that customize them.
- Connection: the device's network and the WebSocket as two separate reactive services.
- Reading state in components:
useStateStore, which hook reads which store, and the selector rules. - Navigation and the Workspace navigation API: how components open channels and threads.
- Workspace with ChatView, ChatView components and Slot layout: the multi-panel workspace.
- Slot geometry: detecting which panels are covered.
Localization
Two changes affect anyone who customized translations, and a third affects code that calls the Streami18n instance directly. If you use the SDK in English and never touched i18n, there is nothing to do.
The v15 behaviour is on Localization. This section is the migration.
English is the only bundled language
The de, es, fr, hi, it, ja, ko, nl, pt, ru and tr dictionaries are removed, along with their dayjs locale data. The deTranslations to trTranslations exports are gone with them, and so is enTranslations.
Those dictionaries were imported statically and copied into Streami18n when it was created, so they shipped even in apps that only rendered English. Removing them, together with the key change below, takes about 112 KB gzipped out of the bundle.
If you rendered one of them, supply it yourself. The last published files are in the SDK's git history:
git show v14.13.2:src/i18n/de.json > de.jsonIts keys are the old natural-language ones, so rename them (see below) before you register it. You also need the dayjs locale and the calendar formats the SDK used to bundle for each language. Date and time for a new language shows both.
SupportedTranslations and isLanguageSupported are removed. language, and the language option of Streami18n, are plain strings, because the languages are whatever you register.
Chat's defaultLanguage prop is removed. In v14, when you passed no i18nInstance, Chat created one in the user's language, else the browser's language if the SDK bundled it, else defaultLanguage. In v15 the instance Chat creates is English. Delete the prop. For a non-English UI, register a dictionary and set language on your Streami18n instance. For translated messages, set language on the user in client.connectUser().
userLanguage, the language the SDK reads auto-translated message text in, no longer falls back to the browser's language either. A registered German dictionary says nothing about whether the API produces message.i18n.de_text: without client.user.language the API doesn't translate. userLanguage follows client.user.language, or en when that's unset, and it follows changes made after the user connects.
Translation keys are namespaced identifiers
A key used to be the English copy itself. It's now a stable dotted identifier, and each call passes the English copy as i18next's defaultValue. The v14 key aria/Send became:
t("messageComposer.sendButton.send.ariaLabel", "Send");The aria/ prefix is gone. What the text is for moved into the last segment of the key, ariaLabel here.
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 and nothing in the console.
The table that maps v14 keys to v15 keys, 621 rows, is ai-docs/i18n-v15-key-map.json in the SDK repository. It covers most v14 keys. A v14 key with no row is mostly copy that v15 no longer renders. If its copy still shows in your UI, find the v15 key by its English text in the TranslationCatalog type.
A few rows of the table:
{
"keys": {
"Cancel": { "key": "common.cancel.label", "prose": true, "shared": true },
"aria/Send": {
"key": "messageComposer.sendButton.send.ariaLabel",
"prose": true
},
"{{ count }} members": {
"key": "channelDetail.channelMembersView.members.title",
"prose": true,
"plural": true
},
"giphy-command-args": { "key": "command.giphy.args", "prose": true },
"language/de": { "key": "language.de", "prose": true },
"timestamp/MessageTimestamp": {
"key": "timestamp.MessageTimestamp",
"prose": false
}
}
}A German dictionary in v14:
i18n.registerTranslation("de", {
Cancel: "Abbrechen",
"aria/Send": "Senden",
"{{ count }} members_one": "{{ count }} Mitglied",
"{{ count }} members_other": "{{ count }} Mitglieder",
});The same dictionary in v15:
import { Streami18n } from "stream-chat-react";
import type { TranslationDictionary } from "stream-chat-react";
const i18n = new Streami18n();
const de: TranslationDictionary = {
"common.cancel.label": "Abbrechen",
"messageComposer.sendButton.send.ariaLabel": "Senden",
"channelDetail.channelMembersView.members.title_one": "{{ count }} Mitglied",
"channelDetail.channelMembersView.members.title_other":
"{{ count }} Mitglieder",
};
i18n.registerTranslation("de", de);Let TypeScript catch the ones you miss
Typing the dictionary as TranslationDictionary turns the quiet failure into a compile error, which is the fastest way through the rename:
const de: TranslationDictionary = {
Cancel: "Abbrechen", // TS2353: 'Cancel' does not exist in type ...
};Streami18n.registerTranslation() and translationsForLanguage already take that type, so a dictionary written inline is checked without the annotation. Typed dictionaries covers the rest, including how to learn that a later release added a key.
Keys whose value is not copy
The timestamp/*, duration/* and translationBuilderTopic/* groups became timestamp.*, duration.* and translationBuilderTopic.*. Their values are dayjs and i18next expressions, not text, and they no longer live in a locale file. The SDK ships them as defaults that every language you register is layered over, so you list one only to change it. Four timestamp/relative* keys were text, and became relativeTime.today, relativeTime.yesterday, relativeTime.daysAgo and relativeTime.weeksAgo.
Four timestamp.* keys put English day words, such as "Today", into their calendarFormats argument, and need an override to translate them. See Keys that are not copy.
There is no en.json
The SDK no longer checks in an English locale file. The copy is written at each t() call, so a committed catalog would be a second copy that could go stale.
To get a file for a translator or a translation service, generate it from a clone of the SDK with yarn i18n:export. To see a key's English copy in your editor, index the type-only TranslationCatalog export.
The translation runtime
Streami18n is built on the shared @stream-io/i18n package. You still import it from stream-chat-react, and its constructor options are the same. Code that calls the instance directly needs these changes:
| v14 | v15 |
|---|---|
await i18n.getTranslators() |
await i18n.init(), which resolves to the instance's state, t and tDateTimeParser included, and rejects if it fails |
assigning i18n.t |
i18n.overrideTFunction(t) |
const t = await i18n.setLanguage("de") |
setLanguage() resolves to nothing. Read t from the instance's state |
i18n.registerSetLanguageCallback(callback) |
subscribe to i18n.state |
i18n.getAvailableLanguages() |
i18n.registeredLanguages, a read-only Set |
i18n.getTranslations() |
removed. The English copy is in the TranslationCatalog type |
i18n.geti18Instance() |
removed |
translators from useChat({ client, i18nInstance }) |
useStreami18n({ client, i18nInstance }) |
The current t is in the instance's state:
await i18n.setLanguage("de");
const { t } = i18n.state.getLatestValue();@stream-io/i18n depends on i18next and dayjs. If you declared them only for this SDK, you can remove them. If you keep dayjs, use the same version range, so the locale you import registers on the copy that formats the dates.
Creating the client
stream-chat 10 builds the client from one configuration tree, the same tree client.config.set() takes. Most of the options v9 took at the top level now go under client. useCreateChatClient passes that tree as config, which replaces its options prop.
In v14:
const client = useCreateChatClient({
apiKey,
options: { baseURL: "https://chat.example.com", timeout: 6000 },
tokenOrProvider: userToken,
userData: { id: userId },
});In v15:
import { Chat, useCreateChatClient } from "stream-chat-react";
export const App = () => {
const client = useCreateChatClient({
apiKey,
config: {
client: {
axiosRequestConfig: { timeout: 6000 },
baseURL: "https://chat.example.com",
},
},
tokenOrProvider: userToken,
userData: { id: userId },
});
if (!client) return null;
return <Chat client={client}>{/* your app */}</Chat>;
};A client you create yourself takes the same tree: new StreamChat(apiKey, { client: { baseURL } }). The secret argument is gone, because server-side code belongs in @stream-io/node-sdk. In plain JavaScript, a v9 options object still runs, but the client ignores its keys and logs a warning for each.
Where the common v9 options went:
| v9 option | v10 setting |
|---|---|
timeout, headers and the other top-level axios options |
client.axiosRequestConfig |
baseURL |
client.baseURL |
enableWSFallback |
client.wsConnection.enableWSFallback |
wsUrlParams |
client.wsConnection.urlParams |
recoverStateOnReconnect |
client.connectionRecovery.enabled |
persistUserOnConnectionFailure |
client.persistUserOnConnectionFailure |
isLocalUnreadCountEnabled |
channel.readEvents.localUnreadCountEnabled |
logger |
removed. Configure logging with chatLoggerSystem from stream-chat |
device |
removed. Call client.createDevice() after connecting |
warmUp, enableInsights, disableCache |
removed |
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 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 |
channel.data?.name |
channel.data?.custom?.name |
channel.data?.image |
channel.data?.custom?.image |
Channel.sendMessage({ text, ... }) |
Channel.sendMessage({ message: { text, custom: {} } }) |
The channel's name and image are the reads most apps have to fix. Filtering is the exception: filter a channel's name as name, and other custom fields as "custom.<field>". To type your fields, extend the Custom*Data interfaces, as Custom data types shows.
Timestamps are nanosecond numbers
Every date the server sends, such as created_at, is a TimestampNS: Unix time in nanoseconds, as a number. In v9 it was an ISO string, and messages carried Dates.
- Compare and sort timestamps as numbers:
a.created_at < b.created_at. - Convert where a timestamp meets a
Date, a date library orDate.now(), withnsToDate(),convertTimestampToDate()ornsToMs()fromstream-chat. new Date(message.created_at)anddayjs(message.created_at)read nanoseconds as milliseconds. The first fails to compile where you use the result as aDate. The second compiles and renders "Invalid Date".
The React props that carry a timestamp changed with it. MessageList's headerPosition is a TimestampNS, and so are lastRead in processMessages and lastReadDate in the VirtualizedMessageList render props. Components that render a date, such as DateSeparator, still take a Date. Timestamps lists the helpers.
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 }
const sort: SortParamRequest[] = [
{ direction: -1, field: "pinned_at" },
{ direction: -1, field: "last_message_at" },
];Keep the order of the fields: the first one sorts first.
Other client changes
These come up less often. The complete list has each one in detail.
- Raw requests and the optimistic layer.
client.queryChannels(),Channel.sendMessage(),Channel.sendReaction(),client.updateMessage()andclient.deleteMessage()are plain requests that change no local state. The v9 behaviour ofqueryChannelsisclient.queryChannelsAndHydrate(). Sending, editing and deleting with an optimistic update goes through the composer andchannel.messageOperations; see Message operations. - Channels the client holds.
client.activeChannels,client.getChannelById()andclient.getChannelByMembers()are removed. Useclient.channelManager.get(cid),client.channelManager.values(), andclient.channel(). See Shared entities. - Channel state.
channel.stateis a store, the message list moved tochannel.messagePaginator, and pinned messages tochannel.pinnedMessagesPaginator. See Channel state is one store. Channel.watch(). It resolves with the channel instance to use, not the server's response: read the data from the channel, such aschannel.data,channel.state.membersandchannel.messagePaginator.items. It sends a request only when the channel isn't loaded yet, or when you pass options it wasn't watched with, so it no longer refreshes a watched channel: callChannel.reload()for that.Channel.query(). Its second argument, the message set to add the messages to, is removed, and the messages it returns aren't added tochannel.messagePaginator. Open a channel withChannel.watch(), and load more messages withMessagePaginator.toTail(),MessagePaginator.toHead()orMessagePaginator.jumpToMessage().- Threads. A thread's replies are in
thread.messagePaginator, and the thread list isclient.threads.paginator. Get a thread withclient.threads.ensure({ channel, parentMessage }), notnew Thread().Thread.activate()returns the function that ends the activation, andThread.deactivate()is removed. - Marking read.
Channel.markRead()sends the request directly. The v9Channel.markRead()path, through the delivery reporter, isChannel.markReadViaReporter(), which also calls yourmarkReadRequesthandler if you register one. - Events.
Eventis a union of event types,EventTypesisEventType, and custom event fields are underevent.custom. - Errors. API errors are
StreamAPIError, which replacesErrorFromResponse. StateStore.stream-chatno longer exports it. Import it from@stream-io/state-store, and keep one copy of that package installed.- Link previews. The composer loads link previews by default where the channel type enriches URLs. To turn them off:
client.config.set({ messageComposer: { linkPreviews: { enabled: false } } }). - Node.js.
stream-chatneeds Node.js 22.18 or later, and no longer bundles a WebSocket for Node.
Renamed and deprecated APIs
The left column keeps working in v15. These are deprecated aliases, not removed APIs, but new code should use the right column.
| Deprecated | Use |
|---|---|
client.setMessageComposerSetupFunction(fn) |
client.config.setSetupFunction("messageComposer", fn) |
Thread.markAsRead() |
Thread.markRead() |
client.userID, client.clientID |
client.userId, client.clientId |
The paginators' old method names are removed, not deprecated. A call to them doesn't compile:
| Removed | Use |
|---|---|
paginator.next() |
paginator.toTail() |
paginator.prev() |
paginator.toHead() |
paginator.nextDebounced() |
paginator.toTailDebounced() |
paginator.prevDebounced() |
paginator.toHeadDebounced() |
paginator.hasNext |
paginator.hasMoreTail |
paginator.hasPrev |
paginator.hasMoreHead |
paginator.headItems |
paginator.anchoredHeadItems |
paginator.headmostItem |
paginator.anchoredHeadItem |
A paginator's state has no hasNext and hasPrev either, so a selector reading them gets undefined: select hasMoreTail and hasMoreHead. In the same way, a cursor's next and prev are tailward and headward, and the hasNext and hasPrev of useChannelPaginatorState() are hasMoreTail and hasMoreHead.
In a message list, the tail is where older messages load, and the head is the newest message. Head and tail explains both ends.
The paginator classes moved inside stream-chat to pagination/paginators/. Import them from stream-chat, never from a path inside stream-chat/dist.
Connection status is two facts now
Until v15 the SDK had one notion of "online": whether its WebSocket was up. It inferred the device's network from that socket, which is wrong both ways. A socket dies on working Wi-Fi when the server closes it or a token expires, and a device can get its network back seconds before the socket. stream-chat 10 splits them into client.networkConnection, whether the device has a network, and client.wsConnection, whether the socket is up.
You're affected if you listen for connectivity, render your own offline UI, or read the socket directly:
| v14 | v15 |
|---|---|
connection.changed event |
client.wsConnection.state or client.networkConnection.state. In components, useWSConnectionState() or useNetworkConnectionState() |
transport.changed event |
the local connection.fallback_activated event |
client.wsConnection, the socket or null |
client.wsConnection always exists. It reports isHealthy and holds the socket's settings |
client.defaultWSTimeout |
client.wsConnection.config.connectTimeoutMs, set as client.wsConnection.connectTimeoutMs in the configuration |
client.wsFallback |
removed |
ThreadManagerState.lastConnectionDropAt |
lastUnhealthyAt in client.wsConnection.state |
Two of these fail without a compile error, so check for them by hand:
connection.changedhandlers. The event no longer exists, so a listener for it doesn't compile. Subscribe to the store you meant instead. If the handler showed a "connection lost" banner, it relied on the event holding a drop back for five seconds, so a short flap didn't flash the banner. The stores publish every change at once, so waitclient.wsConnection.config.offlineNotificationDisplayDelayMsyourself, and cancel if the socket comes back.- Options passed as a separate object. TypeScript rejects the old option names when you pass the options inline. An options object built elsewhere gets past that check, and the client ignores it.
The two stores name their fields differently. The socket's isHealthy is always a boolean. The network's isOnline is undefined until a reporter has reported, so test it with === false.
On React Native, the browser events the built-in network reporter uses don't exist. The client installs a stand-in that repeats what the socket reports, so nothing breaks, but it adds no information. Install a reporter built on the platform's network API for a real signal. See Device network.
Watching changed too. A request that watches a channel or subscribes to presence waits for the WebSocket's connection id, which the server ties the subscription to. Before, it went out without one, got a 200, and was registered nowhere. It waits rather than fails, so it rides out a reconnect. It fails when no socket is open or opening, or when the socket is closed while it waits. A query that asks for neither isn't held. See "No connection id is available".
Recovery after a reconnect changed too. stream-chat 9 re-queried the loaded channels in one request. stream-chat 10 queries again the channels each registered channel list has loaded, up to 150, page by page, and removes those no page returned. It also reloads each active channel and thread, and then dispatches connection.recovered. See Connection recovery.
Channels
Channel props that moved
Channel has one prop: the channel. Everything else that used to hang off it now belongs to the component or the layer that uses it:
v14 prop on Channel |
v15 |
|---|---|
giphyVersion, imageAttachmentSizeHandler, videoAttachmentSizeHandler, shouldGenerateVideoThumbnail |
props of Attachment. Register an Attachment wrapper through WithComponents |
onMentionsClick, onMentionsHover |
props of MessageList |
doSendMessageRequest, doUpdateMessageRequest, doDeleteMessageRequest, doMarkReadRequest |
request handlers in client.config. See Request handlers |
initializeOnMount, channelQueryOptions |
removed. See Channel no longer queries the channel |
activeUnreadHandler |
removed. See Channel no longer writes document.title |
EmptyPlaceholder |
removed. See ChannelProps.channel is required |
allowConcurrentAudioPlayback |
removed. See Audio playback |
markReadOnMount |
removed. The message list marks the channel read when the user is at the newest message |
skipMessageDataMemoization |
removed. Messages come from the channel's message list store |
The request handlers change shape as well as place. The message handlers take a params object instead of positional arguments, plus the default request as a second argument. They resolve to { message }. They're registered once per client, not per mounted Channel, so to treat channels differently, branch inside the handler.
In v14:
<Channel channel={channel} doSendMessageRequest={mySend}>
{/* ... */}
</Channel>In v15:
client.config.set({
channel: {
requestHandlers: {
sendMessageRequest: async ({ message, options }) => ({
message: await mySend(message, options),
}),
},
},
});Thread replies go through the same channel handlers. The thread key takes only markReadRequest, which runs when a thread is marked read.
useChannelEditMessageHandler is removed with the props. An updateMessageRequest handler covers edits.
Channel no longer queries the channel
Channel used to call Channel.watch() when it mounted a channel that wasn't loaded, and initializeOnMount turned that off. Both are gone, along with channelQueryOptions and the getChannel helper. Whoever supplies the channel loads it:
import { useEffect, useState } from "react";
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
ChannelPlaceholder,
MessageComposer,
MessageList,
useChatContext,
} from "stream-chat-react";
export const SupportChannel = () => {
const { client } = useChatContext();
const [channel, setChannel] = useState<StreamChannel>();
useEffect(() => {
client.channel("messaging", "support").watch().then(setChannel);
}, [client]);
if (!channel) return <ChannelPlaceholder>Loading...</ChannelPlaceholder>;
return (
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
);
};client.channel() returns the channel the client holds for that id, or creates it, without a request. Channel.watch() is safe to call again, even from several places at once: it sends a request only when the channel isn't loaded yet, or when you pass options it wasn't watched with. So an effect that runs twice, or two components opening the same channel, still send one query. It resolves with the channel to use from then on. Pass what you passed as channelQueryOptions to Channel.watch(options).
Channels that come from ChannelList, or from a client.queryChannelsAndHydrate() call, are already watched. If yours come from there, there is nothing to change. Channels from search results aren't watched. watch and query covers the ways to load a channel.
Two things to plan for:
- A channel that isn't loaded renders as an empty channel, with no error, because nothing failed. If a channel shows nothing, check
channel.initializedfirst. - Loading and error UI is yours.
Channelno longer rendersLoadingIndicatororLoadingErrorIndicatorfor a query it doesn't make. Render them aroundChannel, in your own layout. TheLoadingErrorIndicatoroverride key is removed, because no component renders it; theLoadingErrorIndicatorcomponent is still exported.
Channel no longer writes document.title
Channel used to add the channel's unread count to the tab title when a message arrived while the tab was hidden, and activeUnreadHandler let you change that. Both are removed, with no replacement in the SDK.
What belongs in a tab title depends on what your app shows, which no SDK component knows. It knows the unread counts, but not whether the user is looking at the channel they're in. The version that shipped counted only the open channel, did nothing in the threads view, skipped thread replies, and stacked prefixes across channel switches.
Two things to know when you write your own:
- The counts belong to the user, not to a view. The total of unread messages comes from the connection's
mepayload and thetotal_unread_counton events. The number of unread threads comes fromclient.threads. Neither needs an open channel or thread. - They count different things. One counts unread messages in channels, the other counts threads with unread replies. Don't add them together.
ChannelProps.channel is required; EmptyPlaceholder is gone
A Channel without a channel had nothing to provide, so the prop is required and EmptyPlaceholder is removed. Check for a channel where you render it, or render ChannelPlaceholder, the channel's container with no channel in it, in the same place:
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
ChannelPlaceholder,
MessageComposer,
MessageList,
} from "stream-chat-react";
export const ChannelPanel = ({ channel }: { channel?: StreamChannel }) =>
channel ? (
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
) : (
<ChannelPlaceholder>Select a conversation</ChannelPlaceholder>
);Window is gone
Window wrapped the channel's header, message list and composer, and made room for an open thread. It's removed. Render those parts directly inside Channel, and render the thread where your layout wants it: in a ThreadSlot with ChatView, or in your own panel with Thread. Channel UI shows a channel column.
Channel state is one store
In stream-chat 9, channel.state was a plain object. Its fields, such as read and members, changed without notifying anyone, and components re-rendered because Channel copied them into ChannelStateContext on every event. In stream-chat 10, channel.state is a store, and nothing copies it. Subscribe to it with useStateStore.
In v14:
const { read } = channel.state;In v15:
import type { ChannelStateData } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
const readSelector = (state: ChannelStateData) => ({ read: state.read });
export const ReadCount = () => {
const channel = useChannel();
const { read } = useStateStore(channel.state, readSelector);
return <span>{Object.keys(read).length} members have read state</span>;
};Most keys keep their v9 names, such as members, membership, read, typing and watchers. watcher_count is watcherCount. The messages and the pinned messages moved to channel.messagePaginator and channel.pinnedMessagesPaginator. The state also holds values that used to live elsewhere: data, memberCount, muteStatus, watchStatus, initialized and aiState. Getters such as channel.state.members still work, but they return a snapshot and re-render nothing. What channels and threads hold lists the fields.
Two more renames:
channel.disconnectedischannel.pendingDisposal, which is read-only.Channel.getConfig()is thechannel.serverConfiggetter: drop the parentheses. It's the channel type's settings from the server. What the client enforces ischannel.config, the server's settings combined with yours, which thechannel.configStatestore also publishes.
The configuration a v14 component read from ChannelStateContext as channelConfig is channel.configState, with new field names:
v14 channelConfig |
v15 channel.configState |
|---|---|
typing_events |
typingEvents.enabled |
read_events |
readEvents.enabled |
replies |
replies.enabled |
user_message_reminders |
userMessageReminders.enabled |
commands |
availableCommands |
AIStates moved too: import it from stream-chat, not stream-chat-react.
The channel's latest message
channel.state.latestMessages and Channel.lastMessage() are removed. The channel's newest message is in its message paginator's aggregateState, whichever interval of messages is on screen.
In v14:
const last = channel.state.latestMessages.at(-1);In v15:
import type { MessagePaginatorAggregateState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
const latestMessageSelector = (state: MessagePaginatorAggregateState) => ({
latestMessage: state.latestMessage,
});
export const LastMessageText = () => {
const channel = useChannel();
const { latestMessage } = useStateStore(
channel.messagePaginator.aggregateState,
latestMessageSelector,
);
return <span>{latestMessage?.text}</span>;
};ChatContext.latestMessageDatesByChannels is removed too. It held when the current user last posted in each channel, for the slow-mode cooldown, which is now channel.cooldownTimer. useCooldownRemaining() returns the seconds left, and useIsCooldownActive() whether the user is still waiting.
ChatContext has no active channel
ChatContext no longer carries channel, setActiveChannel or channelsQueryState. Several Channel components can be mounted at once, each bound through its channel prop, so there is no single selected channel. Each Channel activates its own channel with Channel.activate(), and several can be active at once; see Entity lifecycle.
In v14:
const { setActiveChannel } = useChatContext();
setActiveChannel(nextChannel);In v15:
import type { Channel as StreamChannel } from "stream-chat";
import { useWorkspaceNavigation } from "stream-chat-react";
export const OpenChannelButton = ({ channel }: { channel: StreamChannel }) => {
const { openChannel } = useWorkspaceNavigation();
return <button onClick={() => openChannel(channel)}>Open</button>;
};useWorkspaceNavigation() also has isChannelActive(cid), openChannels, and the same for threads. Without a layout such as ChatView, it does nothing, so a component can call it anywhere. The first call of each open, close and back operation then logs a console warning. An app without a layout keeps the selected channel in its own state and passes it to Channel. See Navigation.
To show a channel from elsewhere, such as a deep link or a search result, in the channel lists, call client.channelManager.ingestChannel(channel) before you open it.
Channel lists
ChannelList no longer queries channels. Its data comes from a ChannelPaginator that you create and register on client.channelManager, and the component renders what that paginator holds. Its props are paginator, loadMoreDebounceMs and loadMoreThresholdPx.
In v14:
<ChannelList
filters={filters}
sort={{ last_message_at: -1 }}
options={{ presence: true, state: true }}
/>In v15:
import { useEffect, useMemo } from "react";
import { ChannelPaginator } from "stream-chat";
import type { ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
export const MyChannelList = ({ userId }: { userId: string }) => {
const { client } = useChatContext();
const paginator = useMemo(() => {
const filters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
return new ChannelPaginator({
client,
filters,
id: "channels:default",
paginatorOptions: { pageSize: 10 },
requestOptions: { presence: true, state: true },
sort,
});
}, [client, userId]);
useEffect(() => {
client.channelManager.insertPaginator({ paginator });
return () => {
client.channelManager.removePaginator(paginator);
};
}, [client, paginator]);
return <ChannelList paginator={paginator} />;
};Registering matters. client.channelManager routes 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 as events arrive.
The query options split in two. Options of the request, such as presence, state and watch, go in requestOptions. Paging, such as pageSize, goes in paginatorOptions. offset and limit aren't accepted: the paginator owns them.
ChannelNavigation renders one ChannelList for each paginator on client.channelManager, with the list header and the search bar. Channel lists shows a sidebar built that way.
Props with no direct replacement
| v14 prop | v15 |
|---|---|
filters, sort, options |
the filters, sort and requestOptions of ChannelPaginator |
channelRenderFilterFn, renderChannels, lockChannelOrder |
the paginator's filters and sort. The order comes from the sort, so a list can't be re-ordered locally |
onMessageNew, onChannelUpdated, onAddedToChannel and the other on* props |
client.channelManager.addEventHandler(...). See Customizing event handling |
allowNewMessagesFromUnfilteredChannels |
the ignoreEventsForUnknownChannels handler from stream-chat, inserted first. See Channels the client hasn't seen |
customQueryChannels |
removed. The paginator sends the query |
Paginator, EmptyStateIndicator |
the EmptyListIndicator, FirstPageLoadingIndicator, LoadingNextPageIndicator and EndReachedIndicator overrides on WithComponents |
getLatestMessagePreview |
the SummarizedMessagePreview override |
showChannelSearch |
render ChannelNavigation, which includes the search bar, or Search where your layout needs it |
setActiveChannelOnMount, customActiveChannel |
open a channel with useWorkspaceNavigation().openChannel(channel) |
sendChannelsToList |
read the paginator's channels with useChannelPaginatorState(paginator) |
recoveryThrottleIntervalMs |
removed. The client reloads the registered lists after a reconnect |
watchers |
removed |
The channel-list helpers moveChannelUpwards, isChannelPinned, isChannelArchived, findLastPinnedChannelIndex, extractSortValue, shouldConsiderPinnedChannels and shouldConsiderArchivedChannels are removed, along with usePaginatedChannels. A list no longer moves a channel to the top when a message arrives: a channel's place comes from the sort, so sort by last_message_at to keep the latest activity first.
More than one list
Register several paginators, and let the manager decide which list holds a channel that matches more than one:
client.channelManager.setPaginators([pinned, archived, muted, everythingElse]);
client.channelManager.setOwnershipResolver([
"channels:archived",
"channels:muted",
]);client.channelManager.setPaginators() publishes one update for the whole set. Use client.channelManager.insertPaginator() and client.channelManager.removePaginator() for changes one at a time. Channels that match several paginators explains the ownership rules.
ChannelListContext holds a paginator
channels, setChannels, hasNextPage and loadNextPage are gone. The context gives a ChannelPaginator, and useChannelPaginatorState reads it. ChannelLists, which ChannelNavigation renders, provides the first paginator registered on client.channelManager. A ChannelList you render yourself doesn't provide it: wrap it in ChannelListContextProvider.
In v14:
const { channels, setChannels } = useChannelListContext();In v15:
import type { ChannelPaginator } from "stream-chat";
import {
useChannelListContext,
useChannelPaginatorState,
} from "stream-chat-react";
const LoadedChannelCount = ({ paginator }: { paginator: ChannelPaginator }) => {
const { channels } = useChannelPaginatorState(paginator);
return <span>{channels?.length ?? 0} channels</span>;
};
export const ChannelCount = () => {
const { paginator } = useChannelListContext();
return paginator ? <LoadedChannelCount paginator={paginator} /> : null;
};To change the loaded list, call ChannelPaginator.setItems({ valueOrFactory }). To load the next page, call ChannelPaginator.toTail().
Channel-list UI overrides
ChannelListUI is removed. The list body and its states are separate overrides on WithComponents: ListItem, EmptyListIndicator, FirstPageLoadingIndicator, LoadingNextPageIndicator and EndReachedIndicator. SummarizedMessagePreview renders a row's message preview. See Channel list UI.
ChannelListItem lost channelUpdateCount, getLatestMessagePreview, setActiveChannel, watchers and its own key prop. React's key is unaffected. Pass previewedMessage to preview a message other than the latest, such as a search match. ChannelListItemUI takes previewedMessage instead of lastMessage and latestMessagePreview. When the channel type skips system messages for the last message, the preview skips them too, so it agrees with the list's order.
Threads
Thread takes a thread and renders its children
Thread no longer renders a header, a message list and a composer of its own. Like Channel, it takes the thread as a required thread prop, gives it to its children, loads it when needed, and renders the children you pass. You no longer wrap it in ThreadProvider.
In v14:
<Thread
additionalMessageComposerProps={{ audioRecordingEnabled: true }}
additionalMessageListProps={{ onUserClick }}
virtualized
/>In v15:
import type { Thread as StreamThread } from "stream-chat";
import {
MessageComposer,
Thread,
ThreadHeader,
VirtualizedMessageList,
} from "stream-chat-react";
export const ThreadPanel = ({ thread }: { thread: StreamThread }) => (
<Thread thread={thread}>
<ThreadHeader />
<VirtualizedMessageList />
<MessageComposer audioRecordingEnabled focus />
</Thread>
);Get the thread from client.threads.ensure({ channel, parentMessage }) or client.threads.get(id). With ChatView, ThreadSlot renders Thread for the thread in its slot, and its children are the thread's contents. Thread UI covers both.
Where the removed props went:
v14 prop on Thread |
v15 |
|---|---|
additionalMessageComposerProps |
props on your MessageComposer |
additionalMessageListProps |
props on your MessageList |
additionalVirtualizedMessageListProps |
props on your VirtualizedMessageList |
additionalParentMessageProps |
a ThreadHead override on WithComponents |
autoFocus |
<MessageComposer focus /> |
virtualized |
render the list you want |
enableDateSeparator |
removed. Pass withDateSeparator to the list |
Message |
a MessageUI override, set with WithComponents around the thread |
messageActions |
removed. See Message actions |
What follows from it:
- Date separators. v14's
Threadturned them off for the list it rendered. AMessageListin a thread now shows them, unless you passwithDateSeparator={false}. - The parent message is rendered by the message list, above the replies, because it scrolls with them. The
headprop ofMessageListandVirtualizedMessageListis removed. To change how the parent renders, set aThreadHeadoverride. - The header.
ComponentContext.ThreadHeaderis removed: render the header you want.ThreadHeadertakesoverrideTitle,StartContentandEndContent. ItsthreadandcloseThreadprops are removed. It closes the thread through workspace navigation, anduseCloseThread()gives a custom header the same behaviour. - No remount per thread.
Threadno longer rebuilds its children when the thread changes. A component inside it that reset its state on mount must depend on the thread instead. useLegacyThreadContextis removed. The thread's composer reads the thread fromThreadContext.
Layout
ChatView is a slot layout
ChatView moved to stream-chat-react/slot-layout, and its compound components are gone. ChatView.Channels and ChatView.Threads are replaced by the views prop, and ChatView.ThreadAdapter by ThreadSlot. layouts declares the slots of each view. useThreadsViewContext is removed.
In v14:
<ChatView>
<ChatView.Selector />
<ChatView.Channels>
<ChannelList filters={filters} />
<Channel>
<Window>
<MessageList />
<MessageComposer />
</Window>
<Thread />
</Channel>
</ChatView.Channels>
<ChatView.Threads>
<ThreadList />
<ChatView.ThreadAdapter>
<Thread />
</ChatView.ThreadAdapter>
</ChatView.Threads>
</ChatView>In v15:
import {
ChannelHeader,
ChannelNavigation,
MessageComposer,
MessageList,
ThreadHeader,
ThreadList,
} from "stream-chat-react";
import {
ChannelSlot,
ChatView,
ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
// Declared outside the component, so ChatView gets the same array on every render.
const layouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["channel", "thread"] },
{ id: "threads", slots: ["thread"] },
];
const ThreadPanel = () => (
<ThreadSlot slot="thread">
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
);
const ChannelsView = () => (
<>
<ChatView.Selector />
<ChannelNavigation />
<ChannelSlot slot="channel">
<ChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
<ThreadPanel />
</>
);
const ThreadsView = () => (
<>
<ChatView.Selector />
<ThreadList />
<ThreadPanel />
</>
);
export const Workspace = () => (
<ChatView
layouts={layouts}
views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
/>
);Pass layouts with views. Without it, each view has a single slot that holds its list, and channels and threads have nowhere to open. Workspace with ChatView explains views, slots and where an opened channel lands.
ChatView.Selector, useActiveThread and useChatViewContext are imported from stream-chat-react/slot-layout too. useChatViewContext throws outside ChatView.
New package entry points
The layout parts have their own entry points, so an app that doesn't use them doesn't load them:
| Import from | What it holds |
|---|---|
stream-chat-react |
useWorkspaceNavigation, ChannelNavigation and everything else in the core |
stream-chat-react/slot-layout |
ChatView and its selector, WorkspaceLayout, Slot, ChannelSlot, ThreadSlot, ThreadListSlot, useActiveThread, useChatViewContext and useChatViewNavigation |
stream-chat-react/slot-geometry |
SlotGeometryProvider, useSlotGeometry and useRegisterSlotGeometry |
The stylesheets didn't move: stream-chat-react/dist/css/index.css and the other files in dist/css/ are the same as in v14, and index.css styles the slot layout too. Entry points and CSS files lists every entry point.
Components
Removed contexts
ChannelStateContext and ChannelActionContext are removed, along with useChannelStateContext, useChannelActionContext and their providers. So are TypingContext, useTypingContext and TypingProvider. Channel state is no longer copied into React context. Read it from the channel.
In v14:
const { channel, messages, members } = useChannelStateContext();In v15:
import type { ChannelStateData, MessagePaginatorState } from "stream-chat";
import { useChannel, useStateStore } from "stream-chat-react";
const itemsSelector = (state: MessagePaginatorState) => ({
messages: state.items,
});
const membersSelector = (state: ChannelStateData) => ({
members: state.members,
});
export const ChannelSummary = () => {
const channel = useChannel();
const { messages } = useStateStore(
channel.messagePaginator.state,
itemsSelector,
);
const { members } = useStateStore(channel.state, membersSelector);
return (
<span>
{messages?.length ?? 0} messages loaded, {Object.keys(members).length}{" "}
members
</span>
);
};useChannel() takes no arguments. It returns the channel of the surrounding Channel, or the thread's channel inside a Thread, and throws outside both. Inside a thread, channel.messagePaginator is the channel's list, not the thread's: use useMessagePaginator(), which returns the thread's list there. Which owner a hook reads shows how to pick the right one.
Where the common v14 values are:
| v14 | v15 |
|---|---|
channel |
useChannel() |
messages |
items in channel.messagePaginator.state |
hasMore, hasMoreNewer, loadingMore |
hasMoreTail (older), hasMoreHead (newer) and isLoading in the same state |
members, read, watchers, watcher_count |
members, read, watchers and watcherCount in channel.state |
typing, from useTypingContext() |
typing in channel.state. See Typing indicators |
channelCapabilities |
useChannelCapabilities() |
channelConfig |
channel.configState. See Channel state is one store |
pinnedMessages |
channel.pinnedMessagesPaginator |
thread, threadMessages, openThread, closeThread |
useThreadContext(), thread.messagePaginator, and openThread and closeThread from useWorkspaceNavigation() |
loadMore, loadMoreNewer |
MessagePaginator.toTail() and MessagePaginator.toHead() |
jumpToMessage(id), jumpToLatestMessage() |
MessagePaginator.jumpToMessage(id) and MessagePaginator.jumpToTheLatestMessage() |
sendMessage, editMessage, retrySendMessage, deleteMessage |
MessageComposer.send() and MessageComposer.update(), and channel.messageOperations. See Message operations |
markRead |
Channel.markReadViaReporter() |
mutes, from MessageContext |
mutes from useChatContext() |
Context hooks take no component name, and throw outside their provider
The optional componentName argument is removed from useChatContext, useTranslationContext, useMessageContext, useComponentContext, useMessageComposerContext, useMessageListContext and useMessageBounceContext. Write useChatContext(), not useChatContext("MyComponent"). TypeScript flags every call. Plain JavaScript ignores the argument.
In v14 most context hooks returned an empty object outside their provider, some with a warning in the console. In v15 a hook whose provider is required throws, and the error names the provider it needs. This affects, among others, useChatContext outside Chat, useMessageContext outside a message, useMessageComposerContext outside a composer, and useChatViewContext outside ChatView. A component that renders both inside and outside a channel reads useChannelInstanceContext(), which returns {} outside a Channel. useTranslationContext and useComponentContext still work anywhere.
Message UI overrides use MessageUI
The deprecated Message override key is removed, and so is every Message prop that took a message UI component. Set the MessageUI override:
| v14 | v15 |
|---|---|
Message key on WithComponents |
MessageUI |
<MessageList Message={CustomMessage} /> |
the MessageUI override |
<VirtualizedMessageList Message={CustomMessage} /> |
the VirtualMessage override, which falls back to MessageUI |
<Message Message={...} />, <Thread Message={...} /> |
the MessageUI override, set around the part it applies to |
import type { PropsWithChildren } from "react";
import {
MessageText,
WithComponents,
useMessageContext,
} from "stream-chat-react";
const CustomMessage = () => {
const { message } = useMessageContext();
return (
<div className="custom-message">
<strong>{message.user?.name}</strong>
<MessageText />
</div>
);
};
// Declared outside the component, so WithComponents gets the same object on every render.
const overrides = { MessageUI: CustomMessage };
export const WithCustomMessages = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);A custom message UI receives no props: it reads everything from useMessageContext(). In TypeScript, a leftover Message key or prop is a compile error, and for the key the error suggests MessageUI. In plain JavaScript it's ignored, and the default message UI renders, so search your code for Message= and Message:. See Overriding components.
Date separators: withDateSeparator
MessageList and VirtualizedMessageList took disableDateSeparator, while Thread took enableDateSeparator. Both lists take withDateSeparator now, and Thread's prop is removed. The defaults behave as before:
| Component | v14 | v15 |
|---|---|---|
MessageList |
disableDateSeparator={false} |
withDateSeparator={true} |
VirtualizedMessageList |
disableDateSeparator={true} |
withDateSeparator={false} |
The helpers took the rename too: processMessages takes withDateSeparator instead of enableDateSeparator, and useEnrichedMessages instead of disableDateSeparator.
Message list props
Besides Message, head and the date separator prop, the lists dropped the props that Channel used to fill in. MessageList dropped loadMore, loadMoreNewer, loadingMoreForJumpToChannelMessage, openThread, retrySendMessage and threadList. VirtualizedMessageList dropped loadMore, loadMoreNewer, openThread and threadList, and also hasMore, hasMoreNewer, loadingMore, loadingMoreNewer, highlightedMessageId, channelUnreadUiState and messageLimit. Each list reads its message paginator, and knows from its context whether it shows a thread. MessageList also dropped hasMore, jumpToLatestMessage, loadingMoreNewer, messageLimit and messages, and VirtualizedMessageList dropped messages: each list renders the messages of its paginator. MessageList keeps loadingMore. Without it, the list shows the LoadingIndicator override while it loads an older page; loadingMore={false} hides it.
Message actions
The messageActions prop is removed from MessageList, VirtualizedMessageList, Thread and Message, together with MESSAGE_ACTIONS, OPTIONAL_MESSAGE_ACTIONS, getMessageActions(), and the MessageActionsArray and Capabilities types. In v14 the prop had already stopped changing which actions showed. MessageContext no longer has getMessageActions or actionsEnabled.
To choose the actions, filter defaultMessageActionSet and pass the result to MessageActions:
import type { PropsWithChildren } from "react";
import {
MessageActions,
WithComponents,
defaultMessageActionSet,
} from "stream-chat-react";
// The dropdown toggle has no type, so keep it by its placement.
const actionSet = defaultMessageActionSet.filter(
(item) =>
item.placement === "quick-dropdown-toggle" || item.type !== "delete",
);
const MessageActionsWithoutDelete = () => (
<MessageActions messageActionSet={actionSet} />
);
const overrides = { MessageActions: MessageActionsWithoutDelete };
export const WithoutDelete = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);The action types the SDK ships are the DefaultMessageActionType union. Actions the user can't perform are still left out for you. See Actions.
Composer
overrideSubmitHandleris removed fromMessageComposer. To change the request, register asendMessageRequesthandler, as in Channel props that moved. To change the message before it's sent, add a composition middleware.preventClearingOnUnmountis removed.MessageComposerno longer clears a composer you supplied throughMessageComposerControllerProviderwhen it unmounts. CallMessageComposer.clear()on it yourself when you're done with it.MessageComposerContextno longer hashandleSubmit. A custom submit control calls the functionuseMessageComposerSubmitFn()returns, which sends a new message or saves an edit. To send from your own code, callMessageComposer.send(), orMessageComposer.update()for an edit. Each resolves to"sent","nothing-to-send"or"failed".useAttachmentManagerState()returnscustomCdninstead ofhasCustomDoUploadRequest. If your upload function sends files to storage Stream doesn't host, declare it withclient.config.set({ messageComposer: { attachments: { customCdn: true } } }), or the file action disappears for users without theupload-filecapability.UnsupportedAttachmentPreviewandRemoveAttachmentPreviewButtonare exported, for custom attachment previews to reuse.
Headers
ComponentContext.HeaderStartContent is removed. ChannelHeader and ThreadHeader take StartContent and EndContent props instead. By default, StartContent is a back button when the panel is stacked over other content, and EndContent is a close button when the panel can be closed:
import {
ChannelHeader,
WorkspaceNavigationBackButton,
} from "stream-chat-react";
// SidebarToggle stands for a control of your own.
const SidebarToggle = () => <button type="button">Menu</button>;
const HeaderStart = () => (
<>
<WorkspaceNavigationBackButton />
<SidebarToggle />
</>
);
export const Header = () => <ChannelHeader StartContent={HeaderStart} />;A prop replaces its default button, so render the button too if you want to keep it. HeaderEndContent stays on ComponentContext, for the end of the channel-list and thread-list headers.
Audio playback
allowConcurrentAudioPlayback is removed from Channel, and allowConcurrentPlayback from WithAudioPlayback. One voice message plays at a time across the app: starting one pauses the other. Switching channels stops the previous channel's audio. useActiveAudioPlayer() reports the player that's active anywhere in the app.
Search
Channel search results, and the channels behind message search results, are no longer watched. The built-in result items watch the channel when the user opens it. A custom result item calls Channel.watch(), or MessagePaginator.jumpToMessage(id, { watchChannel: true }) for a message.
Channel no longer scrolls to a message picked in search. The result item calls MessagePaginator.jumpToMessage() itself, and the highlight is in channel.messagePaginator.messageFocusSignal. See Search.
Styling
The stylesheet paths and the --str-chat__* variables are the same as in v14. A few things changed around them:
- Wrappers.
.str-chat__main-panel, whichWindowrendered, and the channel's inner.str-chat__containerare gone, andcustomClasses.chatContaineris removed with them. The header, the message list and the composer sit directly in.str-chat__channel. Move CSS that targets either wrapper to.str-chat__channel. - The channel list inside
ChatView. InsideChatView, the channel list renders nostr-chatelement of its own and takes the tokens fromChatView's root, so a token you override there reaches the list. CSS that matched the list's root as.str-chatinsideChatView, such as.str-chat.str-chat__channel-list, no longer matches it. OutsideChatView,ChannelNavigation's root, or a wrapper around aChannelListrendered on its own, carriesstr-chatand thethemeclasses.customClasses.channelListreplacesstr-chat__channel-listonChannelNavigation's root. See Design tokens. - Quoted messages. The quoted message preview, in the composer and inside messages, uses the text colour that goes with its background,
--str-chat__chat-text-incoming, or--str-chat__chat-text-outgoingfor your own messages. If you overrode those colours to fix the contrast, remove the override. - Buttons in dark mode. The dark theme sets its own button text colours: dark text on solid primary and destructive buttons, and lighter text on outline and ghost buttons.
Theming lists the variables.