diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 235508e66..3c3b0f97b 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -60,6 +60,8 @@ Run the [detection](#detection) block. List the sections with hits; those are th Work through the sections with hits in this order, the order an app is usually rebuilt in. Fix each compile error before moving on. +**Expect a second wave of compile errors.** An import of a removed export (`useChannelStateContext`, `useChannelActionContext`, ...) fails with `TS2305`, and TypeScript then types every value read from it as `any`. Code that uses those values compiles until the import is replaced, so the errors behind it surface only then. For example, `const { channel } = useChannelStateContext()` hides the removed `channel.search(...)` until `useChannelStateContext` becomes `useChannel()`. Re-run the typecheck after each section rather than counting the first error list as the whole job. + 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) @@ -162,7 +164,7 @@ code '\b(doSendMessageRequest|doUpdateMessageRequest|doDeleteMessageRequest|doMa # §15 resolved configuration (review hits; most are covered by §9 and §13) code '\b(channelConfig|typing_events|read_events|user_message_reminders|shared_locations)\b' # §16 dates (review every hit that reads a server timestamp) -code 'new Date\([^)]*_at\b|dayjs\([^)]*_at\b|moment\([^)]*_at\b|_at\??\.getTime\(|\blast_read\b' +code 'new Date\([^)]*_at\b|dayjs\([^)]*_at\b|moment\([^)]*_at\b|_at\??\.getTime\(|\blast_read\b|\bisDate\(|instanceof Date\b' # §17 other stream-chat v10 data shapes code '\.data\??\.(name|image)\b|members:\s*\[|\b(ReactionSort|reactionDetailsSort|deleteForMe|hardDelete|moderation_details|CommandResponse|DefaultCommandData|CustomCommandData|unBlockUser|ReminderResponse|UserSort|queryUsers|queryChannels|flagMessage|getThread|updateFields|addMembers|removeMembers)\b' # §18 i18n @@ -280,55 +282,57 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th 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. **Detect** lists `rg` patterns (run them with the `code` / `styles` helpers from [Detection](#detection)) or what to review; a row with no hit and nothing to review does not apply. -| Symptom | Cause | Fix | Detect | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| 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 (it logs a one-time console warning per operation) | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | -| 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)) | `.data?.name`, `.data.name`, `.data?.image` | -| A field of the app's own reads `undefined` on a message, user or member, or a field set on an outgoing message is not where the app reads it back | Custom fields are under `custom`, on reads and writes. TypeScript rejects a top-level field only in an inline object literal | Read and write them under `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)); `updateUsersPartial` and the other partial updates keep flat keys in `set` / `unset` | Reads and writes of the app's own fields; `sendMessage(`; `sendMessageRequest`, `updateMessageRequest` | -| 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)) | `disconnectUser`, `useState` (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)) | `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | +| 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)) | `.data?.name`, `.data.name`, `.data?.image` | +| A channel without a name renders `undefined` or crashes where the app reads `channel.data.custom.name`, with no type error | The app declares `name` or `image` on `CustomChannelData` (for example `name: string`, required). Under `skipLibCheck` its type silently wins over the SDK's optional `string | undefined` | Delete the app's `name` / `image` declarations ([section 17](#17-other-stream-chat-v10-data-shapes)) | `interface CustomChannelData`, then `name` / `image` members inside it | +| A field of the app's own reads `undefined` on a message, user or member, or a field set on an outgoing message is not where the app reads it back | Custom fields are under `custom`, on reads and writes. TypeScript rejects a top-level field only in an inline object literal | Read and write them under `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)); `updateUsersPartial` and the other partial updates keep flat keys in `set` / `unset` | Reads and writes of the app's own fields; `sendMessage(`; `sendMessageRequest`, `updateMessageRequest` | +| 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)) | `disconnectUser`, `useState` (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)) | `` ([section 10](#removed-componentcontext-slots)) | The [leftover-identifier grep](#verification) | +| Plain JS: a mention click handler, a custom `renderText` or a custom mention renderer gets `undefined` where it read the mentioned users, and users passed to `mentionsMarkdownPlugin` render without `data-user-id` | The `mentioned_users` / `mentionedUsers` arguments and `node.mentionedUser` are removed, arguments after them moved up, and the plugin takes mention entities only | Read the metadata from `message` or `options.messageMentionEntities` ([section 12](#rendertext-takes-text-options)); pass `getRenderTextMentionEntities(message)` to `mentionsMarkdownPlugin` ([section 12](#mentionsmarkdownplugin-takes-mention-entities-only)) | `mentioned_users`, `mentionedUsers`, `mentionedUser`, `mentionsMarkdownPlugin` | +| Without `onMentionsClick`, a message with mentions is no longer a separate focus stop. Plain JS: a custom message UI that calls `onMentionsClickMessage(event)` throws | `useMessageContext()` sets `onMentionsClickMessage` / `onMentionsHoverMessage` only when `onMentionsClick` / `onMentionsHover` is passed | Pass `onMentionsClick` to `MessageList`, `VirtualizedMessageList` or `Message`, and call the handlers with `?.` ([section 12](#onmentionsclickmessage-and-onmentionshovermessage-are-undefined-without-a-handler)) | `onMentionsClickMessage`, `onMentionsHoverMessage` | +| 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)) | `MessageComposerControllerProvider` | +| 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)) | `allowConcurrent` | +| A custom composer without `CommandChip` loses `/giphy` from the text as soon as the command is typed | `stream-chat` v10 installs the command middlewares, which move the active command out of the text | Remove the four command middlewares ([section 13](#command-middlewares-are-installed-by-stream-chat)) | `stream-io/text-composer/(active-command-guard | command-string-extraction)`, `command-string-injection`, a custom composer that renders no `CommandChip` | +| 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)) | `doUploadRequest`, `customCdn` | +| A custom `MessageTimestamp` does not show in the header of a gallery you build yourself | The gallery renders outside a message and reads the item; it uses the override only when the item carries its `message` | Set `message` on each `GalleryItem` ([section 12](#the-gallery-header-renders-the-messagetimestamp-override-for-items-that-carry-their-message)) | `GalleryItem`, `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without ` ({ ... }), [])`, a spread of another object, or anything behind `any`. In those, and in plain JS, the stale key is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent` and `HeaderEndContent`). Type such objects with `satisfies` so stale keys fail to compile wherever the object is built, then grep for each removed slot below in case an object is untyped: + +```tsx +import { useMemo } from 'react'; +import type { ComponentContextValue } from 'stream-chat-react'; + +// CustomMessageUI and CustomThreadHeader stand for components of your own. +const overrides = useMemo( + () => + ({ + MessageUI: CustomMessageUI, + ThreadHeader: CustomThreadHeader, // TS2353: removed slot + }) satisfies Partial, + [], +); +``` | Removed slot | v15 | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -2777,6 +2796,12 @@ export const PinButton = () => { }; ``` +### Mute and flag need moderation v2 on the app + +The default Mute, Unmute and Flag message actions (`useMuteHandler`, `useFlagHandler`) call `client.moderation.mute` / `unmute` / `flagMessage`. v14 called `client.muteUser` / `unmuteUser` / `flagMessage`, which used the v1 endpoints; `stream-chat` v10 removed those and has no other way to mute or flag. The v2 endpoints need the v2 moderation feature flag on the app. Without it every mute and flag fails with `403` and `"this endpoint needs a feature flag, contact support to get it enabled"`, and the default actions show an error notification. Nothing fails to compile, and an app that never calls moderation code itself is affected through the default actions. + +Before shipping, mute and flag a user once against the app. If either fails with that error, ask Stream support to enable the flag. Custom code that mutes or flags handles the rejection as in the section above ([`stream-chat` v10 moderation methods](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#client-moderation-methods)). + ### `AIStates` moved to `stream-chat` `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: @@ -2905,6 +2930,54 @@ export const registerSendHandler = (client: StreamChat) => export const Composer = () => ; ``` +#### UI side effects after a send + +A v14 override often existed for a side effect in the component that rendered the composer: close a panel, clear a selection, track an event. The request handler is registered per client, so it cannot close over that component's state, and `client.config` gives a component no clean way to take its handler back: + +- `client.config.set` deep-merges. A second `sendMessageRequest` replaces the first, including the one the app registered where the client is created, and `sendMessageRequest: undefined` is skipped by the merge rather than removing the handler. +- `client.config.reset('channel')` clears every registration under `channel` (pagination, read events, the other request handlers), not one handler. + +Register one handler where the client is created, for the life of the client, and let components subscribe to it. Keep the latest callback in a ref so the subscription does not churn on every render: + +```tsx +import { useEffect, useRef } from 'react'; +import type { StreamChat } from 'stream-chat'; + +type SentListener = (cid: string) => void; +const sentListeners = new Set(); + +// once, where the client is created +export const registerSendHandler = (client: StreamChat) => + client.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async (params, defaultRequest) => { + const response = await defaultRequest(params); + sentListeners.forEach((listener) => listener(params.localMessage.cid)); + return response; + }, + }, + }, + }); + +// in a component +export const useOnMessageSent = (onSent: SentListener) => { + const onSentRef = useRef(onSent); + useEffect(() => { + onSentRef.current = onSent; + }); + useEffect(() => { + const listener: SentListener = (cid) => onSentRef.current(cid); + sentListeners.add(listener); + return () => { + sentListeners.delete(listener); + }; + }, []); +}; +``` + +The listener runs after the request resolves, for thread replies too, and not for edits. A component that only needs the outcome of its own submit can await `useMessageComposerSubmitFn()` instead, which resolves to `'sent' | 'nothing-to-send' | 'failed'` (below). + ### `handleSubmit` is removed `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. @@ -3394,6 +3467,7 @@ Not caught: - **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. +- **`Date` type guards**: `isDate(message.created_at)` (still exported from `stream-chat-react`, and its parameter accepts a `number`) and `message.created_at instanceof Date` compile and are now always `false`. v14 message dates were `Date`s, so a guarded branch that always ran now never does, with no error. Drop the guard and test for presence (`created_at !== undefined`; `0` is a valid timestamp), or convert first and guard the result of `convertTimestampToDate(ts)`. - **`t('timestamp.X', { timestamp })`** is not type-checked; a raw timestamp renders `Invalid Date`. ### React types that changed @@ -3482,7 +3556,7 @@ React props typed with `stream-chat` types change with them. The React APIs are - `ChatContextValue.mutes` / `useChat().mutes`: `UserMuteResponse[]`. `getAppSettings`: `ReturnType`. - `MessageSearchResultItem` / `ChannelByMessageSearchResultItemProps.item`: `MessageResponse & { channel?: ChannelResponse }`. -- **Your own fields are custom data on every object.** In `stream-chat` v10 the fields an app adds to a message, user, member, reaction, attachment or event live in that object's `custom`, not at the top level, both in what the server returns and in what the app sends. Read `message.custom?.priority`, not `message.priority`. Send them wrapped in the request's `message`: `channel.sendMessage({ message: { text, custom: { priority } } })` (`stream-chat` v9's `channel.sendMessage({ text })` no longer takes the message at the top level), and in a send handler as `{ ...params, message: { ...params.message, custom: { ...params.message?.custom, priority } } }` ([section 14](#14-request-handlers)). A retry resends the payload cached before any handler changed it, through `retrySendMessageRequest` when one is registered, so set the field in that handler too, or set it on the message before sending. Type the fields by augmenting `CustomMessageData` (and the other `Custom*Data` interfaces), not by widening `MessageRequest` or `LocalMessage`. Filters on them use `'custom.'`. `updateUsersPartial` and the other partial updates keep the flat shape. The full rules are under [Custom data moves under `custom`](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#custom-data) in stream-chat-js. +- **Your own fields are custom data on every object.** In `stream-chat` v10 the fields an app adds to a message, user, member, reaction, attachment or event live in that object's `custom`, not at the top level, both in what the server returns and in what the app sends. Read `message.custom?.priority`, not `message.priority`. Send them wrapped in the request's `message`: `channel.sendMessage({ message: { text, custom: { priority } } })` (`stream-chat` v9's `channel.sendMessage({ text })` no longer takes the message at the top level), and in a send handler as `{ ...params, message: { ...params.message, custom: { ...params.message?.custom, priority } } }` ([section 14](#14-request-handlers)). A retry resends the payload cached before any handler changed it, through `retrySendMessageRequest` when one is registered, so set the field in that handler too, or set it on the message before sending. Type the fields by augmenting `CustomMessageData` (and the other `Custom*Data` interfaces), not by widening `MessageRequest` or `LocalMessage`. Filters on them use `'custom.'`. `updateUsersPartial` and the other partial updates (`updateMessagePartial`, `channel.updatePartial`, `channel.updateMemberPartial`) keep the flat shape: `client.updateMessagePartial({ id }, { set: { priority } })` stores `priority` under `message.custom`. `custom` itself is reserved there, so `set: { custom: { priority } }` is rejected with ``field `custom` is reserved``, and TypeScript does not catch it because `set` is `Record`. The full rules are under [Custom data moves under `custom`](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#custom-data) in stream-chat-js. **Messages:** @@ -3567,6 +3641,7 @@ Full detail: [`i18n-v15-migration.md`](https://github.com/GetStream/stream-chat- 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). Where an entry has `values`, the interpolation names changed too: `'{{ typing }} are typing'` becomes `typing.twoUsers` with `{{ firstUser }}` and `{{ secondUser }}` in place of `{{ typing }}`, for example `'{{ firstUser }} und {{ secondUser }} schreiben'` ([details](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-migration.md#keys-whose-values-changed)). 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')`. For each `t('...')` call and each dictionary key, look the string up in the key map's `keys`. Found: use its `key`. Not found: either the string was never an SDK key (for example `t('Message Failed · Click to try again')` in a custom message UI), or v15 dropped it from the catalog (`live`, `Create`, `Submit`, `Remove`, `Menu`, `{{ user }} is typing...`, `{{ users }} and {{ user }} are typing...`, ...; full list in [`i18n-v15-migration.md`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-migration.md#keys-removed-without-a-replacement)). Either way it is app-owned now. Give it a key of your own through `asDynamicKey`, with the English copy inline, and keep its translations in your dictionaries: `t(asDynamicKey('myApp.message.failed.text'), 'Message Failed · Click to try again')`. A strict-`t` error (`TS2769`) on a v14 string that is not in the map means exactly this. + A mapped key's suffix (`.ariaLabel`, `.placeholder`, `.text`) names where the SDK first used the string, not every place it shows. `t('Search')` maps to `common.search.ariaLabel`, which the SDK's own search inputs also render as their placeholder, so use it for an app's search placeholder too. Translating that key changes both the label and the placeholder. 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. | Symptom | Cause | Fix | @@ -3649,6 +3724,53 @@ v14 laid the thread beside the channel inside `Channel` itself: `.str-chat__chan 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. +**A thread that replaces the channel.** Some v14 apps showed the thread in place of the channel with CSS keyed on `.str-chat__main-panel--thread-open` (hiding the main panel's list and composer while a thread was open). That class is gone, so the rule matches nothing and the thread opens next to (or below) a channel that stays visible. Decide in markup instead: the app knows when its thread is open, because it holds the `thread` it passes to ``. Render the channel's content in an app-owned wrapper and leave it out while a thread is open: + +```tsx +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import { + Channel, + ChannelHeader, + MessageComposer, + MessageList, + Thread, + ThreadHeader, +} from 'stream-chat-react'; + +export const ChannelOrThread = ({ + channel, + thread, +}: { + channel: StreamChannel; + thread?: StreamThread; +}) => ( +
+ + + + {thread && ( + + + + + + )} +
+); +``` + +`hidden` keeps the channel's list mounted, so its scroll position survives the round trip; unmount it instead (`{!thread && ...}`) if that does not matter. `Channel` stays mounted either way, so the channel stays active. This is the layout without `ChatView`, where the app holds the open thread in its own navigation state ([section 4](#without-chatview)). Inside `ChatView`, which panels show is decided by its layouts and slots ([section 3](#3-layout-window-is-gone-chatview-is-rebuilt)). + +The CSS grep above misses the same assumption in code. Search `.ts` / `.tsx` / `.js` for the removed classes in DOM queries (`querySelector('.str-chat__main-panel')`, `closest(...)`, `classList.contains(...)`): they now return `null` or `false`, and code that measured, scrolled or focused through them silently does nothing: + +```bash +code 'str-chat__(main-panel|container|channel-list-inner|thread--virtualized)([^a-z0-9_-]|$)' +``` + **Give the lists a width.** `ChannelNavigation` / `ChannelList` and `ThreadList` size themselves from `--str-chat__channel-list-width` and `--str-chat__thread-list-width`. Both default to `100%` with `flex-shrink: 0`. In a view that puts a list beside a `` (the [channels and threads views](#channels-and-threads-views) above), the list takes the whole row, the slot collapses to nothing and the page scrolls sideways. Set both on the `ChatView` root (`div.str-chat.str-chat__chat-view`): ```css