From 0a88ad866f33649d1c952c175bfdd1962f21fb0e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Sat, 17 Oct 2026 01:01:04 +0200 Subject: [PATCH 1/2] docs: align the v14 -> v15 migration guides with the breaking-changes audit Rewrite ai-migration-v14-v15.md grouped by user task so it covers every confirmed BC entry of v15-breaking-changes.md, with the silent breaks first and an index mapping each entry to its section. Fix the inaccuracies the audit listed: messageOperations instead of the non-existent *WithLocalUpdate methods, toTail/toHead paginator directions, thread replies using the channel request handlers, and others. Correct the i18n guide's runtime attribution (@stream-io/i18n), add the strict t and Streami18n API changes, fix the thread request-handler and channelQueryOptions claims in instance-configuration.md, and remap the two send/edit failure rows in the i18n key map. --- AGENTS.md | 2 +- ai-docs/ai-migration-v14-v15.md | 2626 +++++++++++++++++++++-------- ai-docs/i18n-v15-key-map.json | 4 +- ai-docs/i18n-v15-migration.md | 136 +- ai-docs/instance-configuration.md | 40 +- 5 files changed, 2067 insertions(+), 741 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 816468626..0e22da70c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ Each is a `StateStore`. Components read them with `useStateStore(store, selector `Channel` takes a single `channel` prop (`src/components/Channel/Channel.tsx`). It activates the channel on mount, deactivates it on unmount, seeds the paginator's unread snapshot, and re-queries on `user.deleted` — nothing else. It does not query the channel for you: initialize it first (`channel.ensureWatched()` sends one watch for overlapping calls, and none for a channel already watched). There is no `Window` component in v15; compose layout yourself or through `ChatView` slots. -Read the bound channel with `useChannel()` (`src/context/useChannel.ts`, thread's channel first, then the `Channel` subtree's) and the active message list with `useMessagePaginator()` (`src/hooks/useMessagePaginator.ts`, thread first, then channel). +Read the bound channel with `useChannel()` (`src/context/useChannel.ts`, thread's channel first, then the `Channel` subtree's) and the active message list with `useMessagePaginator()` (`src/hooks/useMessagePaginator.ts`, thread first, then channel). `useMessagePaginator()` is internal and not exported from the package; integrator-facing docs and examples read `(thread ?? channel).messagePaginator` via `useThreadContext()` / `useChannel()`. ### Context layers (`src/context/`) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 96a65ce4d..90c2c273d 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1,951 +1,2173 @@ # stream-chat-react v14 → v15 AI Migration Guide -Execution-only guide for a coding agent migrating a third-party app from `stream-chat-react` v14 to v15. Apply the changes below and fix each compile/runtime break before moving on. For evidence or edge cases, verify against the installed SDK source (see below). +Execution-only guide for a coding agent migrating a third-party app from `stream-chat-react` v14 to v15. Apply the changes below and fix each compile or runtime break before moving on. For evidence and edge cases, verify against the installed SDK source (see below). + +Every breaking change confirmed in `ai-docs/v15-breaking-changes.md` (the audit this guide is written from, `BC-001` to `BC-077`) is covered here. The [index at the end](#index-of-breaking-changes) maps each `BC` entry to its section. + +## Source of truth + +Never rely on training knowledge. The v14 surface is heavily represented in pretraining data and will mislead you. Before writing any code, verify against the user's installed packages: + +1. `node_modules/stream-chat-react/dist/types/index.d.ts`: the public React type surface (props, override keys, hook signatures). Entry points other than the root have their own folders: `dist/types/plugins/SlotLayout/` for `stream-chat-react/slot-layout`, and so on. +2. `node_modules/stream-chat/dist/types/index.d.ts`: the core client types. `Channel` (`channel.d.ts`: `messagePaginator`, `messageOperations`, `configState`, `ensureWatched`), `ChannelManager.d.ts`, `thread.d.ts`, `pagination/paginators/`, `messageOperations/types.d.ts`. +3. `node_modules/stream-chat-react/package.json`: the `exports` map and peer dependencies. + +Rule: before claiming a symbol, prop or method exists, or writing a replacement snippet, grep the installed `.d.ts` and read the current signature. Do not reconstruct it from memory. + +## How to migrate + +Work through the sections in order. They follow the order an app is usually rebuilt in: + +1. [Install and imports](#1-install-and-imports): dependency versions, moved and removed exports. +2. [Client and ``](#2-client-and-chat) +3. [Layout: `Window` is gone, `ChatView` is rebuilt](#3-layout-window-is-gone-chatview-is-rebuilt) +4. [Selecting channels and opening threads](#4-selecting-channels-and-opening-threads-workspace-navigation) +5. [Providing a channel to ``](#5-providing-a-channel-to-channel) +6. [Channel list](#6-channel-list) +7. [Threads](#7-threads) +8. [Channel and thread headers](#8-channel-and-thread-headers) +9. [Reading channel state](#9-reading-channel-state) +10. [Customizing components](#10-customizing-components) +11. [Message lists](#11-message-lists) +12. [Messages](#12-messages) +13. [Message composer](#13-message-composer) +14. [Request handlers](#14-request-handlers) +15. [Configuration](#15-configuration-components-read-the-resolved-config) +16. [Dates](#16-dates-on-response-types-are-unix-nanosecond-numbers) +17. [Other `stream-chat` v10 data shapes](#17-other-stream-chat-v10-data-shapes) +18. [i18n](#18-i18n) +19. [CSS and markup](#19-css-and-markup) +20. [Tests](#20-tests) + +Then run the [silent-break checklist](#breaks-that-do-not-fail-to-compile) once more: those are the changes a green build does not prove. + +## Breaks that do not fail to compile + +These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. + +| Symptom | Cause | Fix | +| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | `await channel.ensureWatched()` before rendering it ([section 5](#channel-no-longer-watches-the-channel)) | +| A filtered or synthetic `messages` array passed to a message list is ignored | `MessageList.messages` / `VirtualizedMessageList.messages` still type-check but are never read | `reviewProcessedMessage`, `hideDeletedMessages` or `renderMessages` ([section 11](#the-messages-prop-is-ignored)) | +| After a failed send, the composer is empty instead of holding the text and attachments | The composer is released before the request and nothing is put back | Nothing to do; the failed message is retried from the list ([section 13](#a-failed-send-or-edit-no-longer-restores-the-composer)) | +| A component that rendered blank outside its provider now throws | Required context hooks throw instead of warning and returning `{}` | Render it inside the provider ([section 10](#required-context-hooks-throw-outside-their-provider)) | +| A standalone `` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | +| The channel is not marked read | Mark-read moved from `Channel` mount to the message list | Render a message list, or mark read yourself ([section 5](#markreadonmount-is-removed-the-list-marks-read)) | +| The tab title no longer shows `(3) Title` | `Channel` no longer writes `document.title` | Write it in app code ([section 5](#channel-no-longer-writes-documenttitle)) | +| Mount effects in a channel's children stop re-running when the channel changes | `Channel` no longer remounts its subtree per channel | Depend on the `channel` instance ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | +| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `ensureWatched()` on open ([section 4](#search-results)) | +| Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | +| CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Retarget ([section 19](#19-css-and-markup)) | +| Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | +| Plain JS: a removed prop or override key (`Message`, `HeaderStartContent`, `do*Request`, `overrideSubmitHandler`, ...) does nothing | Ignored without a warning | Grep for each removed name listed in this guide | +| A supplied composer keeps its content after `MessageComposer` unmounts | A supplied composer is never cleared on unmount | Call `composer.clear()` yourself ([section 13](#preventclearingonunmount-is-removed)) | +| A voice message stops another one playing in a different panel | Playback is exclusive app-wide | Nothing to do ([section 12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time)) | +| The attachment menu offers different actions than before | Actions follow the composer's resolved configuration | Set `customCdn` where it applies ([section 13](#attachment-and-poll-availability)) | +| A custom `MessageTimestamp` no longer shows in the gallery header | The gallery renders its own timestamp | Customize `timestamp.GalleryTimestamp` ([section 12](#the-gallery-header-ignores-the-messagetimestamp-override)) | +| `dayjs(created_at)` renders `Invalid Date` | Server timestamps are unix nanoseconds | Convert with `convertTimestampToDate` ([section 16](#16-dates-on-response-types-are-unix-nanosecond-numbers)) | -> Status: this guide is seeded with the breaking changes confirmed while working on the v15 branch. It is **not yet exhaustive** — additional v15 breaking changes may exist. Always confirm a symbol/prop against the installed source before relying on it. +--- + +## 1. Install and imports + +### Packages + +- **`stream-chat` peer is `^10.0.0-rc.20`.** v9 does not satisfy it, and v10 is itself a breaking client release (see its own migration notes). Install `stream-chat@^10`. +- **`dayjs` and `i18next` are no longer dependencies of this package.** It depends on `@stream-io/i18n`, which brings both (`dayjs ^1.11.23`, `i18next ^26`). `stream-chat` v10 depends on neither. If the app declared them only because of this SDK, remove them. If it keeps `dayjs`, its version must dedupe with `@stream-io/i18n`'s, or `import 'dayjs/locale/xx'` registers the locale on a different instance and dates stay English. +- **`@stream-io/state-store` must resolve to exactly one copy.** `stream-chat`, `@stream-io/i18n` and this package hand each other store instances. `StateStore` has `protected` members, so stores from two copies are not assignable to `useStateStore`'s parameter. Check with `yarn why @stream-io/state-store` / `npm ls @stream-io/state-store`. A linked `stream-chat` checkout needs bundler dedupe. +- **New subpath exports:** `stream-chat-react/slot-layout` (`ChatView` and its slot system) and `stream-chat-react/slot-geometry`. `stream-chat-react/ai-components`, `/channel-detail`, `/emojis` and `/mp3-encoder` already existed. + +```bash +find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly one +``` + +### Imports that moved -## Source of Truth +| Symbol | v14 import | v15 import | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------------------------------------ | +| `ChatView`, `ChatViewContext`, `useChatViewContext`, `useActiveThread`, `ChatViewSelectorButton`, `ChatViewChannelsSelectorButton`, `ChatViewThreadsSelectorButton`, `ChatViewSelectorEntry`, `ChatViewSelectorItem`, `ChatViewSelectorItemProps`, `ChatViewSelectorProps`, `defaultChatViewSelectorItemSet` | `stream-chat-react` | `stream-chat-react/slot-layout` | +| `AIStates` | `stream-chat-react` | `stream-chat` | +| `getAttachmentPreviewUrl`, `isUploadConfirmationPending` | `stream-chat-react` | `stream-chat` (same behavior) | +| `hasPendingUploadState` | `stream-chat-react` | `isPendingUpload` from `stream-chat` | +| `StateStore` | `stream-chat` | `@stream-io/state-store` | -Never rely on training knowledge — the v14 surface is heavily represented in pretraining data and will mislead you. Before writing any code, verify against the user's installed packages: +`stream-chat` v10 no longer exports `StateStore`. The old import still reads as correct but is `undefined` at runtime (`TypeError: StateStore is not a constructor`). -1. `node_modules/stream-chat-react/dist/types/index.d.ts` — public React type surface (props, override keys, hook signatures). -2. `node_modules/stream-chat/dist/types/index.d.ts` — core client types: `Channel` methods (`messagePaginator`, `*WithLocalUpdate`, `messageOperations`), event names, etc. -3. `node_modules/stream-chat-react/package.json` — `exports` map and peer dependencies. +```ts +import { + getAttachmentPreviewUrl, + isPendingUpload, // was hasPendingUploadState + isUploadConfirmationPending, +} from 'stream-chat'; +``` -Rule: before claiming a symbol/prop/method exists or writing a replacement snippet, grep the installed `.d.ts` and read the current signature — do not reconstruct it from memory. +### Exports that were removed + +Each import below is now a compile error (`TS2305` / `TS2724`) and `undefined` in plain JS. The section named in the right column has the replacement. + +| Removed export | Replacement | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `Window`, `WindowProps` | [Section 3](#window-and-windowprops-are-removed) | +| `ChannelStateContext`, `ChannelStateContextValue`, `ChannelStateProvider`, `useChannelStateContext`, `ChannelState`, `ChannelNotifications` | [Section 9](#channelstatecontext-and-channelactioncontext-are-removed) | +| `ChannelActionContext`, `ChannelActionContextValue`, `ChannelActionProvider`, `useChannelActionContext`, `MarkReadWrapperOptions`, `RetrySendMessage` | [Section 9](#channelstatecontext-and-channelactioncontext-are-removed) | +| `TypingContext`, `TypingContextValue`, `TypingProvider`, `useTypingContext` | [Section 9](#typingcontext-is-removed) | +| `ChannelUnreadUiState` | [Section 9](#the-unread-snapshot-channelunreaduistate-is-removed) | +| `useThreadsViewContext`, `ThreadsViewContextValue` | [Section 3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| `getChannel` | [Section 5](#channel-no-longer-watches-the-channel) | +| `useChannelEditMessageHandler` | [Section 14](#14-request-handlers) | +| `useChannelMentionsHandler` | [Section 5](#props-that-moved-off-channel) | +| `ChannelListUI`, `ChannelListUIProps` | [Section 6](#channellistui-is-removed-use-the-list-slots) | +| `usePaginatedChannels`, `useConnectionRecoveredListener`, `CustomQueryChannelParams`, `CustomQueryChannelsFn`, `EffectiveQueryParams` | [Section 6](#channel-list-hooks-types-and-utilities-are-removed) | +| `moveChannelUpwards`, `findLastPinnedChannelIndex`, `extractSortValue`, `isChannelPinned`, `isChannelArchived`, `shouldConsiderPinnedChannels`, `shouldConsiderArchivedChannels` | [Section 6](#channel-list-hooks-types-and-utilities-are-removed) | +| `useLegacyThreadContext` | [Section 7](#thread-takes-a-thread-prop-and-renders-its-children) | +| `MESSAGE_ACTIONS`, `OPTIONAL_MESSAGE_ACTIONS`, `getMessageActions`, `MessageActionsArray`, `Capabilities` | [Section 10](#messageactions-and-message_actions-are-removed) | +| `deTranslations` ... `trTranslations`, `enTranslations`, `isLanguageSupported`, `SupportedTranslations`, `DateFormatterOptions` | [Section 18](#18-i18n) | + +`useMessagePaginator` is not exported from `stream-chat-react` either (it is internal). Read the active list's paginator as `(thread ?? channel).messagePaginator`, with `channel` from `useChannel()` and `thread` from `useThreadContext()`. --- -## The core v15 shift: channel state moved to the low-level client +## 2. Client and `` + +### `useCreateChatClient`: `options` → `config` + +`options` is renamed to `config`, and it takes the `stream-chat` configuration tree (`DeepPartial`), not `StreamChatOptions`. In TypeScript the old key fails (`TS2353`). In plain JS it is ignored and settings such as `baseURL` stop applying. + +```tsx +// v14 +const client = useCreateChatClient({ + apiKey, + options: { baseURL: 'https://chat-proxy.example.com' }, + tokenOrProvider, + userData: { id: userId }, +}); +``` + +```tsx +import { useCreateChatClient } from 'stream-chat-react'; + +export const useClient = (apiKey: string, userId: string, token: string) => + useCreateChatClient({ + apiKey, + config: { client: { baseURL: 'https://chat-proxy.example.com' } }, + tokenOrProvider: token, + userData: { id: userId }, + }); +``` + +- Client settings go under `client`. The keys it accepts are those of `ClientDeclarativeConfig` in the installed `stream-chat`: `allowServerSideConnect`, `axiosRequestConfig`, `baseURL`, `browser`, `persistUserOnConnectionFailure`, `notificationManager`, and the sub-trees `connectionRecovery`, `messageDelivery`, `networkConnection`, `notifications`, `reminders`, `threads`, `wsConnection`. Check the `.d.ts` before using one. +- v14's `StreamChatOptions` was `AxiosRequestConfig & {...}`, so top-level axios fields such as `timeout` now go under `client.axiosRequestConfig`. +- `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `isLocalUnreadCountEnabled`, `logger`, `recoverStateOnReconnect`, `warmUp` and `wsUrlParams` have no `client` key. `notifications` (a `NotificationManager` instance) is now `client.notificationManager`. `wsConnection` is a config object, not a connection instance. +- `userData` is typed `ClientUser`. `config` is read once, on the first render. + +### `useChat` and `Chat.defaultLanguage` + +- `useChat({ client })` returns `{ getAppSettings, mutes }` only. Its `defaultLanguage` and `i18nInstance` params and its `channel`, `setActiveChannel`, `latestMessageDatesByChannels` and `translators` results are removed. +- Translators come from `useStreami18n({ client, i18nInstance })`, which is what `Chat` itself calls. +- `` is removed. Set the language on the `Streami18n` instance you pass as `i18nInstance` (see [`i18n-v15-migration.md`](./i18n-v15-migration.md)). + +```ts +// v14 +const { translators } = useChat({ client, defaultLanguage, i18nInstance }); +``` + +```ts +import type { StreamChat } from 'stream-chat'; +import { type Streami18n, useChat, useStreami18n } from 'stream-chat-react'; -The single largest v15 change: the React SDK no longer owns channel message state in a React reducer or exposes it through the `ChannelStateContext` / `ChannelActionContext`. Message data, pagination, and the write operations now live on the `stream-chat` **`channel` instance**, and the React components read it reactively via `useStateStore`. Everything else in this guide follows from that. +export const useChatParts = (client: StreamChat, i18nInstance?: Streami18n) => { + const { getAppSettings, mutes } = useChat({ client }); + const translators = useStreami18n({ client, i18nInstance }); + return { getAppSettings, mutes, translators }; +}; +``` -### Removed: `ChannelStateContext` / `ChannelActionContext` and their hooks +### `ChatContext` fields -`ChannelStateContext`, `ChannelActionContext`, `useChannelStateContext`, and `useChannelActionContext` are **removed**. Read state and invoke actions off the `channel` instance instead. +| v14 `useChatContext()` field | v15 | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `channel` (the active channel) | Inside the channel subtree: `useChannel()`. Elsewhere: `useWorkspaceNavigation().openChannels`, or `useSlotChannel({ slot })` from `stream-chat-react/slot-layout`. See [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation). | +| `setActiveChannel` | `useWorkspaceNavigation().openChannel(channel, { event })`. See [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation). | +| `channelsQueryState` | The channel list's own query state: `useChannelPaginatorState(paginator)` returns `{ channels, hasNext, hasPrev, isLoading, lastQueryError }`. `ChannelList` reports `lastQueryError` through `client.notifications`. | +| `latestMessageDatesByChannels` | Slow-mode cooldown: `useCooldownRemaining()` or `useStateStore(channel.cooldownTimer.state, selector)`. The channel's latest message: `channel.messagePaginator.aggregateState` (`lastMessage`). When the current user last posted: not available; track it from `message.new` yourself. | +| `mutes` | Unchanged, but typed `UserMuteResponse[]` (was `Mute[]`). | +| `getAppSettings` | Returns `ReturnType`. | -**Reading state (was `useChannelStateContext`):** +`latestMessageDatesByChannels` never held the channel's latest message date: it was filtered to the current user's own messages, to compute slow-mode cooldown, a job `channel.cooldownTimer` now owns. `channelsQueryState` used to drive a loading and error state inside `Channel`; `Channel` renders none now (see [section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)). -| v14 (`useChannelStateContext`) | v15 | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `messages` | `channel.messagePaginator.state.items` (subscribe with `useStateStore(channel.messagePaginator.state, …)`); `channel.state.messages` for the raw channel state | -| `hasMore` (older) | `channel.messagePaginator.hasPrev` | -| `hasMoreNewer` | `channel.messagePaginator.hasNext` | -| `loadingMore` | `channel.messagePaginator.state` (`isLoading`) | -| `read` / `members` / `watchers` / `watcher_count` | `channel.state.read` / `channel.state.members` / `channel.state.watchers` / `channel.state.watcher_count` | +`ChatContextValue` gained a required `channelManager`. Code that builds a value by hand (a custom `ChatProvider` value, a test mock) must set `channelManager: client.channelManager`. `useChatContext` also lost its argument and throws outside `` (see [section 10](#context-hooks-take-no-componentname-argument)). -**Actions (was `useChannelActionContext`):** call the `channel` methods, which perform an optimistic local update and then the request: +### The lost-connection notification -- `sendMessage` → `channel.sendMessageWithLocalUpdate({ localMessage, message, options })` -- `editMessage` → `channel.updateMessageWithLocalUpdate({ localMessage, options })` -- `retrySendMessage` → `channel.retrySendMessageWithLocalUpdate({ localMessage, options })` -- `deleteMessage` → `channel.deleteMessageWithLocalUpdate({ localMessage, options })` -- `loadMore` / `loadMoreNewer` → `channel.messagePaginator.prev()` / `.next()` (and `.toHead()` / `.toTail()`) -- `jumpToMessage` → `channel.messagePaginator.jumpToMessage(id)` +- A WebSocket drop on a working network now shows "Reconnecting..." (key `chat.reportLostConnection.reconnecting.text`) instead of "Waiting for network...", which is still used when the device network is down. +- The display delay applies only to WebSocket drops: the banner waits `client.wsConnection.config.offlineNotificationDisplayDelayMs` (5 s by default) and is dropped if the socket returns within that window. A device-network loss shows immediately. +- `connection.changed` was removed from `stream-chat` v10, and nothing in the SDK listens to it. Use `useWSConnectionState()` / `useNetworkConnectionState()` (new root exports), or the `client.wsConnection.state` / `client.networkConnection.state` stores. +- The `system:network:connection:lost` notification type is unchanged. -These `*WithLocalUpdate` methods delegate to `channel.messageOperations`, which honours request handlers registered through `client.config` (see below). +--- -### `messageActions` / `MESSAGE_ACTIONS` → removed; configure the action set instead +## 3. Layout: `Window` is gone, `ChatView` is rebuilt -The `messageActions` prop on `Thread`, `MessageList`, `VirtualizedMessageList` and `Message` is -**removed**, together with everything behind it: `MESSAGE_ACTIONS`, `OPTIONAL_MESSAGE_ACTIONS`, -`MessageActionsArray`, the `getMessageActions()` util, and the `getMessageActions` and -`actionsEnabled` fields of `MessageContextValue`. +### `Window` and `WindowProps` are removed -It had already stopped working. `getMessageActions` was written into `MessageContext` and read by -nothing -- the `MessageActions` component decides what to show from its `messageActionSet`, filtered -by capabilities and message state in `useBaseMessageActionSetFilter`. Passing -`messageActions={['delete']}` in v15 changed nothing on screen, so removing it takes away a control -that no longer controlled anything. `actionsEnabled` -- `type === 'regular' && status === 'received'` --- goes the same way, and for the same reason: nothing read it. The base filter derives availability -from message state itself, dropping actions on a deleted, bounced or failed message. +There is no replacement component. Render `ChannelHeader` / `MessageList` / `MessageComposer` directly inside ``, and render the thread as a sibling with ``. `.str-chat__channel` (the `Channel` root) is now the flex column `Window` used to provide. Multi-panel layouts come from `ChatView` slots (below), or from your own layout. `SlotGeometryProvider` is not a replacement: it only reports which slots are visually obscured. -To choose the actions, filter or extend the set: +```tsx +// v14 + + + + + + + + +``` ```tsx +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; import { - defaultMessageActionSet, - MessageActions, - WithComponents, + Channel, + ChannelHeader, + MessageComposer, + MessageList, + Thread, + ThreadHeader, } from 'stream-chat-react'; -const ReadOnlyActions = () => ( - type !== 'delete')} - /> +export const ChannelPanel = ({ + channel, + thread, +}: { + channel: StreamChannel; + thread?: StreamThread; +}) => ( + <> + + + + + + {thread && ( + + + + + + )} + +); +``` + +Where the `thread` comes from is [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation). The removed CSS wrappers are in [section 19](#19-css-and-markup). + +### `ChatView` moved to `stream-chat-react/slot-layout` and was rebuilt around slots + +Import `ChatView` and its selector family from `stream-chat-react/slot-layout` (table in [section 1](#imports-that-moved)). The selector pieces keep their props; `ChatView` itself changed shape: + +| v14 | v15 | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `` / `` gated children | `, threads: <...> }} slotRenderers={...}>`. `ChatView.Channels` / `.Threads` are `undefined` at runtime ("Element type is invalid"). | +| `` | `` with the thread's contents as children, or `` with `slotRenderers` | +| `useThreadsViewContext().activeThread` | `useSlotThread({ slot })`, or `useWorkspaceNavigation().openThreads` | +| `useThreadsViewContext().setActiveThread(thread)` | `useWorkspaceNavigation().openThread(thread)` | +| `useChatViewContext().activeChatView` / `.setActiveChatView` | `useChatViewContext().activeView` / `.setActiveView` | +| `useActiveThread({ activeThread })` | Same signature; it now holds the release function returned by `thread.activate()` | +| `ChatView.Selector` | Unchanged | + +`useChatViewContext()` now throws outside `ChatView` (v14 warned and returned `{}`). Children of `ChatView` still render, before the active view. + +```tsx +// v14 + + + + + + {/* ... */} + + + + + + + + + +``` + +v15, channels view, as in `examples/tutorial/src/4-channel-list/App.tsx`. The channel list's paginator must be registered first ([section 6](#channellist-is-driven-by-a-channelpaginator)): + +```tsx +import type { StreamChat } from 'stream-chat'; +import { + Channel, + ChannelHeader, + ChannelNavigation, + Chat, + MessageComposer, + MessageList, + Thread, + ThreadHeader, +} from 'stream-chat-react'; +import { + ChatView, + type ChatViewSlotRenderers, + Slot, +} from 'stream-chat-react/slot-layout'; + +// Module scope: both feed the ChatView layout controller and must be referentially stable. +const layouts = [{ id: 'channels' as const, slots: ['left', 'right'] }]; + +const slotRenderers: ChatViewSlotRenderers = { + channel: ({ source }) => ( + + + + + + ), + thread: ({ source }) => ( + + + + + + ), +}; + +export const App = ({ client }: { client: StreamChat }) => ( + + + + + + + ), + }} + > + + + ); +``` + +A threads view is declared the same way: a `{ id: 'threads', slots: [...] }` layout, and a `threads` entry in `views` rendering `` plus a `ThreadSlot`. `examples/vite/src/ChatLayout/Panels.tsx` (`ThreadsPanels`) is the reference. + +**`ThreadSlot` and `ChannelSlot` wrap their children.** They resolve the entity bound to a slot and hand it to `` / ``, so their `children` are the thread's or channel's contents, not a substitute for the whole panel. Anything that has to sit outside the thread container (a panel shell your layout sizes) moves outside the slot; anything that needs the thread context must sit inside. + +CSS on `.str-chat__chat-view__channels` / `__threads` now matches only when `ChatView` is given `views` ([section 19](#19-css-and-markup)). + +--- + +## 4. Selecting channels and opening threads: workspace navigation + +In v14, `ChatContext.setActiveChannel` selected a channel and `ChannelActionContext.openThread` (set up by `Channel`) opened a thread. Both are removed. Every SDK component that selects or opens something now calls `useWorkspaceNavigation()` (root export): + +- `ChannelListItem` (a channel-list row): `openChannel(channel, { event })`; it shows as active when `isChannelActive(cid)` is true +- channel, message and user search results: `openChannel` +- `ThreadList` rows: `openThread(thread)`; active when `isThreadActive(id)` +- a message's reply action and reply-count button, "also sent in channel": `openThread({ channel, message })` +- `ThreadHeader`'s close button, `useCloseThread()`: `closeThread(threadId)` +- `ChannelHeader` / `ThreadHeader` back and close buttons: `canGoBack`, `goBack`, `isChannelDismissable`, `isThreadDismissable` +- `ChannelListHeader` shows `HeaderEndContent` only while `openChannels` is non-empty + +**Outside `ChatView` the default navigation is an inert no-op**, so all of the above does nothing: clicking a row opens nothing, no row is marked active, and the header buttons do not render. This is the most common silent break of the upgrade. + +The channel-detail "send direct message" member action and the pinned-message entry (from `stream-chat-react/channel-detail`) call `useChatViewNavigation().open` from `stream-chat-react/slot-layout` instead. They work only inside `ChatView`; a custom `WorkspaceNavigationProvider` does not reach them. + +### Inside `ChatView` + +Nothing to wire. `ChatView` provides the navigation and binds what is opened into a layout slot. To customize it (for example, open beside the current channel on ⌘/ctrl-click), pass `deriveWorkspaceNavigation`, which receives the SDK-derived navigation and returns the members to override. It must be referentially stable. + +### Without `ChatView` + +Hold what is open in your own state and provide `WorkspaceNavigationProvider` with an object built from `defaultWorkspaceNavigation`. Override at least `openChannel`, `isChannelActive` and `openChannels`, plus `openThread` / `closeThread` / `openThreads` / `isThreadActive` if you render threads. + +```tsx +// v14 + + + + + + + + + + +``` + +```tsx +import { useEffect, useMemo, useState } from 'react'; +import { + type Channel as StreamChannel, + type ChannelFilters, + ChannelPaginator, + type SortParamRequest, + type StreamChat, + Thread as StreamThread, +} from 'stream-chat'; +import { + Channel, + ChannelList, + ChannelPlaceholder, + Chat, + defaultWorkspaceNavigation, + MessageComposer, + MessageList, + Thread, + ThreadHeader, + type WorkspaceNavigation, + WorkspaceNavigationProvider, +} from 'stream-chat-react'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +export const App = ({ client, userId }: { client: StreamChat; userId: string }) => { + const [activeChannel, setActiveChannel] = useState(); + const [activeThread, setActiveThread] = useState(); + + const paginator = useMemo(() => { + const filters: ChannelFilters = { members: { $in: [userId] }, type: 'messaging' }; + return new ChannelPaginator({ client, filters, id: 'channels:default', sort }); + }, [client, userId]); + + useEffect(() => { + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client, paginator]); + + const navigation = useMemo( + () => ({ + ...defaultWorkspaceNavigation, + closeThread: () => setActiveThread(undefined), + isChannelActive: (cid) => !!cid && cid === activeChannel?.cid, + isThreadActive: (threadId) => !!threadId && threadId === activeThread?.id, + openChannel: (channel) => { + setActiveChannel(channel); + setActiveThread(undefined); + }, + openChannels: activeChannel ? [activeChannel] : [], + openThread: (target) => + setActiveThread( + target instanceof StreamThread + ? target + : client.threads.ensure({ + channel: target.channel, + parentMessage: target.message, + }), + ), + openThreads: activeThread ? [activeThread] : [], + }), + [activeChannel, activeThread, client], + ); + + return ( + + + + {activeChannel ? ( + + + + + ) : ( + + )} + {activeThread && ( + + + + + + )} + + + ); +}; +``` + +- An `openThread` target is either a `Thread` or `{ channel, message }`. `client.threads.ensure({ channel, parentMessage })` turns the latter into a registered `Thread`. +- Instead of a navigation provider, `ChannelListItem` and the search result items take an `onSelect` prop that replaces their selection handler. That covers selection only: thread opening and the header buttons still read the navigation. + +### Opening a channel programmatically + +- `useWorkspaceNavigation().openChannel(channel)`. +- To also add it to the matching channel lists without a re-query: `client.channelManager.ingestChannel(channel)`. +- v14's `setActiveChannel(channel, watchers)` `watchers` argument: query the channel yourself before opening it, for example `channel.ensureWatched({ watchers: { limit: 10 } })`. + +### Search results + +- `stream-chat` v10 queries channel search results with `watch: false`. The SDK's result items watch on open (`ensureWatched()`, or `jumpToMessage(id, { watchChannel: true })` for a message result). **A custom search result component that only opens the channel now opens an unwatched channel**: no events, possibly empty. Call `channel.ensureWatched()` when opening. +- Default result items call `openChannel` and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` and `ChannelListContext.setChannels`. +- Selecting a message result jumps with `channel.messagePaginator.jumpToMessage(...)`. The "jumped here" marker reads `messagePaginator.messageFocusSignal`, not `SearchController._internalState.focusedMessage`. + +### `Message.openThread` and the lists' `openThread` + +- `MessageList.openThread`, `VirtualizedMessageList.openThread` and `VirtuosoContext.openThread` are removed. +- `Message.openThread` remains, typed `(message: LocalMessage, event: BaseSyntheticEvent) => void` (the event is now required: a caller passing no event fails with `TS2554`). Lists no longer drill it, so it applies only to a `` you render yourself, and there only to the reply action. The reply-count button calls `useWorkspaceNavigation().openThread` itself and no longer honors it. + +--- + +## 5. Providing a channel to `` + +`Channel` takes exactly one prop, `channel`. It activates the channel on mount, releases it on unmount, seeds the paginator's unread snapshot and re-queries on `user.deleted`. Everything else it did in v14 moved to the code that chooses the channel. - +### `Channel` requires `channel` and renders no placeholder, loading or error state + +- `ChannelProps.channel` is required (`TS2741` without it). `Channel` no longer picks the channel up from `ChatContext`; in plain JS a missing channel crashes (`channel.activate` of `undefined`). +- `EmptyPlaceholder` is removed, and `Channel` no longer renders `EmptyStateIndicator` (`listType='message'`) when nothing is selected, nor `LoadingIndicator` / `LoadingErrorIndicator`. `ComponentContext.LoadingErrorIndicator` is still declared but read by no SDK component. +- Guard at the call site, and render the new `ChannelPlaceholder` (the same `.str-chat__channel` column, `children` allowed) while nothing is selected. Render loading and error UI yourself, around `Channel`. + +```tsx +// v14 +}> + + + +``` + +```tsx +import type { Channel as StreamChannel } from 'stream-chat'; +import { + Channel, + ChannelPlaceholder, + MessageComposer, + MessageList, +} from 'stream-chat-react'; + +const NoChannelSelected = () =>

Pick a conversation

; + +export const Main = ({ activeChannel }: { activeChannel?: StreamChannel }) => + activeChannel ? ( + + + + + ) : ( + + + + ); +``` + +### `Channel` no longer watches the channel + +`Channel` used to call `channel.watch()` on mount. That call, `initializeOnMount`, `channelQueryOptions` and the `getChannel` util are removed. **An uninitialized channel passed to `Channel` renders as an empty channel, with no error, and receives no events.** If a channel renders blank, check `channel.initialized` first. + +- **Channels from `ChannelList` or any `queryChannels` call arrive watched.** Nothing changes for them. +- **A channel you create and hand to `Channel`**: get the instance from `client.channelManager.ensure({ type, id, data })` and `await channel.ensureWatched(options)` before rendering it. `ensureWatched()` sends nothing for a channel already watched and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. `channel.watch()` always sends its request. `ensureWatched()` resolves with the channel. +- **`channelQueryOptions`** → pass them to `ensureWatched(options)` (`ChannelGetOrCreateRequest`: `messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...). +- **`getChannel({ client, id, type })`** → `await client.channelManager.ensure({ id, type }).ensureWatched()`. + +```tsx +// v14 +const channel = client.channel('messaging', { members: [userId, otherUserId] }); + + + {/* ... */} +; +``` + +```tsx +import type { StreamChat } from 'stream-chat'; + +export const openDirectMessage = ( + client: StreamChat, + userId: string, + otherUserId: string, +) => + client.channelManager + .ensure({ + data: { members: [{ user_id: userId }, { user_id: otherUserId }] }, + type: 'messaging', + }) + .ensureWatched({ messages: { limit: 30 } }); + +// then: +``` + +`data.members` is `ChannelMemberRequest[]` (`{ user_id }` objects) in `stream-chat` v10, not an id list. + +```ts +// v14 +const channel = await getChannel({ client, id: 'general', type: 'messaging' }); +``` + +```ts +import type { StreamChat } from 'stream-chat'; + +export const getGeneral = (client: StreamChat) => + client.channelManager.ensure({ id: 'general', type: 'messaging' }).ensureWatched(); +``` + +`examples/vite` and the tutorial steps show the full flow. + +### Channel lifecycle: activation, no remount, no reload, no search jump + +These keep the same signature and change behavior: + +- **No remount per channel.** `Channel` no longer keys its subtree on `channel.cid`. Switching the `channel` prop keeps children mounted. A custom child that relied on remounting to reset local state or re-run a mount effect must depend on the `channel` instance instead (not on `channel.cid`). +- **Activation.** `Channel` calls `channel.activate()` on mount and the returned release on unmount (refcounted, so several consumers can hold one instance). While a channel is active, the client skips re-seeding its message list on channel-list hydration, which would perturb a larger scrolled-back window. A custom channel surface that does not render `Channel` must activate the channel itself, or the client re-seeds its list and `client.channelManager.releaseUnusedChannels()` may drop it. +- **No reload on reconnect.** `Channel` no longer calls `channel.reload()` on `connection.recovered`. The client reloads active channels itself after a reconnect, so a custom surface needs only the activation: + + ```ts + import { useEffect } from 'react'; + import type { Channel } from 'stream-chat'; + + export const useActiveChannel = (channel: Channel) => { + useEffect(() => channel.activate(), [channel]); + }; + ``` + +- **No search jump.** `Channel` no longer scrolls to a message focused from search (`SearchController._internalState.focusedMessage`). Call `channel.messagePaginator.jumpToMessage(id)` where the message is chosen (`thread.messagePaginator` for a thread reply); `MessageSearchResultItem` does. Pass `{ watchChannel: true }` to watch an unwatched channel with the request that loads the window. A jump restored from elsewhere, such as URL parameters on page load, is the same call: the focus signal waits until a list renders it. +- `Channel` no longer provides `ChannelStateContext`, `ChannelActionContext` or `TypingContext` ([section 9](#9-reading-channel-state)). + +### `Channel` no longer writes `document.title` + +`Channel` set `document.title` to `(3) Your app` when a message arrived while the tab was hidden, and `activeUnreadHandler` intercepted that. Both are removed, with no SDK replacement. Write the title in application code; `examples/vite/src/DocumentTitleManager` is a complete implementation (a component inside `Chat` that subscribes to the unread counts and restores the previous title on unmount). + +When writing your own: the counts are user-scoped (the unread message total from the connection's `me` payload and `total_unread_count` on events; the unread thread count from `client.threads`), and they are different units, messages versus threads, so do not add them. The v14 behavior counted only the open channel and never restored the title. + +### `markReadOnMount` is removed; the list marks read + +`Channel` no longer marks the channel read when it mounts. `MessageList` / `VirtualizedMessageList` mark it read (`useMarkRead`) while the list is at the bottom, showing the live head, the tab is visible and the channel is not flagged unread. **A `Channel` rendered without a message list is never marked read**; call `channel.markRead()` yourself there. `markReadOnMount={false}` has no direct equivalent. To change how read requests go out, register a `markReadRequest` handler ([section 14](#14-request-handlers)). + +### Props that moved off `Channel` + +| Removed `ChannelProps` | v15 | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `giphyVersion`, `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail` | The same props on `Attachment`, applied app-wide through an `Attachment` override (below) | +| `onMentionsClick`, `onMentionsHover` (and `useChannelMentionsHandler`) | The same props on `MessageList` or `Message` | +| `doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest`, `doMarkReadRequest` | Handlers on `client.config` ([section 14](#14-request-handlers)) | +| `skipMessageDataMemoization` | None; it tuned the removed React channel reducer | +| `allowConcurrentAudioPlayback` | None ([section 12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time)) | +| `activeUnreadHandler`, `markReadOnMount`, `initializeOnMount`, `channelQueryOptions`, `EmptyPlaceholder` | Sections above | + +**Attachment props.** `Attachment` builds its own `AttachmentContextProvider` from its props and the defaults, so an outer `AttachmentContextProvider` does not reach the default attachments. Set them through the `Attachment` slot: + +```tsx +// v14 + {/* ... */} + +``` + +```tsx +import type { Channel as StreamChannel } from 'stream-chat'; +import { + Attachment, + type AttachmentProps, + Channel, + MessageList, + WithComponents, +} from 'stream-chat-react'; + +const CustomAttachment = (props: AttachmentProps) => ( + +); + +export const ChannelWithAttachments = ({ channel }: { channel: StreamChannel }) => ( + + + + + +); +``` + +Inside an attachment component, read them with `useAttachmentContext()`. + +**Mention handlers.** The handler type is `(event, mentioned_users, message)`; the second argument is `@deprecated`, read the metadata from `message`. `VirtualizedMessageList` has no such prop (v14 virtualized lists got the handlers only from `Channel`), so a virtualized list needs a custom message UI to handle mention clicks. + +```tsx +// v14 + + + +``` + +```tsx + + + +``` + +--- + +## 6. Channel list + +### `ChannelList` is driven by a `ChannelPaginator` + +`ChannelList` props are now `{ paginator: ChannelPaginator; loadMoreDebounceMs?; loadMoreThresholdPx? }`. Every query, event and render prop is removed, and `ChannelList` no longer renders the list header, the search bar or the channel-list `NotificationList`. + +| Removed `ChannelListProps` | v15 | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `filters`, `sort`, `options`, `watchers`, `customQueryChannels`, `recoveryThrottleIntervalMs` | `new ChannelPaginator({ client, filters, sort, id, requestOptions?, paginatorOptions?, sortComparatorFactory? })` from `stream-chat`, registered with `client.channelManager.insertPaginator({ paginator })` so it receives WebSocket updates. `sort` is `SortParamRequest[]` (`[{ field, direction }]`). | +| `onAddedToChannel`, `onChannelDeleted`, `onChannelHidden`, `onChannelTruncated`, `onChannelUpdated`, `onChannelVisible`, `onMessageNew`, `onMessageNewHandler`, `onRemovedFromChannel`, `allowNewMessagesFromUnfilteredChannels`, `lockChannelOrder` | The `ChannelManager` (`client.channelManager.addEventHandler(...)`, its ownership resolver) and the paginator's `sortComparatorFactory`, in `stream-chat` | +| `customActiveChannel`, `setActiveChannelOnMount` | Open a channel through workspace navigation ([section 4](#opening-a-channel-programmatically)) | +| `showChannelSearch` | `` renders the header, search and every registered list. Search is always shown; hide it with a `Search: () => null` override. | +| `EmptyStateIndicator`, `Paginator`, `renderChannels`, `channelRenderFilterFn`, `sendChannelsToList` | The list slots ([below](#channellistui-is-removed-use-the-list-slots)) | +| `getLatestMessagePreview` | [Below](#channellistitem-props) | + +```tsx +// v14 +const filters: ChannelFilters = { members: { $in: [userId] }, type: 'messaging' }; +const sort: ChannelSort = { last_message_at: -1 }; +const options: ChannelOptions = { limit: 10 }; + +; +``` + +```tsx +import { useEffect } from 'react'; +import { + type ChannelFilters, + ChannelPaginator, + type SortParamRequest, + type StreamChat, +} from 'stream-chat'; +import { ChannelNavigation } from 'stream-chat-react'; + +const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }]; + +export const Sidebar = ({ client, userId }: { client: StreamChat; userId: string }) => { + useEffect(() => { + const filters: ChannelFilters = { members: { $in: [userId] }, type: 'messaging' }; + const paginator = new ChannelPaginator({ + client, + filters, + id: 'channels:default', + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client, userId]); + + // Header, search and one ChannelList per registered paginator. + return ; +}; +``` + +A single list can still be rendered on its own with ``; it has no header or search. Use `` for all registered lists without the header. Page size and other request options map onto `ChannelPaginator`'s `requestOptions` / `paginatorOptions`; check the installed `stream-chat` for their fields. + +### `ChannelListUI` is removed; use the list slots + +`ChannelListUI`, `ChannelListUIProps` and the `ComponentContext.ChannelListUI` slot are removed. `ChannelList` renders an `InfiniteScrollPaginator` and reads these `ComponentContext` slots instead (only `ChannelList` reads them today): + +| Slot | Props | Default | +| --------------------------- | ----------------------- | ------------------------- | +| `EmptyListIndicator` | none | the empty state | +| `EndReachedIndicator` | `{ hasEnded, reached }` | | +| `FirstPageLoadingIndicator` | none | `LoadingChannels` | +| `ListItem` | `{ item }` | renders `ChannelListItem` | +| `LoadingNextPageIndicator` | `{ isLoading? }` | | + +`ComponentContext.LoadingIndicator` / `LoadingErrorIndicator` no longer affect the channel list, and query errors are reported as a notification. For a fully custom list, read `useChannelPaginatorState(paginator)` and render your own. + +```tsx +// v14 +const CustomListUI = ({ children, loading }: PropsWithChildren) => + loading ? :
{children}
; + + + ; ``` -- **Action names** are now `MessageActionSetItem['type']`. The ones the default set ships are listed - in the exported `DefaultMessageActionType`, and a custom action may use any string. -- **Permissions and message state** are applied for you by the base filter -- an action the user - cannot perform, or that a thread reply does not support, is dropped whether or not you list it. - Pass `disableBaseMessageActionSetFilter` to take that over. -- **Adding an action** means adding an item with a `Component` and a `placement` of `quick`, - `dropdown` or `quick-dropdown-toggle`. +```tsx +import type { Channel } from 'stream-chat'; +import { ChannelListItem, ChannelNavigation, WithComponents } from 'stream-chat-react'; + +const Spinner = () =>
; + +export const Sidebar = () => ( +

No conversations

, + FirstPageLoadingIndicator: Spinner, + ListItem: ({ item }) => ( + + ), + }} + > + +
+); +``` + +### Channel list hooks, types and utilities are removed + +- **`usePaginatedChannels`** and the `CustomQueryChannelParams` / `CustomQueryChannelsFn` / `EffectiveQueryParams` types: the `ChannelPaginator` owns querying, pagination, reconnect recovery and the effective query (`paginator.effectiveFilters`, `paginator.effectiveSort`). Read it with `useChannelPaginatorState(paginator)`. Custom query functions: configure or subclass `ChannelPaginator` in `stream-chat`. +- **`useConnectionRecoveredListener(forceUpdate)`** only wrapped `client.on('connection.recovered', ...)`, which still fires in v10 (after the lists are re-queried and active channels reloaded). Use `const { unsubscribe } = client.on('connection.recovered', handler)`, or `useWSConnectionState()` for the raw socket status. +- **`moveChannelUpwards`, `findLastPinnedChannelIndex`, `extractSortValue`, `isChannelPinned`, `isChannelArchived`, `shouldConsiderPinnedChannels`, `shouldConsiderArchivedChannels`**: no export replaces them. Ordering is the paginator's comparator (`sortComparatorFactory`). Pinned and archived state is on the membership: `useChannelMembershipState(channel).pinned_at` / `.archived_at` (or `channel.state.membership`). + +### `ChannelListContext` lost `channels`, `hasNextPage`, `loadNextPage` and `setChannels` + +The context now carries `paginator` (optional; the primary registered `ChannelPaginator`) and is provided by `ChannelLists` (so by `ChannelNavigation`), not by a standalone `ChannelList`. + +| v14 | v15 | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `channels` | `useChannelPaginatorState(paginator).channels` | +| `hasNextPage` | `useChannelPaginatorState(paginator).hasNext` | +| `loadNextPage()` | `paginator.toTail()` (`next()` is `@deprecated` in `stream-chat` v10) | +| `setChannels` | `paginator.setItems({ valueOrFactory })`; to add a channel to the matching lists, `client.channelManager.ingestChannel(channel)` | + +```tsx +import type { ChannelPaginator } from 'stream-chat'; +import { useChannelListContext, useChannelPaginatorState } from 'stream-chat-react'; + +const Count = ({ paginator }: { paginator: ChannelPaginator }) => { + const { channels } = useChannelPaginatorState(paginator); + return {channels?.length ?? 0}; +}; + +// Rendered inside ChannelNavigation / ChannelLists. +export const ChannelCount = () => { + const { paginator } = useChannelListContext(); + return paginator ? : null; +}; +``` + +### `ChannelListItem` props + +| v14 | v15 | +| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lastMessage` (on `ChannelListItemUIProps` and `ChannelListItemTimestamp`) | `previewedMessage`. `ChannelListItem` also takes `previewedMessage` to preview a specific message (a search result previewing the match); it defaults to `channel.messagePaginator.aggregateState.lastMessage`. | +| `latestMessagePreview` (`ChannelListItemUIProps`), `getLatestMessagePreview` (`ChannelListItem` and `ChannelList` props) | Render ``, or override the `SummarizedMessagePreview` slot with `WithComponents` (`SummarizedMessagePreviewProps`: `{ latestMessage, messageDeliveryStatus, participantCount }`). | +| `setActiveChannel`, `watchers` | `useWorkspaceNavigation().openChannel(channel, { event })`, or the `onSelect` prop ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| `channelUpdateCount`, `key` | None; the item subscribes to the channel's stores | +| `active` | Unchanged prop; it now defaults to `useWorkspaceNavigation().isChannelActive(cid)` | + +The `getLatestMessagePreview` and `getLatestMessagePreviewText` utilities are still exported; only their `t` / `userLanguage` parameter types changed. Prefer `WithComponents` for the slot override: it merges over the parent context, while `ComponentProvider` replaces it. Neither `Chat` nor `Channel` takes component props. + +The preview now honors the channel's `skip_last_msg_update_for_system_msgs` config (a system message does not become the previewed message), so preview and sort position agree. + +```tsx +// v14 +const CustomItem = ({ + channel, + displayTitle, + latestMessagePreview, + setActiveChannel, +}: ChannelListItemUIProps) => ( + +); +``` + +```tsx +import { + type ChannelListItemUIProps, + SummarizedMessagePreview, + useWorkspaceNavigation, +} from 'stream-chat-react'; + +export const CustomItem = ({ + channel, + displayTitle, + messageDeliveryStatus, + previewedMessage, +}: ChannelListItemUIProps) => { + const { openChannel } = useWorkspaceNavigation(); + return ( + + ); +}; +``` + +--- + +## 7. Threads + +### `Thread` takes a `thread` prop and renders its children + +`Thread` no longer reads the thread from context or renders a header, list and composer of its own. It takes the `stream-chat` `Thread` instance as a required `thread` prop, provides it to its subtree, loads it, scopes audio playback, and renders the children you compose. It does **not** register the thread with the `ThreadManager`: `client.threads.ensure()` or `thread.activate()` does that. + +- `` without `thread` is a compile error (`TS2741`). In plain JS a `` with no children renders an empty container. +- `useLegacyThreadContext` is removed. Threads are `stream-chat` `Thread` instances only; there is no message-based "legacy" thread. +- `Thread` no longer remounts its subtree per thread, and renders nothing when the channel has replies disabled or the parent message is missing. +- Get the instance from `client.threads.ensure({ channel, parentMessage })` / `client.threads.get(...)`, from a `ThreadSlot`, or from workspace navigation. + +| Removed `ThreadProps` | v15 | +| --------------------------------------- | ----------------------------------------------------------------------------------- | +| `additionalMessageComposerProps` | Props on your `MessageComposer` child | +| `additionalMessageListProps` | Props on your `MessageList` child | +| `additionalVirtualizedMessageListProps` | Props on your `VirtualizedMessageList` child | +| `additionalParentMessageProps` | A `ThreadHead` override on `ComponentContext` (typed `ComponentType`) | +| `autoFocus` | `` | +| `enableDateSeparator` | `withDateSeparator` on the list | +| `Message` | A `MessageUI` override scoped with `WithComponents` | +| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | +| `virtualized` | Render the list you want | + +**Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). The composer is focused only if you pass `focus`. The parent message is rendered by the message list, inside the scroll container; both lists resolve it from the thread context. + +```tsx +// v14 + +``` + +```tsx +import type { Thread as StreamThread } from 'stream-chat'; +import { MessageComposer, MessageList, Thread, ThreadHeader } from 'stream-chat-react'; + +export const ThreadPanel = ({ + onUserClick, + thread, +}: { + onUserClick: () => void; + thread: StreamThread; +}) => ( + + + + + +); +``` + +### `ThreadHeader` lost `closeThread` and `thread` + +- `ThreadHeaderProps` is now `{ overrideTitle?, StartContent?, EndContent? }` ([section 8](#8-channel-and-thread-headers)). The `closeThread` and `thread` props are removed (`TS2339`). +- `ComponentContext.ThreadHeader` is removed: `Thread` renders no header, so the override is ignored. Render your header as a child of ``. +- Read the thread with `useThreadContext()` and its parent message from `thread.state`. Close it with the new `useCloseThread()` hook, or `useWorkspaceNavigation().closeThread(thread.id)`. Both close through workspace navigation, so they do nothing outside `ChatView` or a `WorkspaceNavigationProvider`. +- `useCloseThread()` returns `(options?: WorkspaceNavigationOptions) => void`, not a click handler: pass the event as `{ event }`. + +```tsx +// v14 +const CustomThreadHeader = ({ closeThread, thread }: ThreadHeaderProps) => ( +
+ {thread.user?.name} + +
+); + + + +; +``` + +```tsx +import type { Thread as StreamThread, ThreadState } from 'stream-chat'; +import { + MessageComposer, + MessageList, + Thread, + useCloseThread, + useStateStore, + useThreadContext, +} from 'stream-chat-react'; + +const parentSelector = ({ parentMessage }: ThreadState) => ({ parentMessage }); + +const CustomThreadHeader = () => { + const thread = useThreadContext(); + const { parentMessage } = useStateStore(thread?.state, parentSelector) ?? {}; + const closeThread = useCloseThread(); + return ( +
+ {parentMessage?.user?.name} + +
+ ); +}; + +export const ThreadPanel = ({ thread }: { thread: StreamThread }) => ( + + + + + +); +``` + +### `ThreadProvider` no longer wraps its children in a `Channel` + +v14's `` rendered `` around its children, so it brought the `.str-chat__channel` container and the channel contexts. v15 provides only the thread context. Thread-scoped UI rendered with `ThreadProvider` alone loses the channel container and `ChannelInstanceContext` (`useChannel()` still resolves the thread's channel). + +- Do not wrap `` in `ThreadProvider`; `Thread` provides the thread. +- Keep `ThreadProvider` only for thread-scoped UI outside a `Thread`, and add `` yourself if that UI needs the channel subtree. + +### `ThreadList` + +- Mounting `ThreadList` calls `client.threads.reload({ force: true })` every time. v14 reloaded only when the manager was not ready, had unseen threads or a stale order. +- Clicking a row calls `useWorkspaceNavigation().openThread(thread)`, and a row is active when `isThreadActive(id)` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)). v14 used `ThreadsViewContext`. +- The list reads `client.threads.paginator.state`. Code reading `client.threads.state.threads` / `.pagination` breaks for `stream-chat` v10 reasons; read the paginator. + +--- + +## 8. Channel and thread headers + +`ComponentContext.HeaderStartContent` is removed (`TS2561`; in JS the custom content, often a sidebar toggle, silently disappears). `ChannelHeader` and `ThreadHeader` take the content as props: + +| Prop | Default | Shows | +| -------------- | -------------------------------- | ------------------------------------------------------------------------------------------ | +| `StartContent` | `WorkspaceNavigationBackButton` | Back, when the channel or thread is stacked over other content in its panel | +| `EndContent` | `WorkspaceNavigationCloseButton` | Close, for a dismissable panel: a channel opened beside the primary one, or a thread panel | + +- The v14 thread close button is now `ThreadHeader`'s `EndContent` default. It still renders as `str-chat__close-thread-button`, only for threads the navigation reports as dismissable. `ChannelHeader` gains the same pair (`str-chat__close-channel-button`, `str-chat__channel-header__back-button`). +- The buttons act on the panel they render in. A layout that renders its own panels declares them with `WorkspacePanelProvider`. Without a workspace navigation (outside `ChatView`, with no provider), nothing can be dismissed, so they render nothing. +- Passing a component replaces the default button. Render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` inside yours to keep it. +- `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader`, but `ChannelListHeader` shows it only while a channel is open in the workspace. + +```tsx +// v14 + + + + + +``` + +```tsx +import type { Channel as StreamChannel } from 'stream-chat'; +import { Channel, ChannelHeader, WorkspaceNavigationBackButton } from 'stream-chat-react'; + +// `SidebarToggle` stands for a control of your own. +const SidebarToggle = () => ; + +const HeaderStart = () => ( + <> + + + +); + +export const ChannelPanel = ({ channel }: { channel: StreamChannel }) => ( + + + +); +``` + +--- + +## 9. Reading channel state + +`stream-chat` v10 owns message, thread and composer state; the React SDK subscribes to it. There is no React reducer and no context that copies channel state. Read the channel with `useChannel()` (thread's channel first, then the `Channel` subtree's; throws outside both) or `useChannelInstanceContext()` (returns `{}` when there is no channel), then read or act on the instance. Subscribe with `useStateStore(store, selector)`, with the selector defined at module scope. + +### `ChannelStateContext` and `ChannelActionContext` are removed + +`ChannelStateContext`, `ChannelActionContext`, their providers, `useChannelStateContext`, `useChannelActionContext`, and the `ChannelState`, `ChannelNotifications`, `MarkReadWrapperOptions` and `RetrySendMessage` types are removed. + +The message paginator has two ends: the **tail** is the oldest loaded message, the **head** the newest. `toTail()` loads older messages and `toHead()` newer ones. `next()` / `prev()` and `hasNext` / `hasPrev` are deprecated aliases in `stream-chat` v10; do not use them. + +**Reading state** (was `useChannelStateContext`): + +| v14 | v15 | +| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `channel` | `useChannel()` | +| `messages` | `channel.messagePaginator.state` → `items` | +| `hasMore` (older) | `messagePaginator.state` → `hasMoreTail` (also the getter `messagePaginator.hasMoreTail`) | +| `hasMoreNewer` | `messagePaginator.state` → `hasMoreHead` (getter `messagePaginator.hasMoreHead`) | +| `loading`, `loadingMore`, `loadingMoreNewer` | `messagePaginator.state` → `isLoading` | +| `error` | `messagePaginator.state` → `lastQueryError` | +| `members`, `read`, `watchers`, `watcherCount` / `watcher_count` | `useStateStore(channel.state, selector)` (keys `members`, `read`, `watchers`, `watcherCount`), or the non-reactive getters `channel.state.members` / `.read` / `.watchers` / `.watcher_count` | +| `pinnedMessages` | `channel.pinnedMessagesPaginator.state` | +| `thread`, `threadMessages`, `threadHasMore`, `threadLoadingMore` | `useThreadContext()` (the `Thread` instance) and `thread.messagePaginator` | +| `channelCapabilities` | `useChannelCapabilities({ cid })` (a `Set`), or `ownCapabilities` from `channel.state` | +| `channelConfig` | Resolved gates: `channel.configState` ([below](#channel-configuration-channelconfig--channelconfigstate)). The raw server flags: `channel.serverConfig` (a non-reactive getter, `undefined` until queried). | +| `imageAttachmentSizeHandler`, `videoAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, `giphyVersion` | `useAttachmentContext()` ([section 5](#props-that-moved-off-channel)) | +| `channelUnreadUiState` | `channel.messagePaginator.unreadStateSnapshot` ([below](#the-unread-snapshot-channelunreaduistate-is-removed)) | +| `mutes` | `useChatContext().mutes` | +| `notifications` | None; notifications go through `client.notifications` | +| `suppressAutoscroll`, `threadSuppressAutoscroll`, `highlightedMessageId`, `loadingMoreForJumpToChannelMessage` | None; internal to the message lists. The highlight follows `messagePaginator.messageFocusSignal`. | + +**Actions** (was `useChannelActionContext`): + +| v14 | v15 | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `loadMore` (older) / `loadMoreNewer` | `channel.messagePaginator.toTail()` / `.toHead()`. `MessageList` uses exactly these: `toTail` on scroll to the top, `toHead` on scroll to the bottom. | +| `loadMoreThread` | `thread.messagePaginator.toTail()` | +| `jumpToMessage(id)` / `jumpToLatestMessage()` / `jumpToFirstUnreadMessage()` | `messagePaginator.jumpToMessage(id)` / `.jumpToTheLatestMessage()` / `.jumpToTheFirstUnreadMessage()` | +| `sendMessage` | `messageComposer.send()`, or `useMessageComposerSubmitFn()` in a composer UI. Lower level: `channel.messageOperations.send({ localMessage, message, options })` | +| `retrySendMessage` | `channel.messageOperations.retry({ localMessage })`, or `handleRetry` from `useMessageContext()` | +| `editMessage` | `messageComposer.update()`, or `channel.messageOperations.update({ localMessage, options })` | +| `deleteMessage` | `channel.messageOperations.delete({ localMessage, options })`, or `handleDelete` from `useMessageContext()` | +| `updateMessage` / `removeMessage` (local-only list writes) | `messagePaginator.ingestItem(message)` / `.removeItem({ id })`. Normally unnecessary: the client applies WebSocket events itself. | +| `markRead` | `channel.markRead()`. The unread UI snapshot is separate state on the paginator; there is no `updateChannelUiUnreadState` option. | +| `setChannelUnreadUiState` | `channel.messagePaginator.setUnreadSnapshot(partial)` | +| `openThread` / `closeThread` | `useWorkspaceNavigation().openThread({ channel, message })` / `.closeThread(threadId)`; inside a thread, `useCloseThread()` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| `onMentionsClick` / `onMentionsHover` | The props on `MessageList` / `Message` | +| `dispatch` | None; there is no React reducer | + +`channel.messageOperations` and `thread.messageOperations` perform the optimistic local update and then the request, honoring the request handlers registered on `client.config` ([section 14](#14-request-handlers)). For a thread reply, use `thread.messageOperations` / `thread.messagePaginator`: thread replies are an independent paginator, not part of the channel's list. + +```tsx +// v14 +export const LoadOlder = () => { + const { channel, hasMore, messages } = useChannelStateContext(); + const { loadMore } = useChannelActionContext(); + + if (!hasMore) return null; + + return ( + + ); +}; +``` + +```tsx +import type { MessagePaginatorState } from 'stream-chat'; +import { useChannel, useStateStore } from 'stream-chat-react'; + +const paginatorSelector = (state: MessagePaginatorState) => ({ + hasMoreOlder: state.hasMoreTail, + messages: state.items, +}); + +export const LoadOlder = () => { + const channel = useChannel(); + const { hasMoreOlder, messages } = useStateStore( + channel.messagePaginator.state, + paginatorSelector, + ); + + if (!hasMoreOlder) return null; + + return ( + + ); +}; +``` + +### `TypingContext` is removed + +`TypingContext`, `TypingContextValue`, `TypingProvider` and `useTypingContext` are removed. Subscribe to the channel's own store: `typing` is `Record` keyed by user id. The non-reactive snapshot is `channel.state.typing`. The SDK's own `TypingIndicator` reads the composer's `textComposer.state.typing` and filters by `parent_id` for threads. + +```tsx +// v14 +export const TypingCount = () => { + const { typing } = useTypingContext(); + return {Object.keys(typing ?? {}).length} typing; +}; +``` + +```tsx +import type { ChannelStateData } from 'stream-chat'; +import { useChannel, useStateStore } from 'stream-chat-react'; + +const typingSelector = ({ typing }: ChannelStateData) => ({ typing }); + +export const TypingCount = () => { + const channel = useChannel(); + const { typing } = useStateStore(channel.state, typingSelector); + return {Object.keys(typing).length} typing; +}; +``` + +For a thread-scoped indicator, compare each event's `parent_id` with the thread's parent message (`useStateStore(useThreadContext()?.state, selector)`). The `typing_events` gate is `typingEvents.enabled` on `channel.configState` (below). + +### The unread snapshot: `ChannelUnreadUiState` is removed + +The unread UI state (v14's `channelUnreadUiState` / `setChannelUnreadUiState`) lives on the message paginator, with a camel-cased shape: + +| v14 `ChannelUnreadUiState` | v15 `UnreadSnapshotState` (from `stream-chat`) | +| -------------------------- | ---------------------------------------------- | +| `last_read` | `lastReadAt: TimestampNS \| null` | +| `last_read_message_id` | `lastReadMessageId: string \| null` | +| `unread_messages` | `unreadCount: number` | +| `first_unread_message_id` | `firstUnreadMessageId: string \| null` | + +Read it with `useStateStore(channel.messagePaginator.unreadStateSnapshot, selector)` and write it with `channel.messagePaginator.setUnreadSnapshot(partial)`. + +### Channel configuration: `channelConfig` → `channel.configState` + +In v14 a component read the channel type's raw server flags from `ChannelStateContext.channelConfig` (`ChannelConfigWithInfo`). Configuration now lives on the channel as a reactive store of **resolved** values: each gate is the server flag ANDed with what the integrator registered through `client.config`. + +```ts +// v14 +const { channelConfig } = useChannelStateContext(); +if (channelConfig?.typing_events) { + /* ... */ +} +``` + +```tsx +import type { ChannelConfig } from 'stream-chat'; +import { useChannel, useStateStore } from 'stream-chat-react'; + +const configStateSelector = ({ typingEvents }: ChannelConfig) => ({ + typingEventsEnabled: typingEvents.enabled, +}); + +export const useTypingEventsEnabled = () => { + const channel = useChannel(); + return useStateStore(channel.configState, configStateSelector).typingEventsEnabled; +}; +``` + +| v14 (raw server flag) | v15 (resolved, on `channel.configState`) | +| --------------------------------------- | ---------------------------------------- | +| `channelConfig?.typing_events` | `typingEvents.enabled` | +| `channelConfig?.read_events` | `readEvents.enabled` | +| `channelConfig?.replies` | `replies.enabled` | +| `channelConfig?.user_message_reminders` | `userMessageReminders.enabled` | +| `channelConfig?.commands` | `availableCommands` | + +- `availableCommands` is the server's list, unchanged in shape. Whether a command is usable right now is `messageComposer.isCommandDisabled(command)`. +- Select only the settings you use; a narrow selector means an unrelated configuration change does not re-render you. +- `channel.config` holds the same resolved value as a plain getter. It does not subscribe, so a later `client.config.set()` will not reach the screen. +- Fields of `ChannelConfigWithInfo` with no client-side counterpart are not on the resolved configuration. Read them from `channel.serverConfig`, which is the server's half only. + +### `stream-chat` v10 store changes you will meet here + +These are `stream-chat` changes, documented in its own migration notes. They are listed because React code that read channel state hits them immediately. + +- **`channel.getConfig()` → `channel.serverConfig`**, a getter returning the same value (drop the parentheses). In tests, a getter cannot be replaced with `vi.fn()`; use `Object.defineProperty(channel, 'serverConfig', { get: ... })`. +- **One store per channel.** `readStore`, `typingStore`, `membersStore`, `watcherStore`, `ownCapabilitiesStore` and `mutedUsersStore` are gone. `channel.state` is itself a `StateStore`, subscribed to like `thread.state`. The selector does not change: delete the `.Store` segment. + + ```ts + // v14 + useStateStore(channel.state.ownCapabilitiesStore, ownCapabilitiesSelector); + // v15 + useStateStore(channel.state, ownCapabilitiesSelector); + ``` + +- The state gained channel-level slices UI code used to derive by hand: `data`, `membership`, `muteStatus`, `initialized` / `offlineMode` / `pendingDisposal`, `active`, `aiState`, `watchStatus`, `memberCount`. `messagePaginator`, `pinnedMessagesPaginator`, `messageComposer` and `configState` stay separate. +- **`WatcherState` → `ChannelWatchState`.** **`channel.disconnected` → `channel.pendingDisposal`**, read-only and terminal; `channel.disconnect()` sets it. `getClient()` throws on such a channel, so guard `reload()` and other requests on `pendingDisposal`. +- **The convenience getters are not reactive.** `channel.state.members` / `.read` / `.typing` / `.watchers` return a snapshot; use `useStateStore(channel.state, selector)` for anything that must re-render. Selectors must be at module scope, and must return direct slice references (`(s) => ({ read: s.read })`), not freshly computed values (`Object.values(s.members)` re-renders forever). Drive unread badges off `read`: `unreadCount` is a non-reactive getter derived from it. +- `useIsChannelMuted` reads `muteStatus` instead of subscribing to `notification.channel_mutes_updated`. + +--- + +## 10. Customizing components + +### Removed `ComponentContext` slots + +A stale key in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS or behind `any` it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent`). + +| Removed slot | v15 | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `Message` | `MessageUI` ([below](#message-ui-overrides-consolidate-on-the-messageui-slot)) | +| `ThreadHeader` | Render `` (or your own) as a child of `` ([section 7](#threadheader-lost-closethread-and-thread)) | +| `HeaderStartContent` | `StartContent` on `ChannelHeader` / `ThreadHeader` ([section 8](#8-channel-and-thread-headers)) | +| `ChannelListUI` | The list slots ([section 6](#channellistui-is-removed-use-the-list-slots)) | + +`WithComponents` merges over the parent context and merges `icons` slot by slot. `Channel`, `Thread`, the message lists and `MessageComposer` take no component overrides of their own. + +### Message UI overrides consolidate on the `MessageUI` slot + +`ComponentContext.Message` (deprecated in v14) and every `Message` prop that took a message UI component are removed. + +| v14 | v15 | +| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| `ComponentContext.Message` | `ComponentContext.MessageUI` | +| ``, `` | `MessageUI` override | +| `` | `VirtualMessage` override (falls back to `MessageUI`) | +| `` | `MessageUI` override, scoped to the thread with `WithComponents` | +| `additionalMessageListProps` / `additionalVirtualizedMessageListProps` / `additionalParentMessageProps` carrying `Message` | `MessageUI` override | + +- `VirtualMessage` still takes precedence inside `VirtualizedMessageList`, and only there. It is applied as the `MessageUI` slot of the list's own subtree, so it also applies to any `Message` rendered inside that list, including the thread head. +- A message UI receives no props of its own. Read everything from `useMessageContext()`. +- In TypeScript, `Message={...}` fails with `TS2322` and a `Message:` key with `TS2561` (`Did you mean to write 'MessageUI'?`). In plain JS both are ignored. Grep for `Message=` and `Message:`. + +```tsx +// v14 +<> + + + +``` + +```tsx +import type { Thread as StreamThread } from 'stream-chat'; +import { + MessageList, + Thread, + useMessageContext, + VirtualizedMessageList, + WithComponents, +} from 'stream-chat-react'; + +const CustomMessage = () => { + const { message } = useMessageContext(); + return
{message.text}
; +}; + +export const Lists = () => ( + + + + +); + +// Only a thread's messages (was ): +export const ThreadWithCustomMessages = ({ thread }: { thread: StreamThread }) => ( + + + + + +); +``` + +### Context hooks take no `componentName` argument + +`useChatContext`, `useTranslationContext`, `useMessageContext`, `useComponentContext`, `useMessageComposerContext`, `useMessageListContext` and `useMessageBounceContext` no longer take an argument. TypeScript flags each call (`TS2554 Expected 0 arguments`); in plain JS the argument is ignored. + +```tsx +// v14 +const { t } = useTranslationContext('CustomEmojiPicker'); +const { textareaRef } = useMessageComposerContext('CustomEmojiPicker'); +``` + +```tsx +import { useMessageComposerContext, useTranslationContext } from 'stream-chat-react'; + +export const useEmojiPickerParts = () => { + const { t } = useTranslationContext(); + const { textareaRef } = useMessageComposerContext(); + return { t, textareaRef }; +}; +``` + +### Required context hooks throw outside their provider + +In v14 these hooks logged a `console.warn` (or nothing) and returned `{}` or `undefined`, so a misplaced component rendered blank and failed later with `Cannot read properties of undefined`. In v15 they throw ` was called outside of ` at the call site. This is a runtime error, not a compile error. + +| Hook | Must be rendered within | +| ---------------------------------- | ----------------------------------------------------------- | +| `useChatContext` | `` | +| `useMessageContext` | `MessageProvider` (a rendered message) | +| `useMessageComposerContext` | `MessageComposerContextProvider` (inside `MessageComposer`) | +| `useMessageListContext` | `MessageListContextProvider` | +| `useVirtualizedMessageListContext` | `VirtualizedMessageListContextProvider` | +| `useMessageBounceContext` | `MessageBounceProvider` | +| `usePollContext` | `PollProvider` | +| `useDialogManager` | `DialogManagerProvider` (its own message) | +| `useSearchContext` | `SearchContextProvider` | +| `useSearchSourceResultsContext` | `SearchSourceResultsProvider` | +| `useContextMenuContext` | `ContextMenu` | +| `useChannelListItemContext` | `ChannelListItemUI` | +| `useGalleryContext` | `Gallery` | +| `useChatViewContext` | `ChatView` (from `stream-chat-react/slot-layout`) | +| `useChannel` | a `Channel`, a `Thread` or a `ChannelInstanceProvider` | + +**What to look for:** a custom message UI or attachment component rendered outside a message; a loading or empty state mounted outside ``; a component calling `useMessageComposerContext()`, or `` from `stream-chat-react/emojis`, mounted outside `MessageComposer`. Move it inside the provider, or wrap it. -### `MessageComposer` `overrideSubmitHandler` prop → removed +- To probe whether a composer context exists, use the new `useIsWithinMessageComposerContext()`. +- `useMessageComposerController()` still needs a `Channel` or `Thread` subtree (v14 crashed outside a `Channel` too). It now also resolves inside a `Thread` with no `Channel` around it. -`MessageComposer` (formerly `MessageInput`) now owns the submission flow (`messageComposer.compose()` → `channel.sendMessageWithLocalUpdate()`), so the `overrideSubmitHandler` prop is gone. To customise sending: +These still work outside a provider: `useTranslationContext` (renders the inline English copy), `useComponentContext` (an empty override map), `useChannelInstanceContext`, `useModalContext`, `useAriaLiveAnnouncer`, `useMessageTranslationViewContext`. -- **Intercept the outgoing request** → register a `sendMessageRequest` handler on `client.config` (also `updateMessageRequest` / `deleteMessageRequest` / `markReadRequest`). `channel.messageOperations` uses it instead of the default request. The `do*Request` props that did this in v14 are removed — see "Per-component request-handler props removed" below. -- **Transform the composed message** → register composition middleware on `messageComposer`. +Related type changes: -### `MessageComposer` `preventClearingOnUnmount` prop → removed +- `useChannelInstanceContext()` returns `Partial`, so `channel` may be `undefined`. Use `useChannel()` when a channel is required. +- `ChatViewContext` has no default value, so `useContext(ChatViewContext)` may be `undefined`. +- `MessageComposerContext` is `MessageComposerContextValue | undefined` (its value type changed from `UseMessageComposerBindingsParams`). -`MessageComposer` no longer clears a composer supplied through `MessageComposerControllerProvider` -when it unmounts - that composer belongs to whoever supplied it - so the prop that opted out of the -clearing is gone. Drop it. If you relied on the unmount clearing a supplied composer, call `clear()` -on it yourself once your UI is done with it. The channel's and thread's own composers are still -cleared on unmount, after their draft is saved. +### `messageActions` and `MESSAGE_ACTIONS` are removed -### Composer attachment previews: `UnsupportedAttachmentPreview` and `RemoveAttachmentPreviewButton` are exported +The `messageActions` prop on `Message`, `MessageList`, `VirtualizedMessageList` and `Thread` is removed, along with `MESSAGE_ACTIONS`, `OPTIONAL_MESSAGE_ACTIONS`, `MessageActionsArray`, `Capabilities`, the `getMessageActions()` util, and the `getMessageActions` / `actionsEnabled` fields of `MessageContextValue` (also on `MessageUIComponentProps`, `SharedMessageProps` and `VirtuosoContext`). -The composer has no preview for a custom attachment type, so it lists one as unsupported. That also happens when a user edits a message that carries one. `AttachmentPreviewList` takes the component for those as its `UnsupportedAttachmentPreview` prop. In v14 only the prop types were exported, so a custom preview could not fall back to the default preview or reuse the SDK's remove button. Both are now exported from the package root: +The prop had already stopped working in v14: `getMessageActions` was written into `MessageContext` and read by nothing, so passing `messageActions={['delete']}` in v14 changed nothing on screen, and the default rendering does not change now. In untyped code, a custom `MessageUI` that calls `getMessageActions()` throws `TypeError: getMessageActions is not a function`. -- **`UnsupportedAttachmentPreview`**: the default preview. Render it for the attachment types your component does not handle. -- **`RemoveAttachmentPreviewButton`**: the remove (×) button of the SDK's previews, positioned at the card's top-end corner. Give its container `position: relative`. +Choose the actions on `MessageActions` through `messageActionSet` (filter or extend `defaultMessageActionSet`) and install it with the `MessageActions` slot. `MessageActionSetItem` is a union, and its dropdown-toggle member has no `type`, so guard with `'type' in item`: -If your app copied either component out of the v14 source to get this, import it instead: +```tsx +// v14 + +``` ```tsx import { - AttachmentPreviewList, - type AttachmentPreviewListProps, - RemoveAttachmentPreviewButton, - UnsupportedAttachmentPreview, - type UnsupportedAttachmentPreviewProps, + defaultMessageActionSet, + MessageActions, + MessageList, WithComponents, } from 'stream-chat-react'; -const ProductPreview = (props: UnsupportedAttachmentPreviewProps) => { - const { attachment, removeAttachments } = props; - if (attachment.type !== 'product') return ; - - return ( -
- {attachment.custom?.name} - removeAttachments([attachment.localMetadata.id])} - /> -
- ); -}; +const allowed = new Set(['edit', 'react', 'reply']); -const PreviewList = (props: AttachmentPreviewListProps) => ( - +const CustomMessageActions = () => ( + !('type' in item) || allowed.has(item.type), + )} + /> ); - - {/* … */} -; +export const List = () => ( + + + +); ``` -### Quote and edit previews take the text colour paired with their background +- Action names are typed by the exported `DefaultMessageActionType` union; a custom action may use any string. +- Permission and message-state filtering stays in `useBaseMessageActionSetFilter`: an action the user cannot perform, or that a thread reply does not support, is dropped whether or not you list it. Pass `disableBaseMessageActionSetFilter` to take that over. +- There is no `actionsEnabled` replacement. The base filter already drops actions on deleted, bounced and failed messages. +- Adding an action means adding an item with a `Component` and a `placement` of `quick`, `dropdown` or `quick-dropdown-toggle`. -`.str-chat__quoted-message-preview` (the reply and edit previews in the composer, and quotes inside message bubbles) now sets its text to `--str-chat__chat-text-incoming`. For the user's own message (`--own`) it uses `--str-chat__chat-text-outgoing`, to match the outgoing background. In v14 the author line inherited the surrounding text colour, and the message text always used the incoming colour. A theme with a dark outgoing bubble got dark text on it. +--- -- **Default light theme:** your own quotes change from the primary text colour to the outgoing bubble's text colour (`--str-chat__brand-900`). -- **An app that overrode these colours to fix the contrast** → remove the override. To theme the quote, set `--str-chat__chat-text-incoming` / `--str-chat__chat-text-outgoing`, which the message bubbles use too. +## 11. Message lists -### `ChatContext.setActiveChannel` → removed +Both lists drive themselves from `(thread ?? channel).messagePaginator`. -There is no `setActiveChannel` on `ChatContext`. Bind a channel by: +### The `messages` prop is ignored -- passing it directly as the `channel` prop: `…` (the `Channel` component takes `channel` as a prop; it no longer reads it from context), and/or -- opening it in a `ChatView` layout slot — e.g. `open({ key: channel.cid, kind: 'channel', source: channel })` — the mechanism `ChannelListItemUI` uses on selection. +`MessageList.messages` and `VirtualizedMessageList.messages` still type-check, and the `VirtualizedMessageList` doc comment still says it overrides the messages, but **both lists always render the active paginator's items.** A custom array (filtered, reordered or synthetic) is dropped with no error. -To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the paginators, use `channelManager.ingestChannel(channel)`. Confirm the exact `open()` / orchestrator signatures against the installed source. +- To change what is rendered: `reviewProcessedMessage` (still applied by both lists), `hideDeletedMessages`, or a custom `renderMessages` (`MessageList`). +- To change the data: act on the paginator (`messagePaginator.ingestItem(message)` / `removeItem({ id })`). +- Do not reorder; the paginator owns the sort order. -### `ChatContext.channelsQueryState` → removed +### Pagination and state props are removed or ignored -`Channel` no longer reflects the channel-list query state. Its loading / error / empty rendering is driven by the channel's own `watch()` bootstrap (`LoadingIndicator` while watching, `LoadingErrorIndicator` on watch failure, `EmptyPlaceholder` when no channel is provided). The channel-list query state is the `ChannelList`'s concern, not `Channel`'s. +| Prop | v15 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MessageList`: `loadMore`, `loadMoreNewer`, `loadingMoreForJumpToChannelMessage` | Removed | +| `MessageList`: `hasMore`, `jumpToLatestMessage`, `loadingMoreNewer`, `messageLimit` | **Still typed, never read** | +| `MessageList.loadingMore` | Only gates the `LoadingIndicator` in the "loading older" row, which renders while `messagePaginator.state.isLoading` is true | +| `MessageList.internalInfiniteScrollProps` | Now feeds `InfiniteScrollPaginator`: `hasNextPage`, `hasPreviousPage`, `head`, `initialLoad`, `isLoading`, `loader`, `loadNextPage`, `loadPreviousPage`, `reverse` and `LoadingIndicator` no longer compile | +| `VirtualizedMessageList`: `hasMore`, `hasMoreNewer`, `loadingMore`, `loadingMoreNewer`, `loadMore`, `loadMoreNewer`, `messageLimit`, `highlightedMessageId`, `channelUnreadUiState` | Removed | -## Dates on response types are unix-nanosecond numbers +Code that drove these props uses the paginator: -`stream-chat` now types every **server-sent** date as the unix-nanosecond number the API puts on the -wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a -`Date` and not an ISO string, and the React types that carry those values through changed with it. +- older / newer page: `messagePaginator.toTail()` / `.toHead()` +- has more: `messagePaginator.hasMoreTail` / `.hasMoreHead` +- loading: `useStateStore(messagePaginator.state, selector)` → `isLoading` +- jump: `messagePaginator.jumpToMessage(id)`, `.jumpToTheLatestMessage()`, `.jumpToTheFirstUnreadMessage()`. The highlight follows `messagePaginator.messageFocusSignal` (replaces `highlightedMessageId`). +- unread UI state: `messagePaginator.unreadStateSnapshot` +- page size: `client.config.set({ channel: { messagePaginator: { pageSize } } })` (and `thread.messagePaginator` for replies); there is no per-list `messageLimit` (see [`instance-configuration.md`](./instance-configuration.md)) -The type is **`TimestampNS`**, a branded `number`. Reading, comparing, sorting and subtracting work as -with any number. Two things change at compile time: +### `threadList` and `head` are removed; lists detect the thread -- **`new Date(timestamp)` is a type error.** `stream-chat`'s published types augment the global - `DateConstructor`, because a nanosecond value is out of `Date`'s range (`Date` tops out near 8.64e15 - ms; a current timestamp is ~1.79e18) and yields an Invalid Date whose `.toISOString()` throws. -- **Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field or prop: - use `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value that is already in - nanoseconds (a fixture, a stored value, the epoch `asTimestampNS(0)`). Arithmetic drops the brand — - wrap the result in `asTimestampNS` when it goes back into a timestamp. +- `MessageList.threadList`, `VirtualizedMessageList.threadList`, `VirtuosoContext.threadList`, `TypingIndicatorProps.threadList` and `ScrollToLatestMessageButtonProps.threadList` are removed. The SDK no longer passes `threadList` to custom `TypingIndicator` / `ScrollToLatestMessageButton` overrides; in untyped code it is `undefined`, so a thread-aware override behaves as if it were in the channel list. Detect the thread with `const isThreadList = !!useThreadContext();`. +- `MessageList.head` and `VirtualizedMessageList.head` are removed. Both lists render the parent message from the thread context. Override it with a `ThreadHead` slot (`ComponentType`). On `VirtualizedMessageList`, `additionalVirtuosoProps.components.Header` still replaces the whole header slot. +- `Message` / `MessageContext` still have a `threadList` field; only the list-level prop is gone. -What the compiler still does **not** catch: +### `disableDateSeparator` → `withDateSeparator` -- **Date libraries.** A date library reads a bare number as **milliseconds**, so - `dayjs(created_at).format()` returns the literal string `Invalid Date` and renders it on screen. -- **Fallbacks and derived values.** `new Date(ts ?? Date.now())` and `new Date(Math.max(a, b))` - compile, because the argument is no longer purely `TimestampNS`. Convert first, then fall back. -- **A unit mix-up between two numbers.** Comparing a wire timestamp against `Date.now()`, or adding a - millisecond duration to one, produces a plausible-looking number and no complaint at all. +Both lists take `withDateSeparator` (inverted). `Thread.enableDateSeparator` is removed ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)). -### The public React types that changed +| Component | v14 default | v15 default | +| ------------------------ | ------------------------------ | --------------------------- | +| `MessageList` | `disableDateSeparator={false}` | `withDateSeparator={true}` | +| `VirtualizedMessageList` | `disableDateSeparator={true}` | `withDateSeparator={false}` | -| Type | v14 | v15 | -| ----------------------------------------------------- | ------------------- | ----------------------- | -| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `TimestampNS \| null` | -| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `TimestampNS \| null` | -| `MessageList` `headerPosition` / `insertIntro` | `number` (epoch ms) | `TimestampNS` (unix ns) | +In untyped code `disableDateSeparator` is ignored and the default applies. The rename also reaches `processMessages` and the `context` handed to `reviewProcessedMessage` (`enableDateSeparator` → `withDateSeparator`; reading the old field yields `undefined`), `useEnrichedMessages`, `FloatingDateSeparator`, `useFloatingDateSeparator` and `useFloatingDateSeparatorMessageList`. -`ChatContextValue.latestMessageDatesByChannels` is not in this table because it is **removed**, not -retyped — see [below](#chatcontextlatestmessagedatesbychannels--removed). +```tsx +// v14 +<> + + + +``` -`DateSeparatorMessage` (a member of the exported `RenderedMessage` union) changed shape rather than -type: it **lost its `type: MessageLabel` field**, and `unread` is now optional. The `type` field was -never actually populated — every construction site cast the object into place without it — so reading -it was already `undefined` at runtime; it now fails to compile. `unread` is set only by the unread -separator; the plain day divider omits it. Narrow with `isDateSeparatorMessage` rather than checking -either field. +```tsx +import { MessageList, VirtualizedMessageList } from 'stream-chat-react'; -Comparisons get simpler, not harder — compare and sort the raw numbers and drop the `Date` round-trip: +export const Lists = () => ( + <> + + + +); +``` -```ts -// v14 -if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … } +### Custom `renderMessages` gets different options -// v15 -if (a.created_at < b.created_at) { … } -``` +- `RenderMessagesOptions.channel: Channel` is new and required. +- `channelUnreadUiState` is now required and typed `UnreadSnapshotState` (`firstUnreadMessageId`, `lastReadAt`, `lastReadMessageId`, `unreadCount`); `focusedMessageId?` is new. Reading the v14 snake_case fields yields `undefined`, so a hand-rolled unread separator never renders. +- `getIsFirstUnreadMessage` takes `UnreadSnapshotState & { channel?, isFirstMessage, message, previousMessage? }` (`lastReadDate` → `lastReadAt`, `unreadMessageCount` → `unreadCount`) and returns `false` without a `channel` or inside a thread. +- The default renderer no longer places the unread separator before a system message. -### Presentational props still take `Date` +### `VirtuosoContext` (the context of `additionalVirtuosoProps.components`) -The conversion boundary is where core data enters the component tree, so components that exist to -_render_ a date are unchanged — `DateSeparator`'s `date: Date` and `formatDate?: (date: Date) => string`, -for instance. Convert at that boundary with the guarded helper `stream-chat` exports: +- Removed: `Message`, `messageActions`, `openThread`, `threadList`. +- New and required: `channel: Channel`. New: `focusedMessageId?`. +- `head`, `firstUnreadMessageId` and `lastReadMessageId` are `... | null` (checks written as `=== undefined` miss them); `lastReadDate` is `TimestampNS | null` (unix nanoseconds); `loadingMore: boolean`; `unreadMessageCount: number`. -`convertTimestampToDate` returns `Date | undefined` — `undefined` for an absent or non-finite value. -**Handle that `undefined`; do not cast it away.** A prop typed `date: Date` will accept it through a -cast and then fail somewhere further along: `isDateSeparatorMessage` (`src/components/MessageList/utils.ts`) -gates on `isDate(message.date)`, so the list stops recognising the object as a separator and renders it -as an ordinary message — an empty row where the day divider belonged, with no error and no type error. +### `headerPosition` and `insertIntro` take unix nanoseconds -```ts -import { convertTimestampToDate } from 'stream-chat'; +`MessageList.headerPosition` and `insertIntro(messages, headerPosition)` are compared against `message.created_at`, so they are `TimestampNS`, not epoch milliseconds. A millisecond `number` no longer compiles; with a cast, the header lands in the wrong place. Pass `message.created_at` or `msToNs(ms)` from `stream-chat`. -const createdAt = convertTimestampToDate(message.created_at); +### `DateSeparatorMessage` -// Render nothing when there is no usable timestamp. -{createdAt ? : null} -``` +The `DateSeparatorMessage` member of the `RenderedMessage` union lost `type` (it was never populated at runtime) and `unread` is optional (set only by the unread separator). Narrow with `isDateSeparatorMessage(message)` rather than checking either field. -```ts -// WRONG — the cast launders `undefined` into a required `Date`. - -// WRONG — invents "now", labelling a months-old message "Today". - -``` +### Low-level list hooks and utils -`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` / `asTimestampNS` are exported alongside it -for values known to be present. Note that **outgoing request** date fields are still `Date` (filter bounds like -`created_at_before`, plus `remind_at` and `message_timestamp`) — `JSON.stringify` emits RFC3339 for a -`Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp -back to the API. +Only code that calls these directly (custom list implementations) is affected: -### `MessageList`'s `headerPosition` prop changed unit +- `useEnrichedMessages({ withDateSeparator, headerPosition?: TimestampNS, ... })` +- `useNewMessageNotification(messages, currentUserId)` (no `hasMoreNewer`) +- `useScrollLocationLogic`: no `disableAutoScrollToBottom` / `disableScrollManagement`; `listElement: HTMLElement | null`; `suppressAutoscroll?` optional; `scrollToBottom(options?: { behavior?: ScrollBehavior })` +- `areMessagePropsEqual` no longer compares `Message`, `messageActions` or `mutes` +- `MessageListContextValue.listElement: HTMLElement | null` -`headerPosition` is compared against `message.created_at`, so it is now **unix nanoseconds** — it was -epoch milliseconds while `created_at` was a `Date`. It is typed `TimestampNS`, so a millisecond -`number` no longer compiles: pass `message.created_at` or `msToNs(ms)`. +--- -### Peer-dependency gate before release +## 12. Messages -The SDK imports `convertTimestampToDate` / `nsToDate` / `nsToMs` from `stream-chat`, which only exist -from the version that ships `utils/time`. Until that is published, `package.json` pins -`stream-chat` exactly and the workspace resolves it through a local `portal:` — so a green local build -says nothing about whether a consumer can resolve these imports. Before publishing, widen the peer -range to the version that exports them and verify from a clean install with no `portal:` override. +### A standalone `` needs a channel in context -### Test fixtures have to model the wire +`Message` resolves its channel with `useChannel()`, which throws when there is no `Channel`, `Thread` or `ChannelInstanceProvider` ancestor. In v14 the missing contexts only warned. Wrapping a preview `` in `ChannelStateProvider` / `ChannelActionProvider` no longer compiles. -A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from -runtime behavior. The SDK's own suite normalizes through -`mock-builders/generator/time.ts` (`convertDateToTimestamp`), which accepts a `Date`, an ISO string or a -raw wire number so tests stay readable while the value on the wire stays a number. It returns -`TimestampNS`, so a generated fixture is assignable to the response types; a hand-written literal -(`created_at: 0`, `now - msToNs(1000)`) needs `asTimestampNS(...)`. +- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. `examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx` is the reference. +- `highlighted` is no longer derived from `ChannelStateContext.highlightedMessageId`. Pass `highlighted` yourself (`MessageProps.highlighted?: boolean`). -## i18n: English-only bundle, namespaced translation keys +```tsx +import type { LocalMessage, StreamChat } from 'stream-chat'; +import { ChannelInstanceProvider, Message } from 'stream-chat-react'; + +export const MessagePreview = ({ + client, + message, +}: { + client: StreamChat; + message: LocalMessage; +}) => ( + + + +); +``` -Two breaking changes, both of which fail **silently** — no error, no compile break unless the app -is typed against the new surface. Check for them explicitly. +### `MessageContext.mutes` and `messageIsUnread` are removed -1. **The 11 non-English dictionaries are removed** (`de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, - `pt`, `ru`, `tr`), along with their `dayjs` locale data. The `deTranslations` … - `trTranslations` exports are gone. -2. **Keys are namespaced identifiers, not the English text.** - `t('Send Message')` → `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. +- `mutes` → `useChatContext().mutes` (now `UserMuteResponse[]`), for example `isUserMuted(message, useChatContext().mutes)`. In untyped code the removed field is `undefined`, so `isUserMuted(message, mutes)` returns `false`. +- `messageIsUnread` has no successor. Derive it from the channel's read state, for example `!isMyMessage() && (channel.lastRead() == null || message.created_at > channel.lastRead())`, with `channel` from `useChannel()`. `channel.lastRead()` returns `TimestampNS | null | undefined` and is not reactive; to re-render on read changes, subscribe to `read` on `channel.state`. -**What to look for in the app:** +### `retrySendMessage` and `useRetryHandler` -| Symptom | Cause | Fix | -| ---------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------- | -| `registerTranslation(...)` / `translationsForLanguage` present | keys are the old English strings | rename every key | -| `language: 'de'` (or any non-`en`) with no dictionary registered | the built-in one is gone | register a dictionary | -| non-English dates render in English | the dayjs locale is no longer bundled | `import 'dayjs/locale/de.js'` + `dayjsLocaleConfigForLanguage` | -| imports of `deTranslations` etc. | exports removed | recover from a v14 tag, then rename | +- The `retrySendMessage` prop on `Message`, `MessageList` and `SharedMessageProps` is removed. To change the retry request, register `retrySendMessageRequest` on the client ([section 14](#14-request-handlers)). +- `useRetryHandler()` takes no argument and returns `RetryHandler = (params: Omit, 'message'>) => Promise`. Call it as `retry({ localMessage: message })`. At runtime, v14's `retry(message)` passes a message where `{ localMessage }` is expected. +- Inside a message UI, `useMessageContext().handleRetry(message)` still works. Its return type widened to `Promise | void`, so chaining `.then` on it no longer compiles. -An unrenamed key does **not** throw — it simply never matches, and the English copy renders -instead. Do not assume the absence of an error means the app is migrated. +```tsx +// v14 +const retry = useRetryHandler(); +; +``` -**Renaming:** every old key maps to exactly one new key. The complete table is -[`i18n-v15-key-map.json`](./i18n-v15-key-map.json) (621 rows, `{ "": { "key": "", -"prose": bool, "plural"?: bool } }`). Entries with `"prose": false` hold formatter expressions -rather than copy. Four of them nonetheless carry English words inside their `calendarFormats` -argument — `timestamp.DateSeparator`, `timestamp.ReminderNotification`, -`timestamp.ChannelPreviewTimestamp`, `timestamp.ChannelDetailPinnedMessageTimestamp` — and must be -overridden to translate Today/Tomorrow/Yesterday/Last. `dayjsLocaleConfigForLanguage` does not -reach them, because a per-key `calendarFormats` replaces the locale's calendar. +```tsx +import { useMessageContext, useRetryHandler } from 'stream-chat-react'; -`registerTranslation()` and `translationsForLanguage` take `TranslationDictionary` (exported from -`stream-chat-react`), so TypeScript flags every stale key in a dictionary written inline. Plural keys -accept any `Intl.PluralRules` category, so a language needing `_few` / `_many` / `_zero` stays -checked. Only if the app needs keys the SDK does not define, annotate the variable it passes as -`LooseTranslationDictionary` — that admits any key and will **not** flag a stale one. +export const RetryButton = () => { + const { message } = useMessageContext(); + const retry = useRetryHandler(); + return ; +}; +``` -Full detail, including plurals for languages needing `_few` / `_many` and how to recover a deleted -dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md). +### `usePinHandler` no longer returns `canPin` -### `Channel` no longer queries the channel; `initializeOnMount` / `channelQueryOptions` → removed +`usePinHandler(message, notifications?)` returns `{ handlePin }`. Read `canPin` from `useUserRole(message).canPin`. In untyped code the removed field is `undefined`, so a pin button gated on it never shows. -`Channel` used to call `channel.watch()` when it mounted an uninitialized channel. `initializeOnMount` turned that off and `channelQueryOptions` configured the query. The call and **both props are removed** — whoever supplies the channel initializes it. +### `AIStates` moved to `stream-chat` -- **Channels from `ChannelList`, or any `queryChannels` call, arrive watched.** If that is where yours come from, nothing changes. -- **Creating a channel and handing it straight to `Channel`** → watch it first, with `channel.ensureWatched()`: +`import { AIStates } from 'stream-chat'`. The values are unchanged, but it is now `as const`, so an inferred array of its members no longer accepts the wide `AIState` that `useAIState` returns (`.includes(aiState)` fails to compile). Widen the array: - ```tsx - const channel = client.channelManager.ensure({ - data: { custom, members }, - id, - type: 'messaging', - }); - if (!channel.initialized) await channel.ensureWatched(); - setChannel(channel); - ``` +```tsx +import { type AIState, AIStates, type Channel } from 'stream-chat'; +import { useAIState } from 'stream-chat-react'; - Two separate guards, both worth keeping. `client.channelManager.ensure()` (which `client.channel()` calls in stream-chat v10) returns the stored instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `ensureWatched()` over a bare `channel.watch()` when one _is_ needed: it sends nothing for a channel already watched, and a call made while an earlier `ensureWatched()` with the same options is in flight waits for that one, so an effect that runs twice, or two components opening the same channel, still produce one query. `channel.watch()` always sends its request. That de-duplication used to live inside `Channel`. +const STOPPABLE: readonly AIState[] = [AIStates.Thinking, AIStates.Generating]; -- **A direct message identified by members** → `const channel = await client.channelManager.ensure({ data: { members }, type: 'messaging' }).ensureWatched();` (`ensureWatched()` resolves with the channel). -- **`channelQueryOptions`** → pass them to the watch you now own: `channel.ensureWatched(options)`. -- **`getChannel` → removed.** Get the instance from `client.channelManager.ensure()` and call `channel.ensureWatched(options)` on it. It de-duplicates per instance instead of per cid, which the channel store makes equivalent, as `ensure()` returns one instance per cid (and per member list for a channel without an id). -- **An uninitialized channel renders as an empty channel, with no error**, because nothing failed. If a channel renders blank, check `channel.initialized` before looking anywhere else. -- **Loading and error UI is yours.** `Channel` no longer renders the `LoadingIndicator` or `LoadingErrorIndicator` component slots, because it has no query to report on. Render them around `Channel`, where they sit in your layout instead of replacing the whole channel column. -- **Why it changed:** `Channel` cannot make that call well. It does not know which query options a screen needs, whether a list or a search result already loaded the channel, whether a failed query should retry or navigate away, or what belongs on screen while the query is in flight. All of that belongs to the code that decides which channel to open. Full recipes: [providing a channel](/chat/docs/sdk/react/v15/guides/providing-a-channel/). +export const Stop = ({ channel }: { channel: Channel }) => { + const { aiState } = useAIState(channel); + return STOPPABLE.includes(aiState) ? : null; +}; +``` -### `ChannelProps.channel` is required; `EmptyPlaceholder` → removed +`useAIState(channel)` keeps its signature and `{ aiState }` shape. It now reads the `aiState` slice of `channel.state` instead of its own event subscriptions (v14 already handled `ai_indicator.update` / `.clear` / `.stop`). New: it also resets to `Idle` when the client drops the connection or the channel is unwatched. -`Channel` no longer accepts a missing channel. `channel` is required, and the `EmptyPlaceholder` prop — the "what to render when there is no channel" escape hatch — is **removed**. A `Channel` with no channel bound had nothing to provide: every context it supplies, every hook it runs and every child it enables needs one, and deciding what to show while nothing is selected is the application's layout concern. +### `allowConcurrentPlayback` is removed; one voice message plays at a time -- **Rendering `` where `activeChannel` may be undefined** → guard at the call site: `{activeChannel && …}`. -- **Relying on `EmptyPlaceholder` to fill the layout slot** → render the newly exported `ChannelPlaceholder` instead. It is the same `.str-chat__channel` column (`height: 100%`, `display: flex`) that `Channel` used to render in that case, with no channel bound: `{activeChannel ? … : {yourEmptyState}}`. -- **Why it changed:** the optional prop forced an early return before any hook could run, which forced `Channel` to be split into a public wrapper and an internal `ChannelInner`. With the prop required, the two collapse into one component — and the split was where a channel identity bug (keying on `cid` rather than on the `Channel` instance) went unnoticed. +`WithAudioPlaybackProps.allowConcurrentPlayback` and `ChannelProps.allowConcurrentAudioPlayback` are removed, with no replacement. Delete the prop. -### `Channel` no longer writes `document.title`; `activeUnreadHandler` → removed +- Playback is exclusive across every surface inside a `Chat`: a channel and a thread side by side no longer play together, even if you passed `true` before. Starting a player pauses whichever was playing. +- `useActiveAudioPlayer()` reports the app-wide active player, not the calling surface's. +- Switching channels stops that channel's audio: `Channel` passes its channel as `WithAudioPlayback`'s new `playbackScope`, whose change clears the pool. +- A custom `WithAudioPlayback` joins the shared arbiter that `Chat` provides automatically. Pass `playbackScope` if the provider outlives the entity it plays for. -`Channel` used to set `document.title` to `(3) Your app` when a message arrived while the tab was hidden, and `ChannelProps.activeUnreadHandler` let you intercept that. Both are **removed, with no SDK replacement.** +### The gallery header ignores the `MessageTimestamp` override -What belongs in a tab title depends on what the application is showing, and no SDK component can know that — it can know the unread counts, but not whether the user is currently looking at the channel those unreads are in. So the title is application code now. `examples/vite/src/DocumentTitleManager` is a complete, copyable implementation: a component rendered inside `Chat` that renders nothing, subscribes to the two unread counts and writes `document.title`, restoring the page's previous title when it unmounts. +A custom `ComponentContext.MessageTimestamp` no longer appears in the gallery modal header, which renders the item's own timestamp through the `timestamp.GalleryTimestamp` key. Customize that key's format instead. -**If you relied on the old behavior**, be aware it was broken in ways worth not reproducing: +--- -- **It counted only the open channel** (`channel.countUnread()`), so messages arriving anywhere else never reached the title. -- **It did nothing in a threads view**, where v15 mounts no ambient `Channel` — leaving a stale number belonging to a channel you had left. -- **It skipped thread replies** that were not `show_in_channel`. -- **It never put the title back.** Once set, `(3) Your app` survived reading the messages and returning to the tab. -- **It compounded.** Channel bootstrap re-read the already-prefixed title as the new "original", producing `(1) (3) Your app`. +## 13. Message composer -Two things to know when writing your own: +### `overrideSubmitHandler` is removed -- **The counts are user-scoped, not view-scoped.** The unread message total comes from the connection's `me` payload plus the `total_unread_count` on events; the unread thread count from `client.threads` (which `Chat` keeps subscribed). Neither needs an active channel or thread. -- **They are different units — do not add them.** One counts unread _messages_ in channels, the other counts _threads_ with unread replies. Thread replies also do not appear to reach the message total: `notification.thread_message_new` carries `unread_threads` but no `total_unread_count`. +`MessageComposer.overrideSubmitHandler` (and the same field on `MessageComposerContextValue`) is removed. In plain JS it is ignored, so the custom send path stops running and messages go out through the default request. -### `channel.channelMissing.text` translation key → removed +Submitting is `messageComposer.send()`, which composes, releases the composer and sends through `messageOperations.send()` of the submit target (the channel, or the thread for a reply). Depending on what the override did: -`Channel` no longer renders a "Channel Missing" state. The branch behind it tested `!channel.watch` — a duck-type check for "is this really a `Channel`", from a time when the component could be handed something that was not one. `channel` is required and typed now, so the branch was unreachable, and it was shadowed anyway: the bootstrap path calls `channel.watch()` and fails first. +- **Replace or wrap the outgoing request** → register a `sendMessageRequest` handler on the channel request handlers ([section 14](#14-request-handlers)). It also covers thread replies. Call `defaultRequest(params)`, its second argument, to fall through to the default request, offline queue included. The cid is `params.localMessage.cid`. +- **Change the composed message** → register composition middleware on the composer (unchanged `stream-chat` API). -**This is a compile break for strict dictionaries.** `TranslationDictionary` is exact, so a dictionary that still declares `'channel.channelMissing.text'` fails to typecheck (`TS2353`). Delete the entry from any custom locale files — both example dictionaries in this repo needed it. +Differences from the v14 override: -### `allowConcurrentAudioPlayback` → removed; one voice message plays at a time, app-wide +1. The v14 override replaced the whole send, so the integrator also owned the optimistic insert. The v15 handler replaces only the request: the client still inserts the message optimistically and reconciles it. +2. Registration is per client, not per mounted `MessageComposer`. +3. As in v14, the handler does not see edits. Edits go through `updateMessageRequest`. -`ChannelProps.allowConcurrentAudioPlayback`, `ThreadProps.allowConcurrentAudioPlayback` and -`WithAudioPlaybackProps.allowConcurrentPlayback` are **removed**, with no replacement. Overlapping -voice messages are not a behaviour worth configuring. +```tsx +// v14 +const overrideSubmitHandler: MessageComposerProps['overrideSubmitHandler'] = async ({ + cid, + localMessage, + message, + sendOptions, +}) => { + await myBackend.send({ cid, localMessage, message, sendOptions }); +}; -The flag was never only a policy: with it off, all players share a single `Audio` element and hand -ownership between them, which is also what keeps playback working on iOS, where an element must be -unlocked by a user gesture before it can be played programmatically. Setting it to `true` allocated -an element per player and bypassed that. +; +``` -**Exclusivity now spans the app, which the flag could not express.** `Channel` and `Thread` mount an -audio-player pool each — that is what stops a thread's audio when you close it, while letting a -message scrolled out of a virtualized list keep playing — so with a per-pool rule a channel and a -thread beside it played over each other regardless of the flag. The pools now share one -`AudioPlaybackArbiter`, which owns the element and decides who may play. `Chat` provides it. +```tsx +import type { MessageRequest, StreamChat } from 'stream-chat'; +import { MessageComposer } from 'stream-chat-react'; -- **If you never passed the prop, or passed `false`,** the only change is that playback is exclusive - across surfaces rather than within one. Nothing to do. -- **If you passed `true`,** delete it. Starting a player now pauses whichever was playing and takes - the shared element over. -- **Switching channels stops that channel's audio.** `Channel` passes its channel as - `WithAudioPlayback`'s new `playbackScope`, whose change clears the pool. Unmounting a provider - still clears it, as before; the scope covers a provider that stays mounted while its subject - changes, which is what a `Channel` does since it stopped keying its subtree on the cid. -- **`useActiveAudioPlayer()` now reports the app-wide active player**, not the calling surface's. -- **If you mount your own `WithAudioPlayback`,** it joins the same arbiter automatically inside a - `Chat`, and arbitrates alone outside one. Pass `playbackScope` if it outlives what it plays for. +declare const myBackend: { + audit: (cid: string, message?: MessageRequest) => Promise; +}; -### `ChatContext.latestMessageDatesByChannels` → removed +// once, where the client is created +export const registerSendHandler = (client: StreamChat) => + client.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async (params, defaultRequest) => { + await myBackend.audit(params.localMessage.cid, params.message); + return defaultRequest(params); // or return { message } from your own request + }, + }, + }, + }); -The field is **removed** from `ChatContextValue`, along with the `message.new` subscription in `Channel` that maintained it. +export const Composer = () => ; +``` -Despite the name it never held the channel's latest message date: the write was filtered to the _current user's_ own messages, because it existed to compute how much slow-mode cooldown was left since you last posted. That job now belongs to `channel.cooldownTimer`, a `CooldownTimer` in `stream-chat` with its own `StateStore`, so the map had no reader left. +### `handleSubmit` is removed -- **Slow-mode cooldown** → `useStateStore(channel.cooldownTimer.state, …)`, or the `useCooldownRemaining` / `useIsCooldownActive` hooks. -- **The channel's latest message** → `channel.messagePaginator.aggregateState.lastMessage`. -- **When the current user last posted in a channel** → no longer available from the React SDK. Nothing in the SDK consumed it, but if you did, track it yourself from `message.new`. +`handleSubmit` is gone from `MessageComposerContext` and from `useMessageComposerBindings` (params and result). In plain JS it is `undefined`, so a custom send button wired to it does nothing. Custom composer UIs that submitted with `useChannelActionContext().sendMessage(composition)` / `editMessage(...)` lose that path too. -### `Channel` no longer jumps to a searched message; jump through the message paginator +- `useMessageComposerSubmitFn()` (root export) returns `() => Promise`. It calls `messageComposer.update()` while a message is being edited and `messageComposer.send()` otherwise, decided at submit time. The default send button and the Enter key use it, and it is assignable to `SendButton`'s `sendMessage` prop. +- Code that holds the composer can call `messageComposer.send()` / `.update()` directly. Both compose, release the composer, send, never reject, and resolve to `'sent' | 'nothing-to-send' | 'failed'`. -`Channel` used to watch `SearchController._internalState.focusedMessage` and scroll the list on the -component's behalf. It no longer does: `channel.messagePaginator` already owns "which message should -this list scroll to", and the jump now happens where the message is selected. +```tsx +// v14 +const { handleSubmit } = useMessageComposerContext(); +; +``` -The React SDK no longer reads `_internalState` at all, so it works whether or not your `stream-chat` -version still has the field. The field and its `InternalSearchControllerState` type are being removed -from `stream-chat` itself — that removal is tracked in its own v9 → v10 migration notes, not here. +```tsx +import { + SendButton, + TextareaComposer, + useMessageComposerSubmitFn, +} from 'stream-chat-react'; -Focus was stored in two places for one highlight. `MessageIntervalPaginator.jumpToMessage()` loads the -window around a message and leaves a `messageFocusSignal` on that paginator — which both message lists -already render from, and whose lifetime starts only once the message has actually appeared on screen. -`focusedMessage` was a second copy of the same fact, on a second clock, and a paginator per list -already gives one focus per list. +export const CompactInput = () => { + const submit = useMessageComposerSubmitFn(); + return ( +
+ + +
+ ); +}; +``` -- **To scroll a list to a message**, call `channel.messagePaginator.jumpToMessage(messageId)` (or - `thread.messagePaginator.jumpToMessage(...)` for a thread reply). `jumpToMessage` does not watch the - channel unless asked: pass `{ watchChannel: true }` to watch a channel that isn't watched yet with - the request that loads the window around the message. -- **To read what a list is currently highlighting**, subscribe to - `channel.messagePaginator.messageFocusSignal` and take `signal?.messageId`. This is what the built-in - search results now use for their "you jumped here" marker, so the marker and the highlight share one - piece of state. -- **If you relied on `Channel` performing the jump**, call `jumpToMessage` yourself where the message - is chosen — that is what `MessageSearchResultItem` now does. -- **Restoring a jump from elsewhere**, such as URL parameters on page load, is just a - `jumpToMessage` call — the signal waits, uncounted, until a list renders it. `examples/vite` reads a - repeatable `?focus=:` parameter this way. +### A failed send or edit no longer restores the composer -### `ChannelListItem` `getLatestMessagePreview` prop → removed; customize via `SummarizedMessagePreview` +This affects every `MessageComposer`, with no code change on your side. -`ChannelListItem`'s `getLatestMessagePreview` prop and the `getLatestMessagePreview` util (previously re-exported from the package root) are **removed**, along with the `latestMessagePreview` prop on `ChannelListItemUIProps`. The default `ChannelListItemUI` renders the last-message preview via the `SummarizedMessagePreview` component, which is now overridable through `ComponentContext`. +- v14: a send cleared the composer and, when the request failed, restored the full pre-send state (text, attachments, link previews, poll, custom data) and showed an error notification. A failed edit stayed in the composer. +- v15: the composer is released before the request, for sends and edits alike, and **nothing is put back**. The failed message (or edit) stays in the message list marked `failed`, where the user retries it. +- The error notification now comes from `stream-chat` (`client.notifications.addError`, types `api:message:send:failed` / `api:message:update:failed`), translated through `notification.messageSendFailed` / `notification.messageUpdateFailed`. +- A custom UI that reopens or restores state after submitting must branch on the `MessageComposerSubmitResult`: on `'nothing-to-send'` the composer keeps its content; on `'failed'` the composer is already released and the content is on the failed message. -- **Customize how the preview renders** → provide a `SummarizedMessagePreview` component override (via `ComponentProvider`, or the `` / `` component props). It receives `SummarizedMessagePreviewProps` (`{ latestMessage, messageDeliveryStatus, participantCount }`). -- **Preview a specific message** rather than the channel's latest (e.g. a search result previewing the matched message) → pass the new `previewedMessage?: LocalMessage` prop to `ChannelListItem`. It defaults to the channel's reactive latest, `channel.messagePaginator.aggregateState.lastMessage`. -- **Behavior note:** the preview now honors the channel's `skip_last_msg_update_for_system_msgs` config (a system message no longer becomes the previewed / last message), so the preview and the channel's sort position agree. +### `preventClearingOnUnmount` is removed -### `Thread` renders children; its composition props are removed +`MessageComposer` no longer clears a composer supplied through `MessageComposerControllerProvider` when it unmounts (v14 cleared it unless this prop was set). That composer belongs to whoever supplied it. Drop the prop; if you relied on the unmount clearing a supplied composer, call `composer.clear()` once your UI is done with it. -`Thread` no longer renders a header, a message list and a composer of its own. It now matches `Channel` in both halves of the pattern: it **takes the instance as a required `thread` prop**, provides it to its subtree (so `ThreadProvider` is no longer written by hand around it), owns the lifecycle -- loading the thread, registering it with the `ThreadManager`, scoping audio playback -- and renders whatever you compose inside. +On unmount, `MessageComposer` saves a draft for every composer (`createDraft()` skips edits and draft-disabled composers) and clears only the thread's or channel's own. `MessageComposerControllerProvider` keeps its signature (`{ messageComposerController?: MessageComposer }`). ```tsx -// v14 - - - +import type { MessageComposer as Composer } from 'stream-chat'; +import { MessageComposer, MessageComposerControllerProvider } from 'stream-chat-react'; -// v15 - - - {virtualized ? ( - - ) : ( - - )} - - +export const InlineEditor = ({ composer }: { composer: Composer }) => ( + + + +); +// When the edit is abandoned, discard it yourself: composer.clear(); ``` -Removed props, and where each one goes: +### Attachment and poll availability -| v14 prop | v15 | -| --------------------------------------- | --------------------------------------------- | -| `additionalMessageComposerProps` | props on your `MessageComposer` child | -| `additionalMessageListProps` | props on your `MessageList` child | -| `additionalVirtualizedMessageListProps` | props on your `VirtualizedMessageList` child | -| `additionalParentMessageProps` | a `ThreadHead` override on `ComponentContext` | -| `autoFocus` | `` | -| `virtualized` | render the list you want | +`AttachmentSelector` used to decide which actions to offer from the channel type's raw server flags. It now reads the composer's resolved configuration (server flags ANDed with `client.config`). No React API changed, but the actions offered differ: -Consequences worth planning for: +- **File:** v14 required `upload-file` capability, `channelConfig.uploads` and `isUploadEnabled`. v15 requires only `attachmentManager.isUploadEnabled`, which is `attachments.enabled && hasAvailableUploadSlots && (customCdn || hasUploadPermission)`. + - With `customCdn: true`, the File action now appears for users without `upload-file`; v14 always hid it from them. + - With a custom `doUploadRequest` and `customCdn` unset, uploads the v14 client let through without `upload-file` are now blocked, for example paste, which skipped the capability check when `doUploadRequest` was set. The File action itself was already hidden in v14. +- **Poll:** reads the resolved `polls.enabled` instead of `channelConfig.polls`. +- **Location:** reads the resolved `location.enabled` instead of `channelConfig.shared_locations`. +- **Commands:** read `availableCommands` from `channel.configState` instead of `channel.getConfig().commands`. +- So `client.config.set({ messageComposer: { attachments | polls | location: { enabled: false } } })` now hides the matching action. -- **`Thread`'s defaults are gone.** It used to suppress in-list date separators whichever list it rendered; now `MessageList` follows its own default (separators **on**), so pass `withDateSeparator={false}` if you want the v14 look. The parent message still carries a date separator of its own. -- **The parent message is rendered by the message list**, not by `Thread` -- it has to sit inside the scroll container. Both lists resolve it from thread context, so `` inside a `ThreadProvider` shows it with no prop at all. The `head` prop is **removed** from `MessageList` and `VirtualizedMessageList`; override the rendering with a `ThreadHead` component on `ComponentContext` instead (on `VirtualizedMessageList`, `additionalVirtuosoProps.components.Header` still replaces the whole header slot). -- **`ThreadSlot` wraps its children instead of replacing them.** It resolves the thread bound to a slot and hands it to ``, exactly as `ChannelSlot` does for ``, so its `children` are now the thread's contents rather than a substitute for the whole panel. Anything that has to sit _outside_ the thread container (a panel shell your layout sizes, for instance) moves outside `ThreadSlot`; anything that needs thread context (`useChannel` resolves the thread's own channel) must sit inside. -- **`ThreadProvider` is still exported** for rendering thread-scoped UI outside a ``, but you no longer wrap `` in it. -- **`ComponentContext.ThreadHeader` is removed**, along with the `str-chat__thread--virtualized` class on the container. Compose the header you want directly. `ThreadHeaderProps` is down to `overrideTitle`: the `closeThread` and `thread` props are **removed**, because both are already reachable centrally -- the parent message comes from the thread in context, and closing goes through the workspace navigation (`closeThread(threadId)`), which an app customizes once via `ChatView`'s `deriveWorkspaceNavigation` rather than per header. The new `useCloseThread()` hook gives a custom header the same close behavior. +If your `doUploadRequest` stores files outside Stream, declare it. If it still posts to Stream (a wrapper adding headers, a proxy), leave `customCdn` unset: -### `ComponentContext.HeaderStartContent` → removed; `ChannelHeader` / `ThreadHeader` take `StartContent` / `EndContent` +```ts +client.config.set({ + messageComposer: { attachments: { customCdn: true } }, +}); +``` -In v14, `HeaderStartContent` on `ComponentContext` was rendered at the start of `ChannelHeader` (and of `ThreadHeader`, in the threads view only) -- typically an app-owned sidebar toggle. The SDK does not render either header itself; apps compose them, so the content now goes straight onto the header as a prop, and the slot is **removed** from `ComponentContext`. Both headers take: +`SimpleAttachmentSelector` still requires `upload-file` regardless. `isUploadEnabled` still does not re-render on an `own_capabilities` change; subscribe with `useChannelCapabilities` where that matters. -| Prop | Default | Shows | -| -------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `StartContent` | `WorkspaceNavigationBackButton` | Back, when the channel or thread is stacked over other content in its panel | -| `EndContent` | `WorkspaceNavigationCloseButton` | Close, for a panel that can be dismissed: a channel opened beside the primary one, or a thread panel | +### `useAttachmentManagerState`: `hasCustomDoUploadRequest` → `customCdn` -- **The v14 thread close button is now `ThreadHeader`'s `EndContent` default.** It still renders as `str-chat__close-thread-button`, only for threads the workspace navigation reports as dismissable, and closes through `useCloseThread()`. `ChannelHeader` gains the same pair for channels beside the primary one (`str-chat__close-channel-button`, `str-chat__channel-header__back-button`). -- **The buttons act on the panel they render in** -- the thread in context, otherwise the channel, in the panel a `Slot` / `ChannelSlot` / `ThreadSlot` declares. A layout that renders its own panels declares them with `WorkspacePanelProvider`, so the right panel closes when the same channel is open in two. Without a workspace navigation (e.g. outside `ChatView`) nothing can be dismissed, so they render nothing. -- **A prop replaces its button.** To keep the navigation next to your own control, render the button too: +`hasCustomDoUploadRequest` was only consulted as a proxy for "do uploads bypass Stream's storage rules", and an upload function that still posts to Stream stays subject to them. Read `customCdn`, the flag that decides. In plain JS the removed field is `undefined`, so a branch on it always takes the "no custom upload" path. To ask whether a custom upload function is installed, read `messageComposer.attachmentManager.hasCustomDoUploadRequest`. -```tsx +```ts // v14 - - - - -; +const { hasCustomDoUploadRequest } = useAttachmentManagerState(); +``` -// v15 - `SidebarToggle` stands for a control of your own -const HeaderStart = () => ( - <> - - - -); +```ts +import { useAttachmentManagerState } from 'stream-chat-react'; - - -; +export const useUploadsBypassStream = () => useAttachmentManagerState().customCdn; ``` -- **`HeaderEndContent` stays on `ComponentContext`** as the end of the list headers (`ChannelListHeader`, `ThreadListHeader`), which the SDK renders inside `ChannelNavigation` / `ThreadList`. It is unchanged. +The hook also returns `attachmentsEnabled`, `locationEnabled`, `pollsEnabled` and `maxNumberOfFilesPerMessage` (the resolved gates), and re-renders when the composer configuration changes. Only code that builds or mocks its return type is affected by the new fields. -### `disableDateSeparator` → `withDateSeparator`; `Thread.enableDateSeparator` → removed +### Upload helpers moved to `stream-chat` -`MessageList` and `VirtualizedMessageList` took `disableDateSeparator` while `Thread` took `enableDateSeparator` for the same setting — inverted polarity and two names for one idea, so the value had to be negated on the way down. Both lists now take **`withDateSeparator`**. The defaults are unchanged in behavior, only in spelling: +`getAttachmentPreviewUrl`, `isUploadConfirmationPending` and `hasPendingUploadState` (now `isPendingUpload`) are imported from `stream-chat` ([section 1](#imports-that-moved)). `useAttachmentUploadState` / `useAttachmentsUploadState` stay in `stream-chat-react`. -| Component | v14 | v15 | -| ------------------------ | ------------------------------ | --------------------------- | -| `MessageList` | `disableDateSeparator={false}` | `withDateSeparator={true}` | -| `VirtualizedMessageList` | `disableDateSeparator={true}` | `withDateSeparator={false}` | +### Composer types that follow `stream-chat` v10 -`Thread`'s own `enableDateSeparator` prop is **removed**, and nothing replaces it: `Thread` renders children, so the list says what it wants. +See [section 17](#17-other-stream-chat-v10-data-shapes): `CommandChip` / `CommandItem` commands, suggestion-list entities, `getQuotedMessageAuthor`, geolocation and link previews, and the attachment fields that moved under `custom`. -```tsx -// v14 — Thread forced separators off for whichever list it rendered - +### New: `UnsupportedAttachmentPreview` and `RemoveAttachmentPreviewButton` are exported -// v15 - - - - - -``` +Not a break. The composer lists a custom attachment type it has no preview for as unsupported, and `AttachmentPreviewList` takes the component for those as its `UnsupportedAttachmentPreview` prop. v14 exported only the prop types; both components are now exported from the root. If your app copied either out of the v14 source, import it instead: -Note the changed default that follows: a `MessageList` in a thread now shows date separators unless you pass `withDateSeparator={false}`. +```tsx +import type { ReactNode } from 'react'; +import { + AttachmentPreviewList, + type AttachmentPreviewListProps, + RemoveAttachmentPreviewButton, + UnsupportedAttachmentPreview, + type UnsupportedAttachmentPreviewProps, + WithComponents, +} from 'stream-chat-react'; -The rename also reaches `FloatingDateSeparator`, `useFloatingDateSeparator`, `useFloatingDateSeparatorMessageList`, `useEnrichedMessages` and `processMessages`, which all took `disableDateSeparator` (or, inside, `enableDateSeparator`). +const ProductPreview = (props: UnsupportedAttachmentPreviewProps) => { + const { attachment, removeAttachments } = props; + if (attachment.type !== 'product') return ; -## Message UI overrides consolidate on the `MessageUI` slot + return ( +
+ {attachment.title} + removeAttachments([attachment.localMetadata.id])} + /> +
+ ); +}; -The deprecated `Message` component override is removed from `ComponentContext`, and so is every `Message` **prop** that took a message UI component. There is now exactly one way to override the message UI — the `MessageUI` slot — plus `VirtualMessage` for the virtualized list. +const PreviewList = (props: AttachmentPreviewListProps) => ( + +); -| v14 | v15 | -| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `ComponentContext.Message` (deprecated in v14) | `ComponentContext.MessageUI` | -| `` | `MessageUI` override | -| `` | `MessageUI` override | -| `` | `VirtualMessage` override (falls back to `MessageUI`) | -| `` | `MessageUI` override, scoped with `WithComponents` (see below) | -| `additionalMessageListProps` / `additionalVirtualizedMessageListProps` / `additionalParentMessageProps` carrying `Message` | `MessageUI` override | +export const ComposerPreviews = ({ children }: { children: ReactNode }) => ( + + {children} + +); +``` -```tsx -// before - - - - -; +--- -// after - - - - -; -``` +## 14. Request handlers -**`VirtualMessage` still wins inside the virtualized list**, and only there — `VirtualizedMessageList` applies it to its own subtree's `ComponentContext`, so it no longer needs to be drilled through a prop and no longer affects messages rendered outside the list: +The `doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest` and `doMarkReadRequest` props on `Channel` are removed (v14 `Thread` never had them), and so is `useChannelEditMessageHandler`. In plain JS the props are ignored and the default requests are sent. Register handlers once on the client: ```tsx - + channel.sendMessage(message, options) + } > - - {/* renders CustomVirtualMessage */} - {/* renders CustomMessage */} - - + {/* ... */} +
``` -**Overriding only a thread's messages** (was ``) means scoping the slot to the thread's subtree: - ```tsx - - - -``` - -> **This can fail silently in untyped code.** In TypeScript every leftover is a compile error: `Message={…}` on `Message` / `MessageList` / `VirtualizedMessageList` / `Thread` fails with `TS2322`, and a stale `Message:` key in a `WithComponents` `overrides` object or in `additionalMessageListProps` fails with `TS2561` — which reports `Did you mean to write 'MessageUI'?`, so the compiler names the fix. In plain JS or behind an `any`, both are ignored with no warning and the **default** message UI renders. Grep for `Message=` and `Message:` rather than relying on the build to find them. - -A custom message UI receives no props — read everything from `useMessageContext()`. +import type { StreamChat } from 'stream-chat'; -## Context hooks: no `componentName` argument, and required contexts throw - -Two changes. The first is a compile break; the second only surfaces at runtime. +export const registerRequestHandlers = (client: StreamChat) => + client.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async (params, defaultRequest) => { + // inspect or rewrite params.message here + return defaultRequest(params); + }, + updateMessageRequest: (params, defaultRequest) => defaultRequest(params), + }, + }, + }); -### The optional `componentName` argument is removed +// ... takes no handler props. +``` -```tsx -// v14 -const { t } = useTranslationContext('EmojiPicker'); -const { textareaRef } = useMessageComposerContext('EmojiPicker'); +| Handler under `channel.requestHandlers` | Replaces | Type | +| --------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `sendMessageRequest` | `doSendMessageRequest`, `overrideSubmitHandler` | `OperationRequestFn<'send'>` | +| `updateMessageRequest` | `doUpdateMessageRequest` | `OperationRequestFn<'update'>` | +| `deleteMessageRequest` | `doDeleteMessageRequest` | `OperationRequestFn<'delete'>` | +| `retrySendMessageRequest` | the `retrySendMessage` prop | `OperationRequestFn<'retry'>` | +| `markReadRequest` | `doMarkReadRequest` | `CustomMarkReadRequestFn`, receives `{ channel, options }`, returns the response or `null` | -// v15 -const { t } = useTranslationContext(); -const { textareaRef } = useMessageComposerContext(); -``` - -Affects `useChatContext`, `useTranslationContext`, `useMessageContext`, `useComponentContext`, -`useMessageComposerContext`, `useMessageListContext` and `useMessageBounceContext`. TypeScript flags -every call site (`TS2554`). In plain JS the extra argument is simply ignored — harmless, but dead; -drop it. - -### Required contexts throw outside their provider - -In v14 these hooks logged a `console.warn` and returned `{}`, so a component rendered outside its -provider rendered blank and usually failed later with `Cannot read properties of undefined`. In v15 -the hook throws where the mistake is, naming itself and the provider it needs: - -| hook | must be rendered within | -| ---------------------------------- | --------------------------------------- | -| `useChatContext` | `` | -| `useMessageContext` | `MessageProvider` (a rendered message) | -| `useMessageComposerContext` | `MessageComposerContextProvider` | -| `useMessageListContext` | `MessageListContextProvider` | -| `useVirtualizedMessageListContext` | `VirtualizedMessageListContextProvider` | -| `useMessageBounceContext` | `MessageBounceProvider` | -| `usePollContext` | `PollProvider` | -| `useDialogManager` | `DialogManagerProvider` | -| `useSearchContext` | `SearchContextProvider` | -| `useSearchSourceResultsContext` | `SearchSourceResultsProvider` | -| `useContextMenuContext` | `ContextMenu` | -| `useChannelListItemContext` | `ChannelListItemUI` | -| `useGalleryContext` | `Gallery` | -| `useChatViewContext` | `ChatView` | - -**What to look for in the app:** any custom component that calls one of these hooks but is mounted -outside the provider — most often a custom message UI or attachment component rendered outside a -message, or a component mounted outside `` for a loading / empty state. In v14 these rendered -blank with a console warning; in v15 they throw. Move them inside the provider, or wrap them in it. - -These hooks are unchanged and still work outside their provider — no action needed: -`useTranslationContext` (renders the English copy), `useComponentContext` (empty override map), -`useChannelInstanceContext`, `useModalContext`, `useAriaLiveAnnouncer` and -`useMessageTranslationViewContext`. - -### Related type changes - -- `useChannelInstanceContext()` returns `Partial` — `channel` may be - `undefined`. Use `useChannel()` when a channel is required; it throws. -- `ChatViewContext` has no default value, so `useContext(ChatViewContext)` may be `undefined`. -- `MessageComposerContext` is typed `MessageComposerContextValue | undefined`. -- The gallery header renders the gallery item's own timestamp and no longer honors the - `ComponentContext.MessageTimestamp` override. +- **The signature is `stream-chat`'s.** A message handler takes `(params: { localMessage, message?, options? }, defaultRequest)` and must return `Promise<{ message }>`. Call `defaultRequest(params)` (with the params as given, or changed) to send the default way, offline queueing included. The v14 props took positional arguments and tolerated a `void` return. +- **Thread replies use the channel's handlers.** Send, update, delete and retry for a thread reply go through `thread.messageOperations`, which reads the parent channel's request handlers. The `thread` request handlers (`client.config.set({ thread: { requestHandlers } })`) hold only `markReadRequest`, for marking a thread read; a message handler registered under `thread` does not type-check and is never called. +- **Registration is per client**, not per mounted ``. If behavior has to differ between channels, branch inside one handler on `params.localMessage.cid`. +- `useChannelEditMessageHandler` applied `doUpdateMessageRequest` to the edit path; register `updateMessageRequest` instead. `messageComposer.update()` and `channel.messageOperations.update()` route through it. -## Configuration: components read the resolved config, not raw server flags +More on `client.config`: [`instance-configuration.md`](./instance-configuration.md). -The client combines each server flag with whatever the integrator registers through `client.config`, and -that combined value is what it enforces. Components used to read only the server's half, so a UI could -offer an action the client had already disabled. The sections below are the consequences of closing that -gap; they are all one change. +--- -### Attachment/poll availability now reads the composer's resolved config, not raw server flags +## 15. Configuration: components read the resolved config -`AttachmentSelector` used to decide which actions to offer by reading the channel type's raw server flags -off `ChannelStateContext` (`channelConfig?.uploads` / `.polls` / `.shared_locations`). It now reads the composer's **resolved** -configuration, which is those server flags already reconciled with whatever the integrator registered -through `client.config`. No React API changed — no prop, override key, or hook signature — but two -behaviours differ. +The client combines each server flag with what the integrator registers through `client.config`, and enforces that combined value. Components used to read only the server's half, so a UI could offer an action the client had already disabled. v15 components read the resolved value everywhere: -- **Declarative configuration now reaches the UI.** `client.config.set({ messageComposer: { attachments: { enabled: false } } })` (and the same for `polls` / `location`) hides the corresponding action. Previously only the server flag was consulted, so the menu offered actions the composer would refuse to compose. Either side can switch a feature off; neither can widen — see the LLC's `docs/instance-configuration.md`. -- **A custom `doUploadRequest` no longer implies a custom upload destination.** If yours uploads to storage Stream does not host, you must now declare it, or the File action disappears for users without the `upload-file` capability: +- the composer's attachment, poll and location actions ([section 13](#attachment-and-poll-availability)) +- the channel's typing, read, replies, reminders and commands gates ([section 9](#channel-configuration-channelconfig--channelconfigstate)) +- `useMarkRead` reads the resolved `readEvents.enabled`, so it honors `client.config.set({ channel: { readEvents: { enabled: false } } })` and re-runs when that changes - ```ts - client.config.set({ - messageComposer: { attachments: { customCdn: true } }, - }); - ``` +`channel.serverConfig` (the renamed `channel.getConfig()`) has no callers left in the SDK. Either side can switch a feature off; neither can widen it. - If your custom upload function still posts to Stream (a wrapper adding retries or headers, a proxy - through your own backend), leave `customCdn` alone — Stream's capability correctly applies again. +--- -`useAttachmentManagerState` additionally subscribes to the composer's configuration, so `isUploadEnabled` -and its siblings now re-render when that configuration changes. Purely additive; consumers need no change. +## 16. Dates on response types are unix-nanosecond numbers -> Note: `isUploadEnabled` still does **not** re-render on an `own_capabilities` change. That predates v15 -> and is unchanged here. Components that need it subscribe via `useChannelCapabilities`. +`stream-chat` types every server-sent date (`created_at`, `updated_at`, `last_read`, ... on every response and event) as the unix-nanosecond number the API puts on the wire. It is not a `Date` and not an ISO string. The type is **`TimestampNS`**, a branded `number`. Reading, comparing, sorting and subtracting work as with any number. -### `channel.getConfig()` → `channel.serverConfig`; `channel.config` is new +Caught at compile time: -The LLC renamed the channel's server-configuration accessor to a getter that says what it returns, which freed `config` to mean on `Channel` what it means on every other configurable class: +- **`new Date(timestamp)` is a type error.** `stream-chat` augments the global `DateConstructor`, because a nanosecond value is out of `Date`'s range and yields an Invalid Date whose `.toISOString()` throws. +- **A plain `number` or a `Date` is not assignable to a `TimestampNS` prop.** Mint one with `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value already in nanoseconds. Arithmetic drops the brand; wrap the result in `asTimestampNS`. +- `.getTime()` on a timestamp fails. -| Read this | For | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `channel.serverConfig` | The channel **type's** server flags (`replies`, `commands`, `url_enrichment`, …) | -| `useStateStore(channel.configState, …)` / `channel.config` | The **resolved** configuration — server flags ANDed with what you registered | +Not caught: -`channel.getConfig()` is **removed** — `channel.serverConfig` is a getter returning the same value, so migrating is dropping the parentheses. **If you mock it in tests, note it is a getter** — `vi.fn()` cannot stand in for one; use `Object.defineProperty(channel, 'serverConfig', { get: … })` or a plain value. +- **Date libraries** read a bare number as milliseconds: `dayjs(created_at).format()` returns the string `Invalid Date` and renders it. +- **Fallbacks and derived values**: `new Date(ts ?? Date.now())` and `new Date(Math.max(a, b))` compile. Convert first, then fall back. +- **Unit mix-ups**: comparing a timestamp with `Date.now()`, or adding a millisecond duration to one. +- **`t('timestamp.X', { timestamp })`** is not type-checked; a raw timestamp renders `Invalid Date`. -The React SDK's own `useMarkRead` switched from `channel.getConfig()?.read_events` to the resolved `readEvents.enabled` via `useStateStore`, so it now honours `client.config.set({ channel: { readEvents: { enabled: false } } })` **and** re-runs when that changes — a plain method call could not, being outside React's dependency graph. +### React types that changed -### `useAttachmentManagerState`: `hasCustomDoUploadRequest` → `customCdn`, plus the location and poll gates +| Type | v14 | v15 | +| -------------------------------------------------------------------------------------------------------- | --------------------- | --------------------------------- | +| `MessageList.headerPosition`, `insertIntro(messages, headerPosition)` | `number` (epoch ms) | `TimestampNS` | +| `ProcessMessagesParams.lastRead` | `Date \| null` | `TimestampNS \| null` | +| `VirtualizedMessageList` render-prop context `lastReadDate` | `Date \| undefined` | `TimestampNS \| null` | +| `getIsFirstUnreadMessage` params | `lastReadDate?: Date` | `lastReadAt: TimestampNS \| null` | +| `RenderedMessage.created_at` / `updated_at` / `deleted_at` / `pinned_at` | `Date` | `TimestampNS` | +| `useChannelMembershipState()` dates, `RenderTextMentionEntity` / `DefaultSuggestionListItemEntity` dates | `string` | `TimestampNS` | -`hasCustomDoUploadRequest` is **removed**. It answered "is a custom upload function installed?", which was only ever consulted as a proxy for "do uploads bypass Stream's rules?" — and the LLC now shows those are different questions: an upload function that still posts to Stream stays subject to them. Read `customCdn` instead, which is the flag that actually decides. +Compare and sort the raw numbers: ```ts // v14 -const { hasCustomDoUploadRequest } = useAttachmentManagerState(); +if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … } // v15 -const { customCdn } = useAttachmentManagerState(); +if (a.created_at < b.created_at) { … } ``` -The hook also now returns `attachmentsEnabled`, `locationEnabled`, `pollsEnabled` and `maxNumberOfFilesPerMessage`. The three gates are the composer's **resolved** answers, each already ANDed with the matching channel-type flag (`uploads`, `shared_locations`, `polls`) — so a menu can ask one hook instead of combining `channel.serverConfig` with client configuration itself. `location` and `polls` have no getter on the attachment manager; they exist only on the resolved configuration, which is why they are selected rather than read off the instance. - -### Per-component request-handler props removed — register them on `client.config` +### Presentational props still take `Date` -`doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest` and `doMarkReadRequest` are **removed** from both `` and ``. Register the handlers declaratively instead: +Components that exist to render a date are unchanged: `DateSeparator.date: Date`, `formatDate?: (date: Date) => string`, `Timestamp.timestamp?: Date | string`. Convert at that boundary with `convertTimestampToDate(ts)`, which returns `Date | undefined` (`undefined` for an absent or non-finite value). **Handle the `undefined`; do not cast it away.** `isDateSeparatorMessage` gates on `isDate(message.date)`, so a laundered `undefined` turns the separator into an ordinary, empty message row with no error. ```tsx // v14 - - … -; - -// v15 -client.config.set({ - channel: { - requestHandlers: { - sendMessageRequest: async ({ localMessage, message, options }) => ({ - message: await mySend(message, options), - }), - }, - }, -}); -…; + ``` -Three things change with it: - -- **The handler signature is the LLC's, not the prop's.** Handlers take a single params object (`{ localMessage, message, options }`) and must return `{ message }`. The props took positional arguments and tolerated a `void` return, because an adapter inside the SDK filled in the rest. -- **`thread` variants are gone as a separate shape.** Thread flows register under the `thread` key (`client.config.set({ thread: { requestHandlers: … } })`); the LLC resolves per instance. -- **Registration is global to the client**, not scoped to a mounted subtree. If you were passing different handlers to different `` instances, branch inside one handler on the `channel`/`cid` you receive. - -`useChannelEditMessageHandler` is removed with them. It existed to apply `doUpdateMessageRequest` to the edit path and fell back to `client.updateMessage` when no handler was passed — with the prop gone it wrapped nothing. Register an `updateMessageRequest` handler as above; `channel.updateMessageWithLocalUpdate` already routes through it. - -**Why.** The props and declarative registration wrote to the same slot, so the SDK carried a coordinator that tracked which mounted component owned each handler, restored the previous claimant on unmount, and re-applied everything whenever the LLC re-derived its configuration. All of that existed only to reconcile two ways of doing one thing. Removing the props deletes it — the SDK no longer arbitrates ownership, because there is only one owner. - -### Channel configuration: `ChannelStateContext.channelConfig` → `channel.configState` - -In v14 a component read the channel's configuration from `ChannelStateContext` as -`ChannelConfigWithInfo` — the channel type's raw server flags, copied into React state by `Channel`. -That context is gone, and so is the copy: configuration now lives on the channel as a reactive store. - -```ts -// v14 -const { channelConfig } = useChannelStateContext(); -if (channelConfig?.typing_events) { - /* … */ -} - -// v15 -const configStateSelector = ({ typingEvents }: ChannelConfig) => ({ - typingEventsEnabled: typingEvents.enabled, -}); +```tsx +import { convertTimestampToDate, type LocalMessage } from 'stream-chat'; +import { DateSeparator } from 'stream-chat-react'; -const channel = useChannel(); -const { typingEventsEnabled } = useStateStore(channel.configState, configStateSelector); +export const DayDivider = ({ message }: { message: LocalMessage }) => { + const createdAt = convertTimestampToDate(message.created_at); + return createdAt ? : null; +}; ``` -Two things change at once. - -**The value is now _resolved_, not raw.** Every gate is the server flag ANDed with whatever the -integrator registered through `client.config`, so it is the whole answer to "may this channel do X". -Reading the raw flag is what let a UI offer features the client had already disabled. Field names -change with the shape: - -| v14 (raw server flag) | v15 (resolved) | -| --------------------------------------- | ------------------------------ | -| `channelConfig?.typing_events` | `typingEvents.enabled` | -| `channelConfig?.read_events` | `readEvents.enabled` | -| `channelConfig?.replies` | `replies.enabled` | -| `channelConfig?.user_message_reminders` | `userMessageReminders.enabled` | -| `channelConfig?.commands` | `availableCommands` | - -`availableCommands` is the server's list, unchanged in shape — it is a list, not a gate, so there is -nothing to reconcile. It is renamed for two reasons: whether a command is _usable_ right now is -`messageComposer.isCommandDisabled(command)` and depends on the message context, so "enabled" would be -wrong; and `messageComposer.config.commands` is an unrelated field holding `{ sendValidator }`. +```tsx +// WRONG: the cast launders `undefined` into a required `Date`. + +// WRONG: invents "now", labelling a months-old message "Today". + +``` -**You subscribe rather than read.** Select only the settings you use: the controller keeps the -reference of every subtree a write does not touch, so a narrow selector means an unrelated -configuration change does not re-render you. The selector must live at module scope, since -`useStateStore` keys its subscription on it. +`nsToDate` / `nsToMs` are exported alongside it for values known to be present. **Outgoing request** date fields stay `Date` (filter bounds like `created_at_before`, `remind_at`, `message_timestamp`); use `nsToDate` when handing a server-sent timestamp back to the API. -`channel.config` holds the same resolved value as a plain getter. It does **not** subscribe, so a -`client.config.set()` will not reach the screen — use it only where you are already re-rendering for -another reason. +--- -Fields of `ChannelConfigWithInfo` that have no client-side counterpart are not on the resolved -configuration. Read them from `channel.serverConfig`, and be aware of what that means: you are getting -the server's half only, which is correct for a purely server-owned setting and wrong for anything the -integrator can also configure. +## 17. Other `stream-chat` v10 data shapes -**Everything the React SDK reads now goes through the resolved configuration.** `serverConfig` has no -callers left in `src/`. +React props typed with `stream-chat` types change with them. The React APIs are unchanged; build and read the values with the v10 shapes. -## Channel state moves to a single reactive store +**Channel, chat context and search:** -The client replaced the per-domain stores on `channel.state` with one -`StateStore`, subscribed to exactly like `thread.state`. Consumers previously had to -know which of ~6 sub-stores held a given field; there is now one store and one selector. +- `useChannelMembershipState(channel)`: dates are `TimestampNS`; `banned`, `channel_role`, `created_at`, `notifications_muted`, `shadow_banned`, `updated_at` are now required; `channel_role` is `string`; `custom` is added. +- `ChatContextValue.mutes` / `useChat().mutes`: `UserMuteResponse[]`. `getAppSettings`: `ReturnType`. +- `MessageSearchResultItem` / `ChannelByMessageSearchResultItemProps.item`: `MessageResponse & { channel?: ChannelResponse }`. -### The `*Store` handles are removed +**Messages:** -`readStore`, `typingStore`, `membersStore`, `watcherStore`, `ownCapabilitiesStore` and `mutedUsersStore` -are gone from `ChannelState`. Subscribe to `channel.state` itself: +- **Reaction sort:** `reactionDetailsSort` (on `Message`, `MessageList`, `VirtualizedMessageList`, `MessageContext`, `MessageReactions`, `MessageReactionsDetail`, `VirtuosoContext`), `MessageReactionsDetail.sort`, and the `sort` argument of `handleFetchReactions` / `useReactionsFetcher`: `ReactionSort` → `SortParamRequest[]` (`[{ field: 'created_at', direction: -1 }]`). +- **Delete options:** `handleDelete` / `useDeleteHandler` take `{ delete_for_me?, hard? }` (was `{ deleteForMe?, hardDelete? }`). +- **Mutes:** `isUserMuted(message, mutes)` takes `UserMuteResponse[]` (`target` is optional). +- **Moderation:** `isMessageBounced` / `isMessageBlocked` read only `message.moderation`; `moderation_details` is gone from `LocalMessage`. +- **Attachments:** `CardProps` / `AttachmentActionsProps` lose `duration`, `file_size`, `mime_type`, `waveform_data`, `latitude` and `longitude`, gain a required `custom`, and `giphy` is `Images`. Read `attachment.custom.duration` / `.file_size` / `.mime_type` / `.waveform_data`. +- **Location:** `Attachment.attachments`, `AttachmentContainerProps.attachment`, `Geolocation.location`, `GeolocationContainer.location`, `GeolocationAttachmentMapPlaceholderProps.location`: `SharedLocationResponse` → `SharedLocationResponseData` (no `type`, `latitude` or `longitude` on the union). +- **Polls:** `PollOptionSelector.option`: `PollOptionResponseData`; `PollVote.vote`, `PollVoteListing.votes`, `usePollOptionVotesPagination().votes`, `usePollAnswerPagination().answers`: `PollVoteResponseData`. +- **Rendered messages:** `RenderedMessage` and `EventComponent.message` follow `LocalMessage` (`args`, `before_message_send_failed`, `channel`, `command_info`, `moderation_details` removed; `user` and `text` required). `RenderTextMentionEntity` follows `UserResponse` (`username`, `anon`, `shadow_banned`, `privacy_settings`, `push_notifications`, `notifications_muted`, `channel_last_read_at` removed). +- `calendar` / `calendarFormats` on `DateSeparator`, `EventComponent`, `MessageTimestamp`, `MessageEditedIndicator`, `TimestampProps` were widened; not breaking for callers. -```ts +```tsx // v14 -useStateStore(channel.state.ownCapabilitiesStore, ownCapabilitiesSelector); - -// v15 -useStateStore(channel.state, ownCapabilitiesSelector); + ``` -**The selector does not change.** `ChannelStateData` is flat and keeps the same top-level keys the old -per-store shapes used, so an existing `(s: OwnCapabilitiesState) => O` stays contravariantly assignable -to `(s: ChannelStateData) => O`. In practice the migration is: delete the `.Store` segment. - -The state gained the channel-level slices UI SDKs used to re-derive by hand — `data`, `membership`, -`muteStatus`, `initialized` / `offlineMode` / `pendingDisposal`, `active`, `aiState`, `watchStatus`, -`memberCount`. `messagePaginator`, `pinnedMessagesPaginator`, `messageComposer` and `configState` stay -separate. +```tsx +import { useMessageContext } from 'stream-chat-react'; -Two type renames come with it: +export const DeleteForMe = () => { + const { handleDelete } = useMessageContext(); + return ( + + ); +}; +``` -| v14 | v15 | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `WatcherState` | `ChannelWatchState` (it now also answers whether _we_ are watching) | -| `channel.disconnected` | `channel.pendingDisposal`, read-only; set by `channel.disconnect()` (**removed outright — no deprecated alias**) | +**Composer:** -`pendingDisposal` is one-way and terminal: the paginators are disposed, subscriptions unregistered, and -the channel store drops the channel, so the instance is never revived. It is read-only: `channel.disconnect()` sets -it, which stream-chat calls when a channel ends (deleted, the user removed from it, `disconnectUser()`, or -released as unused). Call `channel.disconnect()` yourself to finish an instance you are done with; it -doesn't remove the channel from the channel store. `getClient()` -throws on such a channel — a reference held across a `disconnectUser()` now fails loudly rather than -quietly requesting on a client with no user. +- `CommandChip.command`, `CommandItem.entity`, `MessageComposerCommand.command`: `CommandResponse` → `Command`, where `args`, `description` and `set` are required. +- `DefaultSuggestionListItemEntity`: `anon`, `channel_last_read_at`, `notifications_muted`, `privacy_settings`, `push_notifications`, `shadow_banned` and `username` are gone (v10 user custom fields live in `entity.custom`); dates are `TimestampNS`; `teams_role` is `Record`. +- `getQuotedMessageAuthor` (on `QuotedMessagePreview` and its props): `(message: LocalMessage | MessageResponse) => string`. A callback typed with `LocalMessage` only is rejected under `strictFunctionTypes`. `QuotedMessagePreviewUI.quotedMessage`: `LocalMessage | MessageResponse`. +- `GeolocationPreviewProps.location`, `useAttachmentsForPreview().location`: `StaticLocationPreview`. +- `LinkPreviewCard.linkPreview`: `LinkPreview` extends the v10 `Attachment`, whose `custom` is required. +- Attachment previews and the recorder's voice recording: `duration`, `file_size`, `mime_type` and `waveform_data` moved under `attachment.custom` (custom `AttachmentPreviewList` parts; `recordingController.recording.custom.duration` / `.custom.waveform_data`). -### Reading channel state without re-rendering +Other `stream-chat` v10 type changes an app may hit (the attachment `Action` type requires `text` and `type`, `unBlockUser` → `unblockUser`, `ReminderResponse` → `ReminderResponseData`, `User` no longer accepting `name` / `image` (type user data as `ClientUser`), channel `members` as `{ user_id }` objects, `ChannelState.messages` replaced by `channel.messagePaginator`) are documented in `stream-chat`'s own migration notes. -The convenience getters (`channel.state.members` / `.read` / `.typing` / `.watchers`) are **not -reactive** — they return a one-shot snapshot. Use `useStateStore(channel.state, selector)` for anything -that must re-render. Two rules that bite: +--- -- **Selectors must be module-scope**, because `useStateStore` keys its subscription on - `[store, selector]`; an inline `(s) => ({ … })` re-subscribes every render. -- **Selectors must return direct slice references**, not freshly-computed values. `(s) => ({ read: s.read })` - is fine; `(s) => ({ members: Object.values(s.members) })` returns a new array every call and re-renders - forever. Derive in the component, after the selector. +## 18. i18n -Drive unread badges off `read`, not `unreadCount`: the latter is a non-reactive getter derived from -`read[ownUserId].unread_messages`, so there is nothing separate to select. +Full detail: [`i18n-v15-migration.md`](./i18n-v15-migration.md). The breaks, all of which fail **silently** unless the app's dictionaries are typed: -### `AIStates` moves to `stream-chat` +1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, `pt`, `ru` and `tr` dictionaries are removed with their dayjs calendar formats, and so are the `deTranslations` ... `trTranslations` / `enTranslations` exports, `isLanguageSupported` (use `i18n.registeredLanguages.has(lang)`) and `SupportedTranslations`. +2. **Keys are namespaced identifiers**, not the English text. `t('Send')` → `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. An unrenamed key never matches, and the English copy renders. Every old key maps to one new key in [`i18n-v15-key-map.json`](./i18n-v15-key-map.json). +3. **`t` is strictly typed** (`StreamTFunction`). `t('English text')` and `t(someString)` no longer compile (`TS2769`). App-owned strings and runtime keys go through `asDynamicKey`: `t(asDynamicKey('myApp.removeMembers.ariaLabel'), 'Remove channel members')`. +4. **`Streami18n`'s API changed:** `getTranslators()` → `init()`; `setLanguage()` returns `void`; `t` is read-only (`overrideTFunction(fn)`); `registerSetLanguageCallback` → `i18n.state.subscribeWithSelector(...)`; `getAvailableLanguages()` → `registeredLanguages`; `geti18Instance()` → `i18nInstance`; custom formatter factories receive a `FormatterContext`, not the `Streami18n` instance. -`stream-chat-react` no longer exports it. +| Symptom | Cause | Fix | +| ---------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `registerTranslation(...)` / `translationsForLanguage` present | keys are the old English strings | Rename every key; type the dictionary `TranslationDictionary` so stale keys fail to compile | +| `language: 'de'` (or any non-`en`) with no dictionary registered | the built-in one is gone | Register a dictionary | +| non-English dates render in English | the dayjs locale is no longer bundled | `import 'dayjs/locale/de.js'` + `dayjsLocaleConfigForLanguage` | +| imports of `deTranslations` etc. | exports removed | Recover from a v14 tag, then rename | +| `t('...')` / `t(variable)` compile errors | strict `t` | Namespaced key with inline default, or `asDynamicKey` | +| a dictionary declares `'channel.channelMissing.text'` | the key is removed (the state it labelled is gone) | Delete the entry (`TS2353` in a `TranslationDictionary`) | -```ts +```tsx // v14 -import { AIStates } from 'stream-chat-react'; - -// v15 -import { AIStates } from 'stream-chat'; + ``` -The values are unchanged, but it is now a literal-typed `as const`, so an inferred array of its members -no longer accepts the wide `AIState` that `useAIState` returns. Widen the array: +```tsx +import { asDynamicKey, useTranslationContext } from 'stream-chat-react'; -```ts -const STOPPABLE: readonly AIState[] = [AIStates.Thinking, AIStates.Generating]; -STOPPABLE.includes(aiState); +export const RemoveButton = ({ label }: { label: string }) => { + const { t } = useTranslationContext(); + return ( + + ); +}; ``` -`useAIState`'s public shape (`{ aiState }`) is unchanged, but it now reads the reactive slice instead of -holding its own `ai_indicator.*` subscriptions — so it also honors `ai_indicator.stop` and the -connection-loss reset, which the event-based version had no way to do. `useIsChannelMuted` likewise -reads `muteStatus` instead of subscribing to `notification.channel_mutes_updated`. - -### `` declares the channel active, and owns its message window +Four formatter keys carry English day words inside their `calendarFormats` (`timestamp.DateSeparator`, `timestamp.ReminderNotification`, `timestamp.ChannelPreviewTimestamp`, `timestamp.ChannelDetailPinnedMessageTimestamp`) and must be overridden to translate Today / Tomorrow / Yesterday / Last; `dayjsLocaleConfigForLanguage` does not reach them. -`` now calls `channel.activate()` on mount and the release function it returns on unmount -(refcounted, so several consumers can hold one instance), and calls `channel.reload()` on -`connection.recovered`. +--- -These two go together and **a custom channel surface must do both**. While a channel is active, the -client deliberately skips re-seeding its message list on channel-list hydration and on reconnect — its -smaller page would perturb a larger scrolled-back window — and hands that window to the consumer. -Nothing in the client calls `reload()` for you: +## 19. CSS and markup -```ts -useEffect(() => { - const release = channel.activate(); - return release; -}, [channel]); +| v14 markup or class | v15 | +| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `div.str-chat__main-panel` (+ `--thread-open`), rendered by `Window` | Gone. `Channel` renders `div.str-chat__channel` as the content column (flex column with header, list, composer). Target `.str-chat__thread-container` / `.str-chat__thread` for the thread panel. The v14 getting-started CSS on `#root .str-chat__main-panel` matches nothing. | +| `div.str-chat__container` inside `Channel` (or `customClasses.chatContainer`) | Gone. `customClasses.chatContainer` is still in the type but applied nowhere; remove it. | +| `.str-chat__channel-list-inner`, `.str-chat__channel-list-inner__main` (from `ChannelListUI`, which held `role="listbox"`) | Gone. Target `.str-chat__channel-list .str-chat__infinite-scroll-paginator` (the `role="listbox"` scroll container, inside `ChannelNavigation`) and `.str-chat__infinite-scroll-paginator__content`. A bare `` has no `.str-chat__channel-list` ancestor. | +| The channel list root carried `str-chat str-chat__channel-list` and `customClasses.channelList` | `ChannelList` renders no wrapper of its own; `ChannelNavigation` renders `str-chat__channel-list`, and the `str-chat ` scope comes from the `ChatView` root (or `Channel`). A list outside `ChatView` with no `.str-chat` ancestor gets no design tokens: wrap it in an element with `className={clsx('str-chat', theme)}`. `customClasses.channelList` is not applied; use a CSS-layer override. | +| `.str-chat__chat-view__channels` / `.str-chat__chat-view__threads` | Rendered only when `ChatView` is given `views`. With the built-in workspace layout, content sits in `.str-chat__chat-view__workspace-layout`, `.str-chat__chat-view__workspace-layout-slots`, `.str-chat__chat-view__slot` (`--hidden`) and `.str-chat__chat-view__slot-layer`. | +| `.str-chat__thread--virtualized` on the thread container | Not applied. Scope by the list you render (`.str-chat__thread .str-chat__virtual-list`, or your own class). | +| `.str-chat__quoted-message-preview` text inherited the surrounding colour | It sets `color: var(--str-chat__chat-text-incoming)`, and `--own` `var(--str-chat__chat-text-outgoing)`, matching the bubble backgrounds. In the default light theme your own quotes change to the outgoing bubble's text colour. Remove contrast workarounds written for v14; theme with those two tokens. | -client.on('connection.recovered', () => { - if (!channel.pendingDisposal) channel.reload().catch(handleError); -}); -``` +Consumers keep overriding through CSS layers (see `README.md`); never edit compiled CSS. -Skip the reload and the failure is quiet: the list keeps whatever it held while offline, and hard -deletes that happened meanwhile are never reconciled — they reach other clients via no event, so only a -re-query surfaces them. Guard on `pendingDisposal` because `reload()` goes through `getClient()`. +--- -### Test mocks +## 20. Tests -`channel.state` **is** a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with -`getLatestValue is not a function`. Build a real store (the SDK ships -`mock-builders/generator/channelState.ts` for its own suite). Also: +**Mock real stores.** `channel.state` is a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with `getLatestValue is not a function`. Build a real channel (`client.channelManager.ensure(...)`) and seed state on it. Build a `Thread` mock from a real `client.threads` instance too. | v14 mock | v15 | | --------------------------------- | ------------------------------------------------------------------------- | | `vi.spyOn(channel, 'muteStatus')` | seed `channel.state.partialNext({ muteStatus })` | | `channel.disconnected = true` | `channel.state.partialNext({ pendingDisposal: true })` (it has no setter) | +| `vi.fn()` for `getConfig` | `Object.defineProperty(channel, 'serverConfig', { get: ... })` | +| a hand-built `ChatContextValue` | add `channelManager: client.channelManager` | + +**Fixtures model the wire.** A fixture that hands the SDK a `Date` diverges from runtime behavior. Generate timestamps as `TimestampNS` (`dateToNs(new Date(...))`, `msToNs(...)`, or `asTimestampNS(...)` for a literal such as `created_at: asTimestampNS(0)`). + +**Render inside the providers.** Components that call the required context hooks throw outside them ([section 10](#required-context-hooks-throw-outside-their-provider)); render them inside `` and the relevant `Channel`, `Thread`, message or composer. + +--- + +## Index of breaking changes + +`BC-###` IDs refer to `ai-docs/v15-breaking-changes.md`. + +| ID | Change | Section | +| ------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| BC-001 | `stream-chat` ^10, dependencies, single `@stream-io/state-store`, new subpaths | [1](#packages) | +| BC-002 | Timestamps are `TimestampNS` | [16](#16-dates-on-response-types-are-unix-nanosecond-numbers) | +| BC-003 | `ChannelStateContext` / `ChannelActionContext` removed | [9](#channelstatecontext-and-channelactioncontext-are-removed) | +| BC-004 | `TypingContext` removed | [9](#typingcontext-is-removed) | +| BC-005 | `ComponentContext` slots removed | [10](#removed-componentcontext-slots) | +| BC-006 | Context hooks take no `componentName` | [10](#context-hooks-take-no-componentname-argument) | +| BC-007 | Required context hooks throw | [10](#required-context-hooks-throw-outside-their-provider) | +| BC-008 | `useMessageComposerContext` takes no argument and throws | [10](#required-context-hooks-throw-outside-their-provider) | +| BC-009 | `ChannelUnreadUiState` removed | [9](#the-unread-snapshot-channelunreaduistate-is-removed) | +| BC-010 | `Window` removed | [3](#window-and-windowprops-are-removed) | +| BC-011 | `ChatView` moved to `slot-layout` | [1](#imports-that-moved), [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| BC-012 | `ChatView` rebuilt around slots | [3](#chatview-moved-to-stream-chat-reactslot-layout-and-was-rebuilt-around-slots) | +| BC-013 | `setActiveChannel` / `channel` removed; workspace navigation | [2](#chatcontext-fields), [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | +| BC-014 | `ChatContext.channelsQueryState` removed | [2](#chatcontext-fields) | +| BC-015 | `ChatContext.latestMessageDatesByChannels` removed | [2](#chatcontext-fields) | +| BC-016 | `useChatContext` argument, throws, `channelManager` | [2](#chatcontext-fields), [10](#context-hooks-take-no-componentname-argument) | +| BC-017 | `useChat` params and results; `Chat.defaultLanguage` | [2](#usechat-and-chatdefaultlanguage) | +| BC-018 | `useCreateChatClient` `options` → `config` | [2](#usecreatechatclient-options--config) | +| BC-019 | `Channel.channel` required, `EmptyPlaceholder` removed | [5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state) | +| BC-020 | `Channel` no longer watches; `getChannel` removed | [5](#channel-no-longer-watches-the-channel) | +| BC-021 | `document.title`, `activeUnreadHandler` | [5](#channel-no-longer-writes-documenttitle) | +| BC-022 | `markReadOnMount` removed | [5](#markreadonmount-is-removed-the-list-marks-read) | +| BC-023 | `do*Request` props, `useChannelEditMessageHandler` | [14](#14-request-handlers) | +| BC-024 | Attachment props moved to `Attachment` | [5](#props-that-moved-off-channel) | +| BC-025 | Mention handlers moved to the lists | [5](#props-that-moved-off-channel) | +| BC-026 | `skipMessageDataMemoization` removed | [5](#props-that-moved-off-channel) | +| BC-027 | `Channel` lifecycle behavior | [5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump) | +| BC-028 | `ChannelList` takes a `ChannelPaginator` | [6](#channellist-is-driven-by-a-channelpaginator) | +| BC-029 | `ChannelListUI` removed | [6](#channellistui-is-removed-use-the-list-slots) | +| BC-030 | `usePaginatedChannels`, `useConnectionRecoveredListener`, query types removed | [6](#channel-list-hooks-types-and-utilities-are-removed) | +| BC-031 | Channel-list utilities removed | [6](#channel-list-hooks-types-and-utilities-are-removed) | +| BC-032 | `ChannelListContext` fields removed | [6](#channellistcontext-lost-channels-hasnextpage-loadnextpage-and-setchannels) | +| BC-033 | `ChannelListItem` props | [6](#channellistitem-props) | +| BC-034 | `HeaderStartContent` → `StartContent` / `EndContent` | [8](#8-channel-and-thread-headers) | +| BC-035 | `Thread` takes `thread`, renders children | [7](#thread-takes-a-thread-prop-and-renders-its-children) | +| BC-036 | `ThreadHeader` props, `ComponentContext.ThreadHeader` | [7](#threadheader-lost-closethread-and-thread) | +| BC-037 | `ThreadProvider` no longer mounts a `Channel` | [7](#threadprovider-no-longer-wraps-its-children-in-a-channel) | +| BC-038 | `ThreadList` reload and selection | [7](#threadlist), [4](#4-selecting-channels-and-opening-threads-workspace-navigation) | +| BC-039 | Search results not watched; selection through navigation | [4](#search-results) | +| BC-040 | Lost-connection notification | [2](#the-lost-connection-notification) | +| BC-041 | `messageActions` and related exports removed | [10](#messageactions-and-message_actions-are-removed) | +| BC-042 | Message UI overrides on `MessageUI` | [10](#message-ui-overrides-consolidate-on-the-messageui-slot) | +| BC-043 | `MessageContext.mutes` / `messageIsUnread` removed | [12](#messagecontextmutes-and-messageisunread-are-removed) | +| BC-044 | `retrySendMessage`, `useRetryHandler` | [12](#retrysendmessage-and-useretryhandler) | +| BC-045 | Opening a thread from a message | [4](#messageopenthread-and-the-lists-openthread) | +| BC-046 | List pagination and state props | [11](#pagination-and-state-props-are-removed-or-ignored) | +| BC-047 | `messages` prop ignored | [11](#the-messages-prop-is-ignored) | +| BC-048 | `threadList` / `head` removed | [11](#threadlist-and-head-are-removed-lists-detect-the-thread) | +| BC-049 | `withDateSeparator` | [11](#disabledateseparator--withdateseparator) | +| BC-050 | `AIStates` from `stream-chat` | [12](#aistates-moved-to-stream-chat) | +| BC-051 | One voice message at a time | [12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time) | +| BC-052 | `usePinHandler` no `canPin` | [12](#usepinhandler-no-longer-returns-canpin) | +| BC-053 | `VirtuosoContext` reshaped | [11](#virtuosocontext-the-context-of-additionalvirtuosopropscomponents) | +| BC-054 | `RenderMessagesOptions` | [11](#custom-rendermessages-gets-different-options) | +| BC-055 | Low-level list hooks and utils | [11](#low-level-list-hooks-and-utils) | +| BC-056 | `headerPosition` / `insertIntro` in nanoseconds | [11](#headerposition-and-insertintro-take-unix-nanoseconds) | +| BC-057 | `DateSeparatorMessage` | [11](#dateseparatormessage) | +| BC-058 | Standalone `Message` needs a channel | [12](#a-standalone-message-needs-a-channel-in-context) | +| BC-059 | Gallery header timestamp | [12](#the-gallery-header-ignores-the-messagetimestamp-override) | +| BC-060 | `overrideSubmitHandler` removed | [13](#overridesubmithandler-is-removed) | +| BC-061 | `preventClearingOnUnmount` removed | [13](#preventclearingonunmount-is-removed) | +| BC-062 | `handleSubmit` removed | [13](#handlesubmit-is-removed) | +| BC-063 | Failed sends do not restore the composer | [13](#a-failed-send-or-edit-no-longer-restores-the-composer) | +| BC-064 | `hasCustomDoUploadRequest` → `customCdn` | [13](#useattachmentmanagerstate-hascustomdouploadrequest--customcdn) | +| BC-065 | `AttachmentSelector` follows resolved config | [13](#attachment-and-poll-availability) | +| BC-066 | Upload helpers moved to `stream-chat` | [1](#imports-that-moved), [13](#upload-helpers-moved-to-stream-chat) | +| BC-067 | English-only bundle, namespaced keys | [18](#18-i18n) | +| BC-068 | Strict `t` | [18](#18-i18n) | +| BC-069 | `Streami18n` API | [18](#18-i18n) | +| BC-070 | Channel and navigation types follow v10 | [17](#17-other-stream-chat-v10-data-shapes) | +| BC-071 | Message-area types follow v10 | [17](#17-other-stream-chat-v10-data-shapes) | +| BC-072 | Composer types follow v10 | [17](#17-other-stream-chat-v10-data-shapes) | +| BC-073 | `Window` / `str-chat__main-panel` / `str-chat__container` CSS | [19](#19-css-and-markup) | +| BC-074 | Channel list CSS | [19](#19-css-and-markup) | +| BC-075 | `ChatView` view wrappers | [19](#19-css-and-markup) | +| BC-076 | `str-chat__thread--virtualized` | [19](#19-css-and-markup) | +| BC-077 | Quote and edit preview colours | [19](#19-css-and-markup) | diff --git a/ai-docs/i18n-v15-key-map.json b/ai-docs/i18n-v15-key-map.json index a6dc6f693..5db06c4bb 100644 --- a/ai-docs/i18n-v15-key-map.json +++ b/ai-docs/i18n-v15-key-map.json @@ -1921,7 +1921,7 @@ "prose": true }, "Send message request failed": { - "key": "messageComposer.sendMessageFn.sendMessageRequestFailed.text", + "key": "notification.messageSendFailed", "prose": true }, "aria/Stop AI Generation": { @@ -1929,7 +1929,7 @@ "prose": true }, "Edit message request failed": { - "key": "messageComposer.updateMessageFn.editMessageRequestFailed.text", + "key": "notification.messageUpdateFailed", "prose": true }, "New Messages!": { diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index ad2375c67..9c4024267 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -6,9 +6,10 @@ Three breaking changes, all in v15: `pt`, `ru` and `tr` dictionaries are gone, along with their `dayjs` locale data. 2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')` became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. -3. **The translation runtime moved into `stream-chat`**, shared with the React Native SDK. The class - keeps its name, two of its methods changed shape, and two timestamp edge cases render differently - — see [The shared runtime](#the-shared-runtime). +3. **The translation runtime moved into `@stream-io/i18n`**, a package shared with the React Native + SDK. The class keeps its name and you still import it from `stream-chat-react`, but several of its + members were removed or changed shape, and two timestamp edge cases render differently — see + [The shared runtime](#the-shared-runtime). Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically imported and copied into `Streami18n` at construction, so they shipped even if you never set @@ -16,17 +17,20 @@ imported and copied into `Streami18n` at construction, so they shipped even if y ## Do I need to do anything? -| If you… | Action | -| ------------------------------------------------ | ---------------------------------------------- | -| use the SDK in English and never touched i18n | **Nothing.** | -| passed `translationsForLanguage` | Rename your keys — see below | -| called `registerTranslation()` | Rename your keys — see below | -| used a built-in non-English language | Supply the dictionary yourself — see below | -| relied on non-English date formats | Import the `dayjs` locale yourself — see below | -| imported `deTranslations` … `trTranslations` | Those exports are removed | -| construct `new Streami18n(...)` | **Nothing** — same name, same options object | -| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below | -| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both | +| If you… | Action | +| --------------------------------------------------- | -------------------------------------------------- | +| use the SDK in English and never touched i18n | **Nothing.** | +| passed `translationsForLanguage` | Rename your keys — see below | +| called `registerTranslation()` | Rename your keys — see below | +| used a built-in non-English language | Supply the dictionary yourself — see below | +| relied on non-English date formats | Import the `dayjs` locale yourself — see below | +| imported `deTranslations` … `trTranslations` | Those exports are removed | +| called `isLanguageSupported()` | Use `i18n.registeredLanguages.has(lang)` | +| call `t('Some English')` or `t(variable)` | `t` is typed — see below | +| construct `new Streami18n(...)` | **Nothing** — same name, same options object | +| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below | +| call other `Streami18n` members or write formatters | See [The shared runtime](#the-shared-runtime) | +| declared `i18next` or `dayjs` yourself | You can drop them; `@stream-io/i18n` supplies both | ## Renaming your keys @@ -140,6 +144,39 @@ Keys are namespaced after the source tree, so they are predictable from the comp `common.*`. The last segment is the modality: `.label`, `.ariaLabel`, `.placeholder`, `.title`, `.description`, `.text`. +## Calling `t` in your own components + +`t` from `useTranslationContext()` is typed `StreamTFunction`, so a call has to name a key the SDK +knows. `t('Some English')` and `t(someString)` no longer compile (`TS2769 No overload matches this +call`); at runtime an unknown key still renders its default, or the key itself. The four accepted +shapes: + +```ts +t('common.close.ariaLabel', 'Close'); // an SDK key with its inline English default +t('channelDetail.channelMembersView.members.title', { count }); // a plural key: `count` is required +t('timestamp.MessageTimestamp', { timestamp }); // a formatter key, no default +t(asDynamicKey(runtimeKey), 'Default copy'); // a key known only at runtime +``` + +App-owned copy goes through `asDynamicKey` with a key of your own, and the dictionary that supplies it +is typed `LooseTranslationDictionary`: + +```tsx +// v14 + + +// v15 + +``` + +`asDynamicKey`, `StreamTFunction`, `TranslationKey`, `DynamicTranslationKey` and `TranslationCatalog` +are exported from `stream-chat-react`. The brand is required, so every escape from the checked set is +deliberate and greppable. The same `StreamTFunction` reaches the label-data callbacks +(`ChannelListItemLabelData.t`, `ThreadListItemLabelData.t`, `WithT.t`), and +`TranslationContextValue.userLanguage` is now a plain `string`. + ### Plurals The SDK's own copy only needs `_one` / `_other`. Supply whichever categories your language @@ -245,9 +282,10 @@ _old_ natural-language keys, so it needs the same rename as your own overrides. ## The shared runtime -`Streami18n` used to live in this package. It now lives in `stream-chat` and is shared with -`stream-chat-react-native`, so both SDKs behave identically and a fix reaches both at once. You still -import it from here, and it still carries this SDK's own key catalog and copy. +`Streami18n` used to live in this package. It now lives in `@stream-io/i18n`, a regular dependency of +this package that `stream-chat-react-native` shares, so both SDKs behave identically and a fix reaches +both at once. You still import it from `stream-chat-react`, and it still carries this SDK's own key +catalog and copy. Do not import `@stream-io/i18n` directly: it is not part of this SDK's public API. ### `getTranslators()` is now `init()` @@ -320,7 +358,54 @@ with no dictionary behind it. Also now internal, none of them documented before: `translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To -register a dayjs locale directly, `@stream-io/i18n` exports `addOrUpdateDayjsLocale()`. +configure a dayjs locale, which is what `addOrUpdateLocale()` did, pass its config to +`registerTranslation(language, dictionary, dayjsLocaleConfig)` or `dayjsLocaleConfigForLanguage` to the +constructor (see [Date and time](#date-and-time)). `stream-chat-react` does not re-export a standalone +locale helper. + +### Other removed and changed members + +| v14 | v15 | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `registerSetLanguageCallback(cb)` / `setLanguageCallback` | `i18n.state.subscribeWithSelector(({ t }) => ({ t }), handler)`; `` already subscribes | +| `geti18Instance()` | `i18n.i18nInstance` (a read-only field) | +| `isLanguageSupported(lang)`, `SupportedTranslations` | `i18n.registeredLanguages.has(lang)`; there is no fixed set of supported languages | +| `Dayjs`, `i18nextConfig` | None (internal) | +| `DateFormatterOptions` | None | +| `init()` resolving `{ t, tDateTimeParser }` | `init()` resolves the full `Streami18nState` (`{ initialized, language, t, tDateTimeParser }`); destructuring is unchanged | + +```ts +// v14 +i18n.registerSetLanguageCallback((nextT) => setT(() => nextT)); + +// v15 — returns an unsubscribe function +const unsubscribe = i18n.state.subscribeWithSelector( + ({ t }) => ({ t }), + ({ t: nextT }) => setT(() => nextT), +); +``` + +### Custom formatters receive a `FormatterContext` + +A `FormatterFactory` used to receive the `Streami18n` instance. It now receives a structural +context with only `currentLanguage`, `logger`, `dateTimeParser`, `tDateTimeParser`, `translate` and +`timezone`: + +```ts +// v14 +const myFormatter: FormatterFactory = (streamI18n) => (value) => + streamI18n.t('...') + streamI18n.DateTimeParser(value).format('LL'); + +// v15 +const myFormatter: FormatterFactory = (context) => (value) => + context.translate('...') + context.dateTimeParser(value).format('LL'); +``` + +`TDateTimeParserOutput` is now `string | number | Date | DateTimeLike`, where `DateTimeLike` is a +structural type exposing only `format`, `calendar?`, `fromNow?`, `diff`, `startOf` and `valueOf`. Cast +to your date library's type if you call anything else. `isDate(value: TDateTimeParserOutput)` is now a +type guard. `moment` is still accepted as a `DateTimeParser`. `defaultTranslatorFunction` is typed +`StreamTFunction`. ### `useChat` no longer returns `translators` @@ -367,8 +452,9 @@ that is not a `Streami18n` warns and falls back to a default instance rather tha ### You no longer need `i18next` or `dayjs` in your own dependencies -`stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK, -remove them — and if you keep them, **match `stream-chat`'s ranges**. Two copies of `dayjs` means +`@stream-io/i18n` depends on both (`dayjs ^1.11.23`, `i18next ^26`), so they arrive transitively. +`stream-chat` v10 depends on neither. If you declared them only for this SDK, remove them — and if you +keep them, **match `@stream-io/i18n`'s ranges**. Two copies of `dayjs` means your `import 'dayjs/locale/de'` registers the locale on a different instance than the one formatting dates, and dates silently stay English: @@ -467,14 +553,14 @@ expressions) and the postProcessor directive. Those are bundled in `runtimeDefau the working defaults without listing them. You only need to supply one if you want a different date format. -Two more sets are still overridable but now come from `stream-chat`, because it owns the code that -renders them: +Two more sets are still overridable but are no longer defined by this package: - **`language.*`** — the 57 language names used to say "Translated from German" on an auto-translated - message. They are derived from the same language union the API uses, so the set can no longer drift - out of sync with it. + message. They come from `stream-chat` and are derived from the same language union the API uses, so + the set can no longer drift out of sync with it. - **`relativeTime.*`** — `Today`, `Yesterday`, `{{ count }}d ago`, `{{ count }}w ago`, used by - `timestampFormatter(relativeCompact: true)`. + `timestampFormatter(relativeCompact: true)`. They come from `@stream-io/i18n`, which owns that + formatter. Both are part of your catalog's types, so you override them exactly as before — `t('language.de')` is a checked key, and a typo in either is still a compile error. diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index 7e80615c8..4a3f41175 100644 --- a/ai-docs/instance-configuration.md +++ b/ai-docs/instance-configuration.md @@ -118,18 +118,21 @@ The top-level `messagePaginator` key exists because one `MessagePaginator` class list and thread replies, and settings like throttling have no reason to differ between them. `pageSize` does differ, so the per-parent slices override the shared one field by field. -### `pageSize` is not `channelQueryOptions.messages.limit` +### `pageSize` is not the initial query's `messages.limit` -`` sizes the **initial** channel query -(`Channel.tsx`). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. -They are different numbers and you usually want both: +`channel.ensureWatched({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code +that opens the channel now sends (`` no longer queries, and its `channelQueryOptions` prop is +removed). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. They +are different numbers and you usually want both: ```tsx chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); - - … -; +const channel = await chatClient.channelManager + .ensure({ id: 'general', type: 'messaging' }) + .ensureWatched({ messages: { limit: 30 } }); + +…; ``` ## Setup functions, for behaviour @@ -173,7 +176,7 @@ reset re-derives configuration and re-runs setup functions, but it discards impe ## Request handlers Register them through `client.config`. The `doSendMessageRequest`, `doUpdateMessageRequest`, -`doDeleteMessageRequest` and `doMarkReadRequest` props on `` and `` are **removed** — +`doDeleteMessageRequest` and `doMarkReadRequest` props on `` are **removed** — see the v14 → v15 migration guide. ```ts @@ -190,15 +193,30 @@ chatClient.config.set({ }); ``` -Thread-scoped requests go under the `thread` key with the same shape. +Thread replies use these same channel handlers: sending, editing, deleting and retrying a reply goes +through `thread.messageOperations`, which reads the parent channel's `requestHandlers`. The `thread` key +takes only `markReadRequest`, for marking a thread read: + +```ts +chatClient.config.set({ + thread: { + requestHandlers: { + markReadRequest: ({ thread, options }) => + markThreadReadViaProxy(thread.id, options), + }, + }, +}); +``` + +A `sendMessageRequest` (or any other message handler) under `thread` does not type-check and is never +called. Registration is per **client**, not per mounted subtree, which is the one thing the props could do that this cannot. If behaviour has to differ between channels, branch inside a single handler on the -`channel` or `cid` it receives: +message's `localMessage.cid`: ```ts sendMessageRequest: async ({ localMessage, message, options }) => { - const channel = chatClient.channelManager.ensure({ ... }); return isSupportChannel(localMessage.cid) ? { message: await sendViaProxy(message, options) } : { message: await sendNormally(message, options) }; From 44494a049f4758a9f32b9a6101561f6ac9103d00 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 19 Oct 2026 13:49:58 +0200 Subject: [PATCH 2/2] docs: apply the v15 migration-log findings to the migration guides Fix the useCreateChatClient option mapping (enableWSFallback, wsUrlParams, recoverStateOnReconnect and isLocalUnreadCountEnabled have config keys), add the silent v14 defaults (setActiveChannelOnMount, allowNewMessagesFromUnfilteredChannels, LoadMorePaginator, Thread.autoFocus) and the panel layout change to the silent-break table, and add mappings for useUserRole gating, options.limit, channel custom name/image and held channels after disconnectUser. State the Node >=22.18 engine of stream-chat@10, make every link to this repository absolute (ai-docs and examples do not ship in npm), and add a channels + threads ChatView example with the slot-targeting rules. --- ai-docs/ai-migration-v14-v15.md | 381 +++++++++++++++++++++++++++----- ai-docs/i18n-v15-migration.md | 8 +- 2 files changed, 334 insertions(+), 55 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 90c2c273d..82eed75e5 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -2,7 +2,7 @@ Execution-only guide for a coding agent migrating a third-party app from `stream-chat-react` v14 to v15. Apply the changes below and fix each compile or runtime break before moving on. For evidence and edge cases, verify against the installed SDK source (see below). -Every breaking change confirmed in `ai-docs/v15-breaking-changes.md` (the audit this guide is written from, `BC-001` to `BC-077`) is covered here. The [index at the end](#index-of-breaking-changes) maps each `BC` entry to its section. +Every breaking change confirmed in [`ai-docs/v15-breaking-changes.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md) (the audit this guide is written from, `BC-001` to `BC-077`) is covered here. The [index at the end](#index-of-breaking-changes) maps each `BC` entry to its section. ## Source of truth @@ -14,6 +14,8 @@ Never rely on training knowledge. The v14 surface is heavily represented in pret Rule: before claiming a symbol, prop or method exists, or writing a replacement snippet, grep the installed `.d.ts` and read the current signature. Do not reconstruct it from memory. +Links to this repository point at `master`, because `ai-docs/` and `examples/` are not in the npm package (`files` ships only `dist`, `package.json` and `README.md`), so an agent working in a consumer repo cannot read them from `node_modules`. At the stable v15 release, re-pin them to the release tag. + ## How to migrate Work through the sections in order. They follow the order an app is usually rebuilt in: @@ -45,27 +47,34 @@ Then run the [silent-break checklist](#breaks-that-do-not-fail-to-compile) once These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. -| Symptom | Cause | Fix | -| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | -| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | `await channel.ensureWatched()` before rendering it ([section 5](#channel-no-longer-watches-the-channel)) | -| A filtered or synthetic `messages` array passed to a message list is ignored | `MessageList.messages` / `VirtualizedMessageList.messages` still type-check but are never read | `reviewProcessedMessage`, `hideDeletedMessages` or `renderMessages` ([section 11](#the-messages-prop-is-ignored)) | -| After a failed send, the composer is empty instead of holding the text and attachments | The composer is released before the request and nothing is put back | Nothing to do; the failed message is retried from the list ([section 13](#a-failed-send-or-edit-no-longer-restores-the-composer)) | -| A component that rendered blank outside its provider now throws | Required context hooks throw instead of warning and returning `{}` | Render it inside the provider ([section 10](#required-context-hooks-throw-outside-their-provider)) | -| A standalone `` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | -| The channel is not marked read | Mark-read moved from `Channel` mount to the message list | Render a message list, or mark read yourself ([section 5](#markreadonmount-is-removed-the-list-marks-read)) | -| The tab title no longer shows `(3) Title` | `Channel` no longer writes `document.title` | Write it in app code ([section 5](#channel-no-longer-writes-documenttitle)) | -| Mount effects in a channel's children stop re-running when the channel changes | `Channel` no longer remounts its subtree per channel | Depend on the `channel` instance ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | -| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `ensureWatched()` on open ([section 4](#search-results)) | -| Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | -| CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Retarget ([section 19](#19-css-and-markup)) | -| Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | -| Plain JS: a removed prop or override key (`Message`, `HeaderStartContent`, `do*Request`, `overrideSubmitHandler`, ...) does nothing | Ignored without a warning | Grep for each removed name listed in this guide | -| A supplied composer keeps its content after `MessageComposer` unmounts | A supplied composer is never cleared on unmount | Call `composer.clear()` yourself ([section 13](#preventclearingonunmount-is-removed)) | -| A voice message stops another one playing in a different panel | Playback is exclusive app-wide | Nothing to do ([section 12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time)) | -| The attachment menu offers different actions than before | Actions follow the composer's resolved configuration | Set `customCdn` where it applies ([section 13](#attachment-and-poll-availability)) | -| A custom `MessageTimestamp` no longer shows in the gallery header | The gallery renders its own timestamp | Customize `timestamp.GalleryTimestamp` ([section 12](#the-gallery-header-ignores-the-messagetimestamp-override)) | -| `dayjs(created_at)` renders `Invalid Date` | Server timestamps are unix nanoseconds | Convert with `convertTimestampToDate` ([section 16](#16-dates-on-response-types-are-unix-nanosecond-numbers)) | +| Symptom | Cause | Fix | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | +| No channel is open when the app loads | v14 `ChannelList.setActiveChannelOnMount` defaulted to `true`; v15 selects nothing | Open the first channel yourself ([section 6](#v14-list-defaults-that-changed)) | +| A new message in a channel outside the list's filters no longer adds that channel to the list | v14 `allowNewMessagesFromUnfilteredChannels` defaulted to `true`; v15 adds only channels the filters match | Widen the paginator's filters ([section 6](#v14-list-defaults-that-changed)) | +| The channel list scrolls infinitely instead of showing a "Load more" button | v14 `Paginator` defaulted to `LoadMorePaginator` | Keep it, or render your own list ([section 6](#v14-list-defaults-that-changed)) | +| Opening a thread no longer focuses its composer | v14 `Thread.autoFocus` defaulted to `true`, so every thread focused it | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | +| A thread renders below the channel instead of beside it, or a panel collapses to a narrow column | The channel no longer contains a flex row, and `ChatView` slots are `flex: 1 1 0` siblings | Make your container a row; size panels by slot ([section 19](#panel-layout)) | +| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | +| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | +| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | `await channel.ensureWatched()` before rendering it ([section 5](#channel-no-longer-watches-the-channel)) | +| A filtered or synthetic `messages` array passed to a message list is ignored | `MessageList.messages` / `VirtualizedMessageList.messages` still type-check but are never read | `reviewProcessedMessage`, `hideDeletedMessages` or `renderMessages` ([section 11](#the-messages-prop-is-ignored)) | +| After a failed send, the composer is empty instead of holding the text and attachments | The composer is released before the request and nothing is put back | Nothing to do; the failed message is retried from the list ([section 13](#a-failed-send-or-edit-no-longer-restores-the-composer)) | +| A component that rendered blank outside its provider now throws | Required context hooks throw instead of warning and returning `{}` | Render it inside the provider ([section 10](#required-context-hooks-throw-outside-their-provider)) | +| A standalone `` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | +| The channel is not marked read | Mark-read moved from `Channel` mount to the message list | Render a message list, or mark read yourself ([section 5](#markreadonmount-is-removed-the-list-marks-read)) | +| The tab title no longer shows `(3) Title` | `Channel` no longer writes `document.title` | Write it in app code ([section 5](#channel-no-longer-writes-documenttitle)) | +| Mount effects in a channel's children stop re-running when the channel changes | `Channel` no longer remounts its subtree per channel | Depend on the `channel` instance ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | +| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `ensureWatched()` on open ([section 4](#search-results)) | +| Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | +| CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Retarget ([section 19](#19-css-and-markup)) | +| Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | +| Plain JS: a removed prop or override key (`Message`, `HeaderStartContent`, `do*Request`, `overrideSubmitHandler`, ...) does nothing | Ignored without a warning | Grep for each removed name listed in this guide | +| A supplied composer keeps its content after `MessageComposer` unmounts | A supplied composer is never cleared on unmount | Call `composer.clear()` yourself ([section 13](#preventclearingonunmount-is-removed)) | +| A voice message stops another one playing in a different panel | Playback is exclusive app-wide | Nothing to do ([section 12](#allowconcurrentplayback-is-removed-one-voice-message-plays-at-a-time)) | +| The attachment menu offers different actions than before | Actions follow the composer's resolved configuration | Set `customCdn` where it applies ([section 13](#attachment-and-poll-availability)) | +| A custom `MessageTimestamp` no longer shows in the gallery header | The gallery renders its own timestamp | Customize `timestamp.GalleryTimestamp` ([section 12](#the-gallery-header-ignores-the-messagetimestamp-override)) | +| `dayjs(created_at)` renders `Invalid Date` | Server timestamps are unix nanoseconds | Convert with `convertTimestampToDate` ([section 16](#16-dates-on-response-types-are-unix-nanosecond-numbers)) | --- @@ -73,7 +82,8 @@ These compile cleanly, or are ignored in plain JS, and change behavior at runtim ### Packages -- **`stream-chat` peer is `^10.0.0-rc.20`.** v9 does not satisfy it, and v10 is itself a breaking client release (see its own migration notes). Install `stream-chat@^10`. +- **`stream-chat` peer is `^10.0.0-rc.20`.** v9 does not satisfy it, and v10 is itself a breaking client release (see [`v10-breaking-changes.md`](https://github.com/GetStream/stream-chat-js/blob/master/ai-docs/v10-breaking-changes.md) in stream-chat-js (added by GetStream/stream-chat-js#1919; until that merges, read it on the PR)). Install `stream-chat@^10`. +- **Node `>=22.18.0`.** `stream-chat@10` declares `engines.node >=22.18.0`, and Yarn 1 refuses to install on older Node. Move `.nvmrc` and every CI `node-version` (GitHub Actions `setup-node`, Docker base images, deploy workflows) to Node 22 before anything else: CI is usually the first thing to break. - **`dayjs` and `i18next` are no longer dependencies of this package.** It depends on `@stream-io/i18n`, which brings both (`dayjs ^1.11.23`, `i18next ^26`). `stream-chat` v10 depends on neither. If the app declared them only because of this SDK, remove them. If it keeps `dayjs`, its version must dedupe with `@stream-io/i18n`'s, or `import 'dayjs/locale/xx'` registers the locale on a different instance and dates stay English. - **`@stream-io/state-store` must resolve to exactly one copy.** `stream-chat`, `@stream-io/i18n` and this package hand each other store instances. `StateStore` has `protected` members, so stores from two copies are not assignable to `useStateStore`'s parameter. Check with `yarn why @stream-io/state-store` / `npm ls @stream-io/state-store`. A linked `stream-chat` checkout needs bundler dedupe. - **New subpath exports:** `stream-chat-react/slot-layout` (`ChatView` and its slot system) and `stream-chat-react/slot-geometry`. `stream-chat-react/ai-components`, `/channel-detail`, `/emojis` and `/mp3-encoder` already existed. @@ -150,7 +160,15 @@ import { useCreateChatClient } from 'stream-chat-react'; export const useClient = (apiKey: string, userId: string, token: string) => useCreateChatClient({ apiKey, - config: { client: { baseURL: 'https://chat-proxy.example.com' } }, + config: { + // was `isLocalUnreadCountEnabled`: the top-level `channel` key, not `client` + channel: { readEvents: { localUnreadCountEnabled: true } }, + client: { + baseURL: 'https://chat-proxy.example.com', + connectionRecovery: { enabled: true }, // was `recoverStateOnReconnect` + wsConnection: { enableWSFallback: true }, // was `enableWSFallback` + }, + }, tokenOrProvider: token, userData: { id: userId }, }); @@ -158,14 +176,24 @@ export const useClient = (apiKey: string, userId: string, token: string) => - Client settings go under `client`. The keys it accepts are those of `ClientDeclarativeConfig` in the installed `stream-chat`: `allowServerSideConnect`, `axiosRequestConfig`, `baseURL`, `browser`, `persistUserOnConnectionFailure`, `notificationManager`, and the sub-trees `connectionRecovery`, `messageDelivery`, `networkConnection`, `notifications`, `reminders`, `threads`, `wsConnection`. Check the `.d.ts` before using one. - v14's `StreamChatOptions` was `AxiosRequestConfig & {...}`, so top-level axios fields such as `timeout` now go under `client.axiosRequestConfig`. -- `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `isLocalUnreadCountEnabled`, `logger`, `recoverStateOnReconnect`, `warmUp` and `wsUrlParams` have no `client` key. `notifications` (a `NotificationManager` instance) is now `client.notificationManager`. `wsConnection` is a config object, not a connection instance. +- Four v14 options moved into config sub-trees. Do not drop them: leaving out `enableWSFallback`, for example, silently loses the WebSocket long-poll fallback. + + | v14 option | v15 `config` key | + | --------------------------- | -------------------------------------------- | + | `enableWSFallback` | `client.wsConnection.enableWSFallback` | + | `wsUrlParams` | `client.wsConnection.urlParams` | + | `recoverStateOnReconnect` | `client.connectionRecovery.enabled` | + | `isLocalUnreadCountEnabled` | `channel.readEvents.localUnreadCountEnabled` | + +- `device`, `disableCache`, `enableInsights`, `logger` and `warmUp` have no config key. `notifications` (a `NotificationManager` instance) is now `client.notificationManager`. `wsConnection` is a config object, not a connection instance. +- The full v9 → v10 option table is in [`v10-breaking-changes.md`](https://github.com/GetStream/stream-chat-js/blob/master/ai-docs/v10-breaking-changes.md) in stream-chat-js (added by GetStream/stream-chat-js#1919; until that merges, read it on the PR). Do not restate it from memory. - `userData` is typed `ClientUser`. `config` is read once, on the first render. ### `useChat` and `Chat.defaultLanguage` - `useChat({ client })` returns `{ getAppSettings, mutes }` only. Its `defaultLanguage` and `i18nInstance` params and its `channel`, `setActiveChannel`, `latestMessageDatesByChannels` and `translators` results are removed. - Translators come from `useStreami18n({ client, i18nInstance })`, which is what `Chat` itself calls. -- `` is removed. Set the language on the `Streami18n` instance you pass as `i18nInstance` (see [`i18n-v15-migration.md`](./i18n-v15-migration.md)). +- `` is removed. Set the language on the `Streami18n` instance you pass as `i18nInstance` (see [`i18n-v15-migration.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-migration.md)). ```ts // v14 @@ -211,7 +239,7 @@ export const useChatParts = (client: StreamChat, i18nInstance?: Streami18n) => { ### `Window` and `WindowProps` are removed -There is no replacement component. Render `ChannelHeader` / `MessageList` / `MessageComposer` directly inside ``, and render the thread as a sibling with ``. `.str-chat__channel` (the `Channel` root) is now the flex column `Window` used to provide. Multi-panel layouts come from `ChatView` slots (below), or from your own layout. `SlotGeometryProvider` is not a replacement: it only reports which slots are visually obscured. +There is no replacement component. Render `ChannelHeader` / `MessageList` / `MessageComposer` directly inside ``, and render the thread as a sibling with ``. `.str-chat__channel` (the `Channel` root) is now the flex column `Window` used to provide. Nothing places the thread beside the channel any more: give the element that holds both `display: flex` ([section 19](#panel-layout)). Multi-panel layouts come from `ChatView` slots (below), or from your own layout. `SlotGeometryProvider` is not a replacement: it only reports which slots are visually obscured. ```tsx // v14 @@ -297,7 +325,7 @@ Import `ChatView` and its selector family from `stream-chat-react/slot-layout` ( ``` -v15, channels view, as in `examples/tutorial/src/4-channel-list/App.tsx`. The channel list's paginator must be registered first ([section 6](#channellist-is-driven-by-a-channelpaginator)): +v15, channels view, as in [`examples/tutorial/src/4-channel-list/App.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/tutorial/src/4-channel-list/App.tsx). The channel list's paginator must be registered first ([section 6](#channellist-is-driven-by-a-channelpaginator)): ```tsx import type { StreamChat } from 'stream-chat'; @@ -358,7 +386,122 @@ export const App = ({ client }: { client: StreamChat }) => ( ); ``` -A threads view is declared the same way: a `{ id: 'threads', slots: [...] }` layout, and a `threads` entry in `views` rendering `` plus a `ThreadSlot`. `examples/vite/src/ChatLayout/Panels.tsx` (`ThreadsPanels`) is the reference. +#### Channels and threads views + +Two views, each with its own layout. The channel kind and the thread kind open into these slots by the rules [below](#where-an-opened-entity-lands): + +```tsx +import type { StreamChat } from 'stream-chat'; +import { + Channel, + ChannelHeader, + ChannelNavigation, + ChannelPlaceholder, + Chat, + MessageComposer, + MessageList, + Thread, + ThreadHeader, + ThreadList, +} from 'stream-chat-react'; +import { + ChannelSlot, + ChatView, + type ChatViewSlotRenderers, + Slot, + ThreadSlot, +} from 'stream-chat-react/slot-layout'; + +// Module scope: layouts, renderers and views must be referentially stable. +const layouts = [ + { id: 'channels' as const, slots: ['channel', 'channel-thread'] }, + { id: 'threads' as const, slots: ['thread'] }, +]; + +// Used by a without children, and for layers stacked over any slot. +const slotRenderers: ChatViewSlotRenderers = { + channel: ({ source }) => ( + + + + + + ), + thread: ({ source }) => ( + + + + + + ), +}; + +const views = { + channels: ( + <> + + {/* Children replace the renderer: this slot also shows UI while no channel is open. */} + + Pick a conversation} + slot='channel' + > + + + + + + {/* No children: renders `slotRenderers.thread` for a reply thread opened from a message. */} + + + ), + threads: ( + <> + + + + + + + + + + ), +}; + +export const App = ({ client }: { client: StreamChat }) => ( + + + + + +); +``` + +- **`` children replace the renderer.** A `` with children renders them for the slot's own binding; one without children renders the `slotRenderers` entry for the bound kind. Layers stacked over a slot always render through `slotRenderers`, so register a renderer for every kind that can land there. +- **`ChannelSlot` / `ThreadSlot`** resolve the entity bound to their slot and render `` / `` around their children. Without one bound they render `fallback`. UI that used to live inside `` but must show while no channel is open (an empty state, a "create channel" panel) goes in `ChannelSlot`'s `fallback`, wrapped in `ChannelPlaceholder` so it fills the same column. +- **`ThreadSlot` hides its slot while empty.** `hideIfEmpty` defaults to `true`: with no thread bound it calls `layoutController.hide(slot)`, which marks that `` section `str-chat__chat-view__slot--hidden`, and unhides it when a thread opens. Pass `hideIfEmpty={false}` to keep the slot visible (for example to show a `fallback`). +- [`examples/vite/src/ChatLayout/Panels.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/ChatLayout/Panels.tsx) (`ChannelsPanels`, `ThreadsPanels`) is a complete app layout built this way, with resizable side panels. + +#### Where an opened entity lands + +`openChannel` / `openThread` (and the SDK components that call them) bind the entity into a slot of a view: + +1. **The view.** The `channel` kind belongs to the `channels` view: opening a channel from the threads view switches to the channels view first. A `thread` declares no view and opens in the active view, so a reply opened from a message in the channels view stays there. +2. **The slot**, in that view's layout, in this order: + 1. an explicit `slot` option, if the layout has it; + 2. the slot already holding the same kind, so selecting another channel replaces the open one (skipped for an additive open, `{ additive: true }`); + 3. the first free non-persistent slot; + 4. the first free slot; + 5. the last non-persistent slot, evicting a secondary panel, never the primary. + + Persistent slots (the `channelList` / `threadList` kinds) are never an implicit target. If no slot qualifies, the open is rejected and nothing changes. + +3. **Replace or stack.** The primary slot (the first non-persistent slot in the layout) always replaces its content. A secondary slot that is already occupied gets the entity stacked as a layer on top, and closing it reveals what was beneath. + +In the example: selecting a channel binds it to `channel`, replacing the open one; a reply opened from a message goes to the free `channel-thread`; clicking a `ThreadList` row binds the thread to `thread`. Each view keeps its own slot state when you switch between them. + +`ThreadList` rows open additively on ⌘/ctrl-click. Channel rows only forward the click event, so opening a channel _beside_ the current one is opt-in: pass `ChatView` a `deriveWorkspaceNavigation` whose `openChannel` sets `additive` from the event's modifier keys. With it, that channel lands in `channel-thread`. **`ThreadSlot` and `ChannelSlot` wrap their children.** They resolve the entity bound to a slot and hand it to `` / ``, so their `children` are the thread's or channel's contents, not a substitute for the whole panel. Anything that has to sit outside the thread container (a panel shell your layout sizes) moves outside the slot; anything that needs the thread context must sit inside. @@ -498,6 +641,7 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) - An `openThread` target is either a `Thread` or `{ channel, message }`. `client.threads.ensure({ channel, parentMessage })` turns the latter into a registered `Thread`. - Instead of a navigation provider, `ChannelListItem` and the search result items take an `onSelect` prop that replaces their selection handler. That covers selection only: thread opening and the header buttons still read the navigation. +- **Drop held channels on `disconnectUser()`.** It disposes the user's channels, and a disposed channel throws `Channel is pending disposal and cannot be used` from `getClient()`, so rendering it (as `activeChannel` above, after a logout or a user switch) throws. Clear the state that holds channels and threads when you disconnect, or re-`ensure` them on the new connection. This also shows up in development when Fast Refresh keeps a stale channel in state across a reconnect. ### Opening a channel programmatically @@ -610,7 +754,7 @@ export const getGeneral = (client: StreamChat) => client.channelManager.ensure({ id: 'general', type: 'messaging' }).ensureWatched(); ``` -`examples/vite` and the tutorial steps show the full flow. +[`examples/vite`](https://github.com/GetStream/stream-chat-react/tree/master/examples/vite) and the tutorial steps show the full flow. ### Channel lifecycle: activation, no remount, no reload, no search jump @@ -634,7 +778,7 @@ These keep the same signature and change behavior: ### `Channel` no longer writes `document.title` -`Channel` set `document.title` to `(3) Your app` when a message arrived while the tab was hidden, and `activeUnreadHandler` intercepted that. Both are removed, with no SDK replacement. Write the title in application code; `examples/vite/src/DocumentTitleManager` is a complete implementation (a component inside `Chat` that subscribes to the unread counts and restores the previous title on unmount). +`Channel` set `document.title` to `(3) Your app` when a message arrived while the tab was hidden, and `activeUnreadHandler` intercepted that. Both are removed, with no SDK replacement. Write the title in application code; [`examples/vite/src/DocumentTitleManager`](https://github.com/GetStream/stream-chat-react/tree/master/examples/vite/src/DocumentTitleManager) is a complete implementation (a component inside `Chat` that subscribes to the unread counts and restores the previous title on unmount). When writing your own: the counts are user-scoped (the unread message total from the connection's `me` payload and `total_unread_count` on events; the unread thread count from `client.threads`), and they are different units, messages versus threads, so do not add them. The v14 behavior counted only the open channel and never restored the title. @@ -714,7 +858,7 @@ Inside an attachment component, read them with `useAttachmentContext()`. | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filters`, `sort`, `options`, `watchers`, `customQueryChannels`, `recoveryThrottleIntervalMs` | `new ChannelPaginator({ client, filters, sort, id, requestOptions?, paginatorOptions?, sortComparatorFactory? })` from `stream-chat`, registered with `client.channelManager.insertPaginator({ paginator })` so it receives WebSocket updates. `sort` is `SortParamRequest[]` (`[{ field, direction }]`). | | `onAddedToChannel`, `onChannelDeleted`, `onChannelHidden`, `onChannelTruncated`, `onChannelUpdated`, `onChannelVisible`, `onMessageNew`, `onMessageNewHandler`, `onRemovedFromChannel`, `allowNewMessagesFromUnfilteredChannels`, `lockChannelOrder` | The `ChannelManager` (`client.channelManager.addEventHandler(...)`, its ownership resolver) and the paginator's `sortComparatorFactory`, in `stream-chat` | -| `customActiveChannel`, `setActiveChannelOnMount` | Open a channel through workspace navigation ([section 4](#opening-a-channel-programmatically)) | +| `customActiveChannel`, `setActiveChannelOnMount` | Open a channel through workspace navigation ([section 4](#opening-a-channel-programmatically)). `setActiveChannelOnMount` defaulted to `true`: see [below](#v14-list-defaults-that-changed) | | `showChannelSearch` | `` renders the header, search and every registered list. Search is always shown; hide it with a `Search: () => null` override. | | `EmptyStateIndicator`, `Paginator`, `renderChannels`, `channelRenderFilterFn`, `sendChannelsToList` | The list slots ([below](#channellistui-is-removed-use-the-list-slots)) | | `getLatestMessagePreview` | [Below](#channellistitem-props) | @@ -760,7 +904,89 @@ export const Sidebar = ({ client, userId }: { client: StreamChat; userId: string }; ``` -A single list can still be rendered on its own with ``; it has no header or search. Use `` for all registered lists without the header. Page size and other request options map onto `ChannelPaginator`'s `requestOptions` / `paginatorOptions`; check the installed `stream-chat` for their fields. +A single list can still be rendered on its own with ``; it has no header or search. Use `` for all registered lists without the header. + +v14 `options` split in two: `options.limit` (the page size) → `paginatorOptions.pageSize`, and every other query option (`state`, `watch`, `presence`, `message_limit`, `member_limit`, ...) → `requestOptions` (`ChannelPaginatorRequestOptions` omits `limit` and `offset`): + +```ts +import { ChannelPaginator, type StreamChat } from 'stream-chat'; + +export const createPaginator = (client: StreamChat, userId: string) => + new ChannelPaginator({ + client, + filters: { members: { $in: [userId] }, type: 'messaging' }, + id: 'channels:default', + paginatorOptions: { pageSize: 10 }, // was options.limit + requestOptions: { message_limit: 30, presence: true }, // the rest of options + sort: [{ direction: -1, field: 'last_message_at' }], + }); +``` + +### v14 list defaults that changed + +These hit apps that never passed the props, because they were v14 defaults: + +- **No channel opens on load.** `setActiveChannelOnMount` defaulted to `true`, so every v14 app opened the first channel. v15 has no default selection: nothing is open until you call `useWorkspaceNavigation().openChannel(channel)`. To keep the v14 behavior, open the paginator's first item once: + + ```tsx + import { useEffect, useRef } from 'react'; + import type { ChannelPaginator } from 'stream-chat'; + import { useWorkspaceNavigation } from 'stream-chat-react'; + + export const useOpenFirstChannel = (paginator: ChannelPaginator) => { + const { openChannel } = useWorkspaceNavigation(); + const opened = useRef(false); + useEffect( + () => + paginator.state.subscribeWithSelector( + ({ items }) => ({ first: items?.[0] }), + ({ first }) => { + if (first && !opened.current) { + opened.current = true; + openChannel(first); + } + }, + ), + [paginator, openChannel], + ); + }; + ``` + + Call it below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. + +- **Unfiltered channels no longer join the list.** `allowNewMessagesFromUnfilteredChannels` defaulted to `true`: a new message in a channel that was not in the list added it, whatever the list's filters. In v15 the paginator adds a channel only if it matches the paginator's own filters, and there is no opt-out. If channels used to appear this way, widen the filters to cover them. +- **Infinite scroll, not "Load more".** `Paginator` defaulted to `LoadMorePaginator` (a button). The v15 `ChannelList` always scrolls infinitely, loading the next page near the end of the list. To keep a button, render your own list: + + ```tsx + import { useEffect } from 'react'; + import type { ChannelPaginator } from 'stream-chat'; + import { + ChannelListItem, + LoadMorePaginator, + useChannelPaginatorState, + } from 'stream-chat-react'; + + export const ButtonPagedList = ({ paginator }: { paginator: ChannelPaginator }) => { + const { channels, hasNext, isLoading } = useChannelPaginatorState(paginator); + // `undefined` means "never queried": on mount, and again after `disconnectUser` resets the list. + useEffect(() => { + if (channels === undefined) void paginator.toTail(); + }, [channels, paginator]); + return ( + paginator.toTail()} + > + {channels?.map((channel) => ( + + ))} + + ); + }; + ``` + + `ChannelList` runs that first query itself; a list of your own has to, as above. ### `ChannelListUI` is removed; use the list slots @@ -908,19 +1134,19 @@ export const CustomItem = ({ - `Thread` no longer remounts its subtree per thread, and renders nothing when the channel has replies disabled or the parent message is missing. - Get the instance from `client.threads.ensure({ channel, parentMessage })` / `client.threads.get(...)`, from a `ThreadSlot`, or from workspace navigation. -| Removed `ThreadProps` | v15 | -| --------------------------------------- | ----------------------------------------------------------------------------------- | -| `additionalMessageComposerProps` | Props on your `MessageComposer` child | -| `additionalMessageListProps` | Props on your `MessageList` child | -| `additionalVirtualizedMessageListProps` | Props on your `VirtualizedMessageList` child | -| `additionalParentMessageProps` | A `ThreadHead` override on `ComponentContext` (typed `ComponentType`) | -| `autoFocus` | `` | -| `enableDateSeparator` | `withDateSeparator` on the list | -| `Message` | A `MessageUI` override scoped with `WithComponents` | -| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | -| `virtualized` | Render the list you want | +| Removed `ThreadProps` | v15 | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `additionalMessageComposerProps` | Props on your `MessageComposer` child | +| `additionalMessageListProps` | Props on your `MessageList` child | +| `additionalVirtualizedMessageListProps` | Props on your `VirtualizedMessageList` child | +| `additionalParentMessageProps` | A `ThreadHead` override on `ComponentContext` (typed `ComponentType`) | +| `autoFocus` (default `true`) | `` in every thread that should focus, whether or not you passed `autoFocus` | +| `enableDateSeparator` | `withDateSeparator` on the list | +| `Message` | A `MessageUI` override scoped with `WithComponents` | +| `messageActions` | Removed ([section 10](#messageactions-and-message_actions-are-removed)) | +| `virtualized` | Render the list you want | -**Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). The composer is focused only if you pass `focus`. The parent message is rendered by the message list, inside the scroll container; both lists resolve it from the thread context. +**Defaults changed.** A `MessageList` inside a thread now shows date separators unless you pass `withDateSeparator={false}` (v14's `Thread` turned them off). `autoFocus` defaulted to `true`, so every v14 thread focused its composer on open, not only threads that passed the prop; in v15 the composer is focused only if you render ``. The parent message is rendered by the message list, inside the scroll container; both lists resolve it from the thread context. ```tsx // v14 @@ -1427,6 +1653,18 @@ export const List = () => ( - Action names are typed by the exported `DefaultMessageActionType` union; a custom action may use any string. - Permission and message-state filtering stays in `useBaseMessageActionSetFilter`: an action the user cannot perform, or that a thread reply does not support, is dropped whether or not you list it. Pass `disableBaseMessageActionSetFilter` to take that over. - There is no `actionsEnabled` replacement. The base filter already drops actions on deleted, bounced and failed messages. +- **A custom `MessageUI` that gated its own buttons** on `getMessageActions()` / `MESSAGE_ACTIONS` (for example showing a react or reply button only when allowed) reads the permission from `useUserRole(message)`, which returns `canReact`, `canReply`, `canPin`, `canEdit`, `canDelete`, `canFlag`, `canMute`, `canQuote`, `canMarkUnread`, `canBlockUser`, `canSendMessage`, `isMyMessage` and the role flags: + + ```tsx + import { useMessageContext, useUserRole } from 'stream-chat-react'; + + export const ReplyButton = () => { + const { handleOpenThread, message } = useMessageContext(); + const { canReply } = useUserRole(message); + return canReply ? : null; + }; + ``` + - Adding an action means adding an item with a `Component` and a `placement` of `quick`, `dropdown` or `quick-dropdown-toggle`. --- @@ -1460,7 +1698,7 @@ Code that drove these props uses the paginator: - loading: `useStateStore(messagePaginator.state, selector)` → `isLoading` - jump: `messagePaginator.jumpToMessage(id)`, `.jumpToTheLatestMessage()`, `.jumpToTheFirstUnreadMessage()`. The highlight follows `messagePaginator.messageFocusSignal` (replaces `highlightedMessageId`). - unread UI state: `messagePaginator.unreadStateSnapshot` -- page size: `client.config.set({ channel: { messagePaginator: { pageSize } } })` (and `thread.messagePaginator` for replies); there is no per-list `messageLimit` (see [`instance-configuration.md`](./instance-configuration.md)) +- page size: `client.config.set({ channel: { messagePaginator: { pageSize } } })` (and `thread.messagePaginator` for replies); there is no per-list `messageLimit` (see [`instance-configuration.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/instance-configuration.md)) ### `threadList` and `head` are removed; lists detect the thread @@ -1537,7 +1775,7 @@ Only code that calls these directly (custom list implementations) is affected: `Message` resolves its channel with `useChannel()`, which throws when there is no `Channel`, `Thread` or `ChannelInstanceProvider` ancestor. In v14 the missing contexts only warned. Wrapping a preview `` in `ChannelStateProvider` / `ChannelActionProvider` no longer compiles. -- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. `examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx` is the reference. +- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. [`examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx) is the reference. - `highlighted` is no longer derived from `ChannelStateContext.highlightedMessageId`. Pass `highlighted` yourself (`MessageProps.highlighted?: boolean`). ```tsx @@ -1875,7 +2113,7 @@ export const registerRequestHandlers = (client: StreamChat) => - **Registration is per client**, not per mounted ``. If behavior has to differ between channels, branch inside one handler on `params.localMessage.cid`. - `useChannelEditMessageHandler` applied `doUpdateMessageRequest` to the edit path; register `updateMessageRequest` instead. `messageComposer.update()` and `channel.messageOperations.update()` route through it. -More on `client.config`: [`instance-configuration.md`](./instance-configuration.md). +More on `client.config`: [`instance-configuration.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/instance-configuration.md). --- @@ -1965,6 +2203,30 @@ React props typed with `stream-chat` types change with them. The React APIs are **Channel, chat context and search:** +- **Channel `name` and `image` are custom data.** A `stream-chat` v10 channel response carries them under `custom`, with no top-level `name`, so `channel.data?.name` (the most common read, in every channel header and preview) is `undefined`. Read `channel.data?.custom?.name` / `.image`. To type them, augment `CustomChannelData`. `name` is still a top-level **filter** key (`{ name: { $autocomplete: q } }`); other custom fields filter as `'custom.'`. Writes go under `data.custom`, and `channel.update({ data: { custom } })` replaces the whole custom object, so spread the existing `custom` (or use `channel.updatePartial`) to keep the other fields. The full mapping is in [`v10-breaking-changes.md`](https://github.com/GetStream/stream-chat-js/blob/master/ai-docs/v10-breaking-changes.md) in stream-chat-js. + + ```tsx + import type { Channel, ChannelFilters } from 'stream-chat'; + + declare module 'stream-chat' { + interface CustomChannelData { + image?: string; + name?: string; + } + } + + export const channelTitle = (channel: Channel) => + channel.data?.custom?.name ?? channel.id; + + export const byNameAndDemo = (query: string): ChannelFilters => ({ + 'custom.demo': 'team', + name: { $autocomplete: query }, + }); + + export const rename = (channel: Channel, name: string) => + channel.update({ data: { custom: { ...channel.data?.custom, name } } }); + ``` + - `useChannelMembershipState(channel)`: dates are `TimestampNS`; `banned`, `channel_role`, `created_at`, `notifications_muted`, `shadow_banned`, `updated_at` are now required; `channel_role` is `string`; `custom` is added. - `ChatContextValue.mutes` / `useChat().mutes`: `UserMuteResponse[]`. `getAppSettings`: `ReturnType`. - `MessageSearchResultItem` / `ChannelByMessageSearchResultItemProps.item`: `MessageResponse & { channel?: ChannelResponse }`. @@ -2012,10 +2274,10 @@ Other `stream-chat` v10 type changes an app may hit (the attachment `Action` typ ## 18. i18n -Full detail: [`i18n-v15-migration.md`](./i18n-v15-migration.md). The breaks, all of which fail **silently** unless the app's dictionaries are typed: +Full detail: [`i18n-v15-migration.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-migration.md). The breaks, all of which fail **silently** unless the app's dictionaries are typed: 1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, `pt`, `ru` and `tr` dictionaries are removed with their dayjs calendar formats, and so are the `deTranslations` ... `trTranslations` / `enTranslations` exports, `isLanguageSupported` (use `i18n.registeredLanguages.has(lang)`) and `SupportedTranslations`. -2. **Keys are namespaced identifiers**, not the English text. `t('Send')` → `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. An unrenamed key never matches, and the English copy renders. Every old key maps to one new key in [`i18n-v15-key-map.json`](./i18n-v15-key-map.json). +2. **Keys are namespaced identifiers**, not the English text. `t('Send')` → `t('messageComposer.sendButton.send.ariaLabel', 'Send')`. An unrenamed key never matches, and the English copy renders. Every old key maps to one new key in [`i18n-v15-key-map.json`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-key-map.json). 3. **`t` is strictly typed** (`StreamTFunction`). `t('English text')` and `t(someString)` no longer compile (`TS2769`). App-owned strings and runtime keys go through `asDynamicKey`: `t(asDynamicKey('myApp.removeMembers.ariaLabel'), 'Remove channel members')`. 4. **`Streami18n`'s API changed:** `getTranslators()` → `init()`; `setLanguage()` returns `void`; `t` is read-only (`overrideTFunction(fn)`); `registerSetLanguageCallback` → `i18n.state.subscribeWithSelector(...)`; `getAvailableLanguages()` → `registeredLanguages`; `geti18Instance()` → `i18nInstance`; custom formatter factories receive a `FormatterContext`, not the `Streami18n` instance. @@ -2067,7 +2329,22 @@ Four formatter keys carry English day words inside their `calendarFormats` (`tim | `.str-chat__thread--virtualized` on the thread container | Not applied. Scope by the list you render (`.str-chat__thread .str-chat__virtual-list`, or your own class). | | `.str-chat__quoted-message-preview` text inherited the surrounding colour | It sets `color: var(--str-chat__chat-text-incoming)`, and `--own` `var(--str-chat__chat-text-outgoing)`, matching the bubble backgrounds. In the default light theme your own quotes change to the outgoing bubble's text colour. Remove contrast workarounds written for v14; theme with those two tokens. | -Consumers keep overriding through CSS layers (see `README.md`); never edit compiled CSS. +Consumers keep overriding through CSS layers (see [`README.md`](https://github.com/GetStream/stream-chat-react/blob/master/README.md)); never edit compiled CSS. + +### Panel layout + +v14 laid the thread beside the channel inside `Channel` itself: `.str-chat__channel` held a `.str-chat__container` flex **row** with `Window`'s `.str-chat__main-panel` and the `Thread`. v15 has no row inside the channel. `.str-chat__channel` is the content column (header, list, composer), and `` is a sibling you render. + +- **Without `ChatView`**, a sibling `` stacks **below** the channel unless its container is a row. Give the element that holds both `display: flex` (row), and size the panels there. +- **Inside `ChatView`**, every panel rendered through `` (and every panel of the built-in workspace layout) is a `section.str-chat__chat-view__slot` with `data-slot=""`. Slots are `display: flex; flex: 1 1 0` siblings, so open panels share the width equally, and an empty slot is hidden. v14 sizing rules on `.str-chat__thread` / `.str-chat__channel` (for example `max-width: 45%` or `flex: 0 1 330px`) now apply **inside** the slot and compound, which can crush a panel to a narrow column. Move panel sizing to the slot: + +```css +.str-chat__chat-view__slot[data-slot='thread'] { + flex: 0 0 min(400px, 45%); +} +``` + +A panel your layout renders without `` (for example a `ThreadSlot` placed directly in a view, as [`examples/vite`](https://github.com/GetStream/stream-chat-react/tree/master/examples/vite) does) has no slot section; size the element you wrap it in. --- @@ -2090,7 +2367,7 @@ Consumers keep overriding through CSS layers (see `README.md`); never edit compi ## Index of breaking changes -`BC-###` IDs refer to `ai-docs/v15-breaking-changes.md`. +`BC-###` IDs refer to [`ai-docs/v15-breaking-changes.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/v15-breaking-changes.md). | ID | Change | Section | | ------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 9c4024267..5a9aa6bab 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -11,6 +11,8 @@ Three breaking changes, all in v15: members were removed or changed shape, and two timestamp edge cases render differently — see [The shared runtime](#the-shared-runtime). +Links to this repository point at `master` (`ai-docs/` is not in the npm package); re-pin them to the release tag at the stable v15 release. + Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically imported and copied into `Streami18n` at construction, so they shipped even if you never set `language`. @@ -35,7 +37,7 @@ imported and copied into `Streami18n` at construction, so they shipped even if y ## Renaming your keys Every old key maps to exactly one new key. The full table (621 rows, including the 18 `aiComponents.*` keys of `stream-chat-react/ai-components`) is -[`i18n-v15-key-map.json`](./i18n-v15-key-map.json): +[`i18n-v15-key-map.json`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-key-map.json): ```json { @@ -137,7 +139,7 @@ nothing catches a stale one there. The extra plural categories some languages us outright. Four of them do carry English day words; those are handled by overriding the key, see [Date and time](#date-and-time). Pass `--all` for the complete 633-key catalog. - `ai-docs/i18n-v15-key-map.json` also lists every key, alongside the v14 string it replaced. + [`ai-docs/i18n-v15-key-map.json`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-key-map.json) also lists every key, alongside the v14 string it replaced. Keys are namespaced after the source tree, so they are predictable from the component: `message.*`, `messageComposer.*`, `poll.*`, `channelList.*`, with genuinely shared copy under @@ -469,7 +471,7 @@ find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly > interpolation bag is untyped, so a raw wire number compiles and renders the literal text > `Invalid Date`. `getDateString`'s `messageCreatedAt` _is_ typed (`string | Date`). If a timestamp is > rendering wrong or blank, check the conversion first; see -> [Dates on response types are unix-nanosecond numbers](./ai-migration-v14-v15.md#dates-on-response-types-are-unix-nanosecond-numbers). +> [Dates on response types are unix-nanosecond numbers](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/ai-migration-v14-v15.md#16-dates-on-response-types-are-unix-nanosecond-numbers). Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship are gone. For any other language, import the locale and supply the calendar config: