From 83192804afb9ae6510889870c5dc1e6ea712a99d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 10:43:27 +0100 Subject: [PATCH 1/2] =?UTF-8?q?docs(ai-migration):=20make=20the=20v9=20?= =?UTF-8?q?=E2=86=92=20v10=20guide=20agent-ready=20and=20re-verify=20again?= =?UTF-8?q?st=20V10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure ai-docs/ai-migration-v9-to-v10.md for coding agents: rules, companion documents, a phased workflow with a definition of done and a report template, root-agnostic detection, a consolidated table of breaks that still compile, and verification with a leftover-identifier grep at the end. Duplicate ids are resolved (i18n is Part S, Verify is unnumbered), K.x is in order, and every § reference resolves. Re-verify against V10 HEAD: cover #3916's reopen-at-latest default and useJumpToLatestOnOpen, correct the statusReporter, useMutedUsers, typing and DEFAULT_BACKEND_SORT claims, and drop names that only existed in release candidates. Sections with no integrator action shrink to one line, with their detail moved to v10-breaking-changes.md. The v8 → v9 guide now points at this one. --- ai-docs/ai-migration-v9-to-v10.md | 1107 +++++++++++++------------ ai-docs/ai-migration.md | 3 + ai-docs/channel-state-ui-migration.md | 10 +- ai-docs/i18n-v10-migration.md | 6 +- ai-docs/v10-breaking-changes.md | 305 ++++++- ai-docs/v10-docs-impact.md | 2 +- 6 files changed, 872 insertions(+), 561 deletions(-) diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index e6a3992490..291267ff67 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -1,171 +1,289 @@ -# stream-chat-react-native v9 → v10 — Agent Migration Guide - -> Machine-oriented migration reference for AI coding agents, mirroring the style -> of `ai-migration.md` (v8 → v9). v10 adopts the `stream-chat` v10 **reactive -> state layer**: state that used to be copied into React context is now read -> reactively from `stream-chat` instance stores (`channel.state`, -> `channel.messagePaginator`, `thread.messagePaginator`) via -> `useStateStore(store, selector)`. This guide documents every integrator-facing -> breaking change verified against the v10 source. - -## 0. For the agent (read first) - -1. **Your training data predates v10.** Do not rely on memory for v10 symbols or - export paths. Verify against the installed SDK source under - `node_modules/stream-chat-react-native-core/src/`. -2. **The unifying idea:** state that used to be copied into React context is now - read reactively from `stream-chat` instance stores via the SDK hook - `useStateStore(store, selector)`. The channel exposes a **single unified - reactive store** on `channel.state` and a message paginator on - `channel.messagePaginator`. (Earlier v10 pre-releases exposed per-concern - handles `channel.state.readStore` / `typingStore` / `membersStore` / - `watcherStore` / `ownCapabilitiesStore`; these were **removed** in favor of the - one `channel.state` store — see §K.) -3. **Resolution hooks** (new in v10, additive — use these as the entry points): - - `useChannelContext().channel` — the active `Channel` instance. - - `useChannel()` — `thread?.channel ?? channel` (thread-aware). - - `useMessagePaginator()` — the thread's `messagePaginator` inside a ``, - `channel.messagePaginator` otherwise. - - `useStateStore(store, selector)` — subscribe to a `StateStore` with a - memo-stable selector (return a stable object; do not allocate fresh arrays - inside the selector). -4. **Message ops and mark-read moved to new hooks.** The message operations - formerly on `useMessagesContext()` (§9) and `markRead` formerly on - `useChannelContext()` (§5) now live on the exported hooks - `useMessageOperations()` and `useMarkRead()` — both **new in v10**. Use those - as the drop-in replacements; the underlying `channel.*` instance methods are - available too for non-React call sites. -5. **Capability is preserved — only the means changed.** Every removed symbol - has a documented v10 replacement (a store, an instance method, or a component - override). If you know the v9 symbol you used, Ctrl-F it in the mapping table - below. -6. **Detect before editing.** Run §1; skip any section with zero hits. +# stream-chat-react-native v9 → v10: AI migration guide + +Execution guide for a coding agent upgrading an app from v9 to v10 of `stream-chat-react-native` (React Native +CLI), `stream-chat-expo` (Expo) and `stream-chat-react-native-core` (the shared core both wrappers re-export). +v10 runs on `stream-chat` v10, whose reactive state layer replaces most of what the SDK used to copy into React +context: state is read from `stream-chat` instance stores (`channel.state`, `channel.messagePaginator`, +`thread.messagePaginator`) with `useStateStore(store, selector)`. Upgrade the SDK and `stream-chat` together. + +Verified against `V10` @ `74884ff55` (`stream-chat-react-native-core@9.11.1` in `package/package.json`; the +version is bumped to 10.x at release; `stream-chat@^10.0.0-rc.30`), 2026-11-03. + +## Rules for the agent + +1. **The installed packages are the source of truth, not your memory and not this guide.** Before writing a + v10 symbol, prop or import, find it in: + - `node_modules/stream-chat-react-native-core/lib/typescript/index.d.ts` (public types; the source ships too, + under `node_modules/stream-chat-react-native-core/src/`). `stream-chat-react-native` and `stream-chat-expo` + re-export all of it; import from the wrapper the app already uses. If the core package is not at the top of + `node_modules` (pnpm, nested installs), locate it with + `node -p "require.resolve('stream-chat-react-native-core/package.json', { paths: [require.resolve('stream-chat-react-native/package.json')] })"` + (use `stream-chat-expo/package.json` for Expo apps). + - `node_modules/stream-chat/dist/types/index.d.ts` (and `node_modules/stream-chat/src/`) for `stream-chat`. + - `node_modules/@stream-io/i18n/dist/index.d.ts` for the i18n runtime behind `Streami18n`. +2. **If a symbol this guide names is missing from the installed package, stop and report it.** Do not invent a + fallback, and do not keep the v9 code "just in case". +3. **Detect before editing.** Run §1; skip every section whose detection returns zero hits. +4. **Make the smallest edit that fixes the break.** Code that still type-checks and still does what the app + needs stays as it is. The snippets show one valid form, not the only one; do not rewrite working code into + their shape. +5. **A green build is not the finish line.** Many v10 changes compile and still change behaviour, and in plain + JavaScript none of them fail to compile. Walk [Breaks that compile](#breaks-that-compile) after the build is + green. +6. **Keep the app's behaviour** unless a section says an SDK default changed and the app relied on the old + default; then restore the old behaviour explicitly, or list it as a decision for the human. +7. **Do not edit lockfiles, generated files or `node_modules` by hand.** Change dependencies with the app's + package manager only. +8. **`stream-chat` errors this guide does not cover** — look the symbol up in the `stream-chat` guide and its + [type rename table](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#type-rename-table) + (see Companion documents). Do not guess a v10 name from a v9 one. +9. **How the state model changed, in one line:** state that used to come from React context is read from + `stream-chat` stores. The entry points are `useChannelContext().channel` (the `Channel`), `useChannel()` + (thread-aware: `thread?.channel ?? channel`), `useMessagePaginator()` (the thread's paginator inside a + ``, the channel's otherwise) and `useStateStore(store, selector)`. Define selectors at module scope, + return an object of slice references, and never allocate new arrays inside a selector. Message operations + moved to `useMessageOperations()` (§9) and mark-read to `useMarkRead(channel)` (§5). +10. **Section ids** (`§4`, `§13.1`, `§K.5`, `P.17`, `§R.2`) are unique. Find a section by searching for its id + at the start of a heading (`## 4.`, `## 13.1`, `## K.5`, `## P.17`, `### 2.1`); §16.1–§16.3 are numbered + bullets inside §16. Each section names the imports it needs. + +## Companion documents + +Fetch these only when the "when" column applies. `ai-docs/` is not part of the npm packages, so read them +from these URLs. + +| Document | Raw URL | When to fetch | +| --- | --- | --- | +| `stream-chat` v9 → v10 guide | https://raw.githubusercontent.com/GetStream/stream-chat-js/release-v10/ai-docs/ai-migration-v9-to-v10.md | A `stream-chat` compile error or call this guide does not cover (§17.5, §R.7, §R.8 cover the common ones) | +| `stream-chat` v10 breaking changes (reference) | https://raw.githubusercontent.com/GetStream/stream-chat-js/release-v10/ai-docs/v10-breaking-changes.md | Looking up a renamed `stream-chat` type ([type rename table](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#type-rename-table)) or a `stream-chat` behaviour change ([behaviour index](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#behaviour-index)) | +| RN v10 breaking changes (reference) | https://raw.githubusercontent.com/GetStream/stream-chat-react-native/V10/ai-docs/v10-breaking-changes.md | Checking that a removed prop or field is covered; edge cases and fix details behind the one-line "No action" sections | +| i18n migration guide | https://raw.githubusercontent.com/GetStream/stream-chat-react-native/V10/ai-docs/i18n-v10-migration.md | The app registers translations, calls `t()` with its own keys, constructs `Streami18n`, or formats dates (§19) | +| i18n key map (v9 key → v10 key) | https://raw.githubusercontent.com/GetStream/stream-chat-react-native/V10/ai-docs/i18n-v10-key-map.json | Renaming v9 translation keys in the app's dictionaries (§19) | + +## Workflow + +**Phase 0 — Preflight.** Record, before changing anything: + +- the installed versions of `stream-chat-react-native` / `stream-chat-expo`, `stream-chat-react-native-core`, + `stream-chat`, `react-native`, `expo`, `react` and `typescript`, from `package.json` and the lockfile; +- the package manager (from the lockfile), the Node version (`node -v`), and whether the app is TypeScript or + plain JavaScript; +- that the git tree is clean (`git status`); if it is not, stop and ask; +- the baseline result of the app's own typecheck, lint and test scripts (read `scripts` in `package.json`), so + that errors you did not cause are not counted against the migration. + +**Phase 1 — Install and platform.** Apply §R.1 first: React Native ≥ 0.79, Expo SDK ≥ 53, and Node ≥ 22.18 for +installs and CI. If the app is below a floor, upgrading React Native / Expo is its own task: stop and report +it. Then install the v10 wrapper the app uses (`stream-chat-react-native` or `stream-chat-expo`; list versions +with `npm view versions`; if no stable 10.x exists and the user named no version, ask which +pre-release to use). Set `stream-chat` to the range in the installed core package's `dependencies` (§17). Run +`pod install` for iOS, and `npx expo install --check` for Expo apps. + +**Phase 2 — Detect.** Run every command in §1 from the app root. Write down the sections with hits. + +**Phase 3 — Apply compile-breaking sections**, in the order they appear in this guide, only for sections with +hits. Re-run the typecheck after each Part. For a remaining `stream-chat` error, see Rule 8. + +**Phase 4 — Walk [Breaks that compile](#breaks-that-compile).** Run each row's Detect command. Fix the row, or +record it as a decision for the human when the right behaviour is a product choice. + +**Phase 5 — Verify.** Run [Verification](#verification): typecheck, lint, tests, the leftover-identifier grep +(must return zero hits), and list the runtime checks that apply. + +**Phase 6 — Report**, using the template below. + +**Definition of done** + +- The app's typecheck, lint and tests are no worse than the Phase 0 baseline. +- The leftover-identifier grep in [Verification](#verification) returns no hits outside comments and changelogs. +- Every section with detection hits is either applied or listed as skipped with a reason. +- Every Breaks-that-compile row with hits is fixed or listed as an open decision. +- Dependencies changed only through the package manager; no lockfile or generated file edited by hand. + +**Report template** + +```text +Upgraded: @ → , stream-chat@ → (, Node ) +Changed files by section: § , ; § … +Skipped sections (zero hits / not applicable): § ; … +Breaks that compile — fixed: ; checked, no hits: +Open decisions for the human: +Symbols this guide names that were missing from node_modules: +Verification: typecheck , lint , tests , leftover grep +Runtime checks still to do on a device: +``` ## 1. Detection (run first) -Run each ripgrep against the customer's app source root. Zero hits = skip the -matching section. +Run from the app root. Each command searches every JS/TS source (`.js .jsx .mjs .cjs .ts .tsx .mts .cts`) +outside `node_modules`; patterns match the **v9** names. Zero hits for a section's commands = skip that +section. A comment line starting `#` names the section the commands below it belong to. ```bash -# §2 — TypingContext + typing prop/util changes -rg '\b(useTypingContext|TypingProvider|TypingContext|TypingContextValue|useCreateTypingContext|filterTypingUsers)\b' src/ -rg '\bTypingIndicatorContainer\b' src/ +# §2 — TypingContext removed; typing prop/util changes (§2.1) +rg -n -t js -t ts -g '!**/node_modules/**' '\b(useTypingContext|TypingProvider|TypingContext|TypingContextValue|useCreateTypingContext|filterTypingUsers)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A6 ' removed +rg -n -t js -t ts -g '!**/node_modules/**' '\b(loadChannelAroundMessage|loadChannelAtFirstUnreadMessage|reloadChannel|highlightedMessageId)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A8 'useChannelContext\(\)' . | rg '\bloading\b' +rg -n -t js -t ts -g '!**/node_modules/**' -A20 ', removed thread props/fields +rg -n -t js -t ts -g '!**/node_modules/**' '\b(useThreadContext|openThread|closeThread|reloadThread|loadMoreThread|loadMoreRecentThread|threadMessages|threadHasMore|threadLoadingMore|threadInstance|closeThreadOnDismount|ThreadFooterComponent|threadList|ThreadMessageComposer|ThreadType|additionalMessageListProps|additionalMessageFlashListProps|additionalMessageComposerProps|shouldUseFlashList|shouldSyncChannel|onThreadSelect|onAlsoSentToChannelHeaderPress)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A20 ') -rg '\b(useThreadContext|openThread|closeThread|reloadThread|loadMoreThread|threadMessages|closeThreadOnDismount|ThreadFooterComponent|threadList|ThreadMessageComposer|additionalMessageListProps|additionalMessageFlashListProps|additionalMessageComposerProps|shouldUseFlashList|shouldSyncChannel)\b' src/ -rg ' no longer reach the menu +rg -n -t js -t ts -g '!**/node_modules/**' '\b(useMessageActions|useMessageActionHandlers)\(\s*\{|\bMessageActionsHookProps\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A10 ' only +rg -n -t js -t ts -t json -g '!**/node_modules/**' 'messageList\.newMessages\.text|messageList\.scrollToBottom\.withCount|channelPreview\.unreadCount\.accessibilityLabel|message\.reactionList\.more|avatar\.channel\.group' . +rg -n -t js -t ts -g '!**/node_modules/**' '\bisMessageAIGenerated\b' . # §R.1 — platform floors and packaging (also check package.json: react-native >=0.79, expo >=53) -rg "from 'stream-chat'" src/ | rg '\b(StateStore|Unsubscribe)\b' +rg -n '"(react-native|expo|dayjs|i18next|@stream-io/state-store)"' package.json +rg -n -t js -t ts -g '!**/node_modules/**' "import[^;]*\b(StateStore|Unsubscribe)\b[^;]*from 'stream-chat'" . # §R.2 — connection status off ChatContext -rg '\b(isOnline|connectionRecovering)\b' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(isOnline|connectionRecovering|statusReporter|NetworkDownIndicator)\b' . # §R.3 — delivery receipts -rg '\b(readBy|deliveredToCount)\b' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(readBy|deliveredToCount)\b' . # §R.4 — more removed / retyped context fields and props -rg '\b(maximumMessageLimit|uploadAbortControllerRef|doFileUploadRequest|threadInstance|forceUpdate|useCreateMessageContext|InlineLoadingMoreIndicator|InlineLoadingMoreRecentIndicator|LoadingErrorIndicator|CommandSuggestionItem)\b' src/ -rg '\bAIStates\.\w+\s*\]\.includes\b' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(uploadAbortControllerRef|forceUpdate|InlineLoadingMoreIndicator|InlineLoadingMoreRecentIndicator|LoadingErrorIndicator|CommandSuggestionItem|giphyVersion|ImageGalleryStateStore)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' 'AIStates\.\w+\s*\]\.includes\b' . # §R.5 — / behaviour -rg '\b(initializeOnMount|isLocalUnreadCountEnabled|supersededBy|UnreadMessagesNotification)\b' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(initializeOnMount|isLocalUnreadCountEnabled|supersededBy|UnreadMessagesNotification|filterErrorMessages)\b' . -# §R.6 — channel list query -rg 'new ChannelPaginator\b' -A12 src/ | rg '\b(filters|offset|limit)\b' +# §R.6 — channel list query inputs +rg -n -t js -t ts -g '!**/node_modules/**' -A12 'new ChannelPaginator\b' . | rg '\b(filters|offset|limit)\b' # §R.7 — stream-chat state read directly -rg 'channel\.state\.(messages|latestMessages|messageSets|threads|pinnedMessages|isUpToDate|findMessage|loadMessageIntoState|addMessageSorted|mutedUsers)\b' src/ -rg '\.lastMessage\(\)|\b(activeChannels|hydrateActiveChannels|threadsById)\b' src/ -rg 'client\.threads\.(state|loadNextPage)\b|thread\.(loadNextPage|loadPrevPage|queryReplies)\b|thread\.state\b' src/ +rg -n -t js -t ts -g '!**/node_modules/**' 'channel\.state\.(messages|latestMessages|messageSets|threads|pinnedMessages|isUpToDate|findMessage|loadMessageIntoState|addMessageSorted|mutedUsers)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' '\.lastMessage\(\)|\b(activeChannels|hydrateActiveChannels|threadsById)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' 'client\.threads\.(state|loadNextPage)\b|thread\.(loadNextPage|loadPrevPage|queryReplies)\b|thread\.state\b|channel\.query\(' . # §R.8 — stream-chat client calls -rg '\b(addDevice|getDevices|removeDevice|setLocalDevice|queryChannels|queryUsers|queryMembers|muteUser|unmuteUser|flagMessage|flagUser|banUser|shadowBan|upsertReminder|createReminder|stopLiveLocationSharing|sendEvent|ErrorFromResponse|ai_generated)\b' src/ -rg 'client\.channel\(' -A3 src/ | rg '\b(name|members)\b' +rg -n -t js -t ts -g '!**/node_modules/**' '\b(addDevice|getDevices|removeDevice|setLocalDevice|queryChannels|queryUsers|queryMembers|muteUser|unmuteUser|flagMessage|flagUser|banUser|shadowBan|upsertReminder|createReminder|stopLiveLocationSharing|sendEvent|ErrorFromResponse|connectUser|logger)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A3 'client\.channel\(' . | rg '\b(name|members|image)\b' # §R.9 — offline DB contract (custom offline code only) -rg '\b(getChannelsForQuery|handleOfflineFailedUpdateMessagePendingTask|mergeFailedMessageUpdateIntoPendingSendMessage|deleteMessagesForChannel|OfflineStoreApis)\b|SqliteClient\.logger' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(getChannelsForQuery|handleOfflineFailedUpdateMessagePendingTask|mergeFailedMessageUpdateIntoPendingSendMessage|deleteMessagesForChannel|OfflineStoreApis|OfflineDB|SqliteClient)\b' . # §R.10 — i18n gaps -rg '\b(geti18Instance|getAvailableLanguages|getTranslations|addOnTFunctionOverrideListener|userLanguage|isDayOrMoment|SCROLL_TO_BOTTOM_\w+)\b' src/ -rg 'Location end at|Remind Me|ReminderNotification|MessageEditedTimestamp' src/ +rg -n -t js -t ts -g '!**/node_modules/**' '\b(geti18Instance|getAvailableLanguages|getTranslations|addOnTFunctionOverrideListener|userLanguage|isDayOrMoment|SCROLL_TO_BOTTOM_\w+)\b' . +rg -n -t js -t ts -t json -g '!**/node_modules/**' 'Location end at|Remind Me|ReminderNotification|MessageEditedTimestamp' . # §R.11 — deep imports -rg "stream-chat-react-native(-core)?/(src|lib)/" src/ +rg -n -t js -t ts -g '!**/node_modules/**' "stream-chat-(react-native|expo)(-core)?/(src|lib)/" . + +# §19 — i18n: keys, the Streami18n API, the a11y namespace, date helpers +rg -n -t js -t ts -g '!**/node_modules/**' '\bStreami18n\b|registerTranslation|translationsForLanguage|setLanguage|getTranslators|addOnLanguageChangeListener|relativeCompactDateFormatter' . +rg -n -t js -t ts -t json -g '!**/node_modules/**' "t\(\s*'a11y/|t\(\s*'[A-Z][a-z]+ [a-z]|\"a11y/" . # v9 keys WERE the English copy +rg -n -t js -t ts -g '!**/node_modules/**' '\b(enTranslations|esTranslations|frTranslations|heTranslations|hiTranslations|itTranslations|jaTranslations|koTranslations|nlTranslations|ptBRTranslations|ruTranslations|trTranslations)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' -A4 'getDateString|getDateStringForA11y' . | rg '\bdate:' +rg -n '"(dayjs|i18next)"' package.json ``` --- @@ -179,7 +297,7 @@ means changed. Details in the linked section. |---|---|---| | `useTypingContext().typing` | `useStateStore(channel.state, s => ({ typing: s.typing }))` | §2 | | `filterTypingUsers({ client, thread, typing })` | `filterTypingUsers({ client, threadId: thread?.id, typing })` | §2.1 | -| `usePaginatedMessageListContext().messages` | `useStateStore(channel.messagePaginator.state, s => s.items)` | §3 | +| `usePaginatedMessageListContext().messages` | `useStateStore(channel.messagePaginator.state, s => ({ messages: s.items }))` | §3 | | …`.hasMore` / `.hasMoreNewer` | `channel.messagePaginator.state.hasMoreTail` / `.hasMoreHead` | §3 | | …`.loadMore()` / `.loadMoreRecent()` | `channel.messagePaginator.toTail()` / `.toHead()` | §3 | | …`.loadLatestMessages()` | `channel.messagePaginator.jumpToTheLatestMessage()` | §3 | @@ -216,8 +334,8 @@ means changed. Details in the linked section. | `ThreadMessageComposer` slot / `additionalMessageComposerProps` | a `MessageComposer` child of `` with its own props | §10.2 | | `MessageList` slot | a `MessageList` (or `MessageFlashList`) child of ``, or your own list component | §10.2 | | `useMessageComposerContext().thread` (parent message; deep import only) | `thread` is now the `Thread`; parent on `thread.state` | §10.2 | -| `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | P.17 | -| `` props | removed — list reads the `Thread` from `ThreadContext` + paginator internally; `thread` stays, retyped to `Thread \| null` | §12 | +| `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | §11, P.17 | +| `` props | removed — list reads the `Thread` from `ThreadContext` + paginator internally; `thread` stays, retyped to `Thread` (or `undefined`) | §12 | | custom `FooterComponent` / `HeaderComponent` reading `loadingMore` from context | receive `{ loadingMore?: boolean }` as a prop | §12.1 | | `` props | removed — state lives in the paginator | §13 | | `useMutedUsers(channel)` | `useMutedUsers()` | §14 | @@ -240,11 +358,10 @@ means changed. Details in the linked section. | `new StreamChat(key, { axiosRequestConfig, persistUserOnConnectionFailure, … })` / `useCreateChatClient({ options })` | the same fields under `client`: `new StreamChat(key, { client: { … } })` | §17.7 | | a `connection.changed` listener | delete it — the event is **no longer dispatched** (it compiles and never fires). Reconnect is `client.connectionRecovery`'s job; status is `client.wsConnection.state` / `client.networkConnection.state` (`useWSConnectionState()`, `useNetworkConnectionState()`, `useSettledWSConnectionHealth()`) | §L, §R.2 | | `connection.recovered` as "the `_reconnect()` path fired" | now dispatched on **every** reconnect path, after the recovery reload | §L.2 | -| `useStateStore(channel.state.readStore / typingStore / membersStore / watcherStore / ownCapabilitiesStore, sel)` | `useStateStore(channel.state, sel)` — drop the `.Store`, keep the selector | §K.1 | -| `channel.state.mutedUsersStore` | `client.mutedUsersStore` (via `useMutedUsers()`) | §K.2 | -| in-place `channel.data.member_count = n` / `.own_capabilities = […]` | reassign `channel.data = { …channel.data, member_count: n }` | §K.5 | +| rendering from `channel.state.members` / `.read` / `.typing` / `.watchers` and re-reading on events | `useStateStore(channel.state, selector)` — the getters still exist but do not subscribe | §K.1 | +| `channel.state.mutedUsers` | `useMutedUsers()` / `client.mutedUsersStore` | §K.2 | +| in-place `channel.data.member_count = n` (or any `channel.data` field) | reassign `channel.data = { …channel.data, member_count: n }` | §K.5 | | `channel.disconnected` | `channel.pendingDisposal` (hard rename — no alias) | §K.8 | -| `WatcherState` type | `ChannelWatchState` (now also carries `watchStatus`) | §K.3 | | `t('Send Message')` — the key *was* the English copy | `t('messageInput.sendMessage.accessibilityLabel', 'Send Message')` — stable dotted key, copy inline | §19 | | `t('a11y/Send message')` | the `.accessibilityLabel` leaf of the owning component's key | §19 | | `import { frTranslations } from 'stream-chat-react-native'` | removed — English is the only bundled language; supply your own dictionary | §19 | @@ -252,7 +369,7 @@ means changed. Details in the linked section. | `const t = await i18n.setLanguage('de')` | `await i18n.setLanguage('de')` → `void`; read `i18n.t` or subscribe to `i18n.state` | §19 | | `i18n.addOnLanguageChangeListener(fn)` | `i18n.state.subscribeWithSelector(({ t }) => ({ t }), fn)` | §19 | | `i18n.getTranslators()` | `i18n.init()` — removed, not aliased; same return value | §19 | -| `{{ timestamp \| relativeCompactDateFormatter }}` | `{{ timestamp \| timestampFormatter(relativeCompact: true) }}` | §19 | +| the `relativeCompactDateFormatter` formatter in a translation string | the `timestampFormatter(relativeCompact: true)` formatter in its place | §19 | | `getDateString({ date, … })` | `getDateString({ messageCreatedAt, … })`; returns `null`, not `undefined`, when unrenderable | §19 | | `getDateStringForA11y({ date, … })` | `getCalendarDateStringForA11y({ messageCreatedAt, … })` — same behaviour under a new name | §19 | | poll `state.errors.x` as an English string | a `PollComposerValidationError` — key your copy on `error.code`, fall back to `error.message` | §19 | @@ -274,6 +391,76 @@ means changed. Details in the linked section. | custom `AbstractOfflineDB.getChannelsForQuery({ filters, sort })` | `getChannelsForQuery({ userId, options })` — `options.filter_conditions`, `options.sort` | §R.9 | | `i18n.geti18Instance()` | `i18n.i18nInstance` | §R.10 | | `t('timestamp/ReminderNotification', …)` and the other app-callable formatter keys | register the expression yourself in a `LooseTranslationDictionary` | §R.10 | +| `` | `channel.messagePaginator.jumpToMessage(id, …)` yourself + `initialScrollToFirstUnreadMessage={!messageId}` | §8 | +| `channel.data.name` / `.image`, `attachment.mime_type` / `file_size` | `channel.data.custom?.name`, `attachment.custom?.mime_type` | §17.1 | +| `deleteMessage(msg, { hardDelete: true })` / `{ deleteForMe: true }` | `{ hard: true }` / `{ delete_for_me: true }` | §17.2 | +| `message.moderation_details` | `message.moderation` (V2) | §17.4 | +| `new Date(message.created_at)` / `dayjs(message.created_at)` | `convertTimestampToDate(message.created_at)` — server dates are unix-nanosecond `TimestampNS` numbers | §17.5 | +| `{ last_message_at: -1 }` sort objects | `[{ field: 'last_message_at', direction: -1 }]` | §17.5 | +| `` | `client.config.set({ channel: { messagePaginator: { maxLoadedItems: n } } })` | §O.1 | +| `` / `` | `client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled } } })`; offline support stays on `` | §O.3 | +| `useSelectedChannelState`, `findInMessagesById`, `getLastReceivedMessage`, `DebugContextProvider` | see the table in P.1 | P.1 | +| `useIsOnline(client, …)` | `useConnectionLifecycle(client, …)` (returns nothing) | P.2 | +| `useMutedChannels(channel)` | `useMutedChannels()` | P.3 | +| `OfflineStoreApis.*({ currentUserId })` | `{ userId }` | P.6 | +| `` | removed — the list pages its own paginator | P.7 | +| `useMessageList({ threadList })` | `useMessageList({ paginator: useMessagePaginator() })` | P.8 | +| `useChatContext().appSettings` / `.mutedUsers` / `.channel` / `.enableOfflineSupport` | `await getAppSettings()` / `useMutedUsers()` / your navigation state / `client.offlineDb !== undefined` | P.11 | +| `useClientMutedUsers(client)` / `useAppSettings(…)` | `useMutedUsers()` / `useChatContext().getAppSettings()` | P.12 | +| `` | a `sendMessageRequest` handler that calls `defaultRequest(params)` | P.17 | +| `useChannelsContext().channels` / `hasNextPage` / `loadingChannels` | `useStateStore(useChannelsContext().paginator.state, selector)` | P.20 | +| `` / `useMessageContext().isMessageAIGenerated` | `` / `useChatContext().isMessageAIGenerated` | Q.3 | +| `client.offlineDb.syncManager.syncStatus` | `client.offlineDb.syncManager.isSynced` | §N.3 | + +## Breaks that compile + +These compile cleanly in TypeScript, or are silently ignored in plain JavaScript, and change behaviour at +runtime. Check every row whose Detect finds something; a green build does not cover them. `rg …` below is short +for `rg -n -t js -t ts -g '!**/node_modules/**' … .` run from the app root. + +| Symptom | Cause | Detect | Fix | § | +| --- | --- | --- | --- | --- | +| Translation overrides stop applying; a non-English UI renders English | v9 keys were the English copy; v10 keys are dotted ids, and the 12 bundled non-English dictionaries are gone. An old key never matches | `rg -e "registerTranslation" -e "t\(\s*'[A-Z]" -e "'a11y/"` | Rename keys with the key map, register your own dictionaries, type them as `TranslationDictionary` | §19 | +| An override of "{{count}} new messages" (and four other count strings) no longer applies | They are plural keys now; the bundled English plural wins over a bare-key override | `rg -t json -e 'newMessages\.text' -e 'withCount' -e 'unreadCount\.accessibilityLabel' -e 'reactionList\.more' -e 'avatar\.channel\.group'` | Provide the `_one` / `_other` forms | Q.1 | +| Dates render blank, `Invalid Date`, or in 1970 / far future | Server timestamps are unix-nanosecond numbers (`TimestampNS`); `new Date(ts ?? …)`, date libraries and arithmetic with `Date.now()` compile | `rg -e 'new Date\(' -e 'dayjs\(' -e 'moment\('` (review the ones given a server field such as `created_at`) | `convertTimestampToDate(ts)` at the boundary; compare raw numbers | §17.5 | +| Dates stay English after registering a language | Two copies of `dayjs` (the app pins a different version); the locale registers on the copy the SDK does not use | `npm ls dayjs` (or the package manager's equivalent) | One `dayjs` copy, range-compatible with `^1.11.23`; do not declare `i18next` | §19, §R.1 | +| Channel name, image or attachment metadata renders empty; `isMessageAIGenerated` returns false | Custom fields moved under `.custom` (`channel.data.custom.name`, `message.custom.ai_generated`, `attachment.custom.mime_type`) | `rg -e '\.data\??\.name\b' -e '\.data\??\.image\b' -e '\.mime_type\b' -e '\.file_size\b' -e '\.ai_generated\b'` | Read `custom`; `useChannelName(channel)` for names | §17.1, §R.8 | +| User fields you connect with (`imageUrl`, `nickname`, …) are lost | `connectUser` takes a `ClientUser`; extra top-level keys are rejected only in an object literal passed inline | `rg -B8 'connectUser\('` | Avatar in `image`, other app fields under `custom` | §R.8 | +| A `connection.changed` listener never fires | The event is no longer dispatched | `rg "connection\.changed"` | Delete it; read `useNetworkConnectionState()` / `useSettledWSConnectionHealth()` or `client.wsConnection.state` | §L, §R.2 | +| Offline banners always or never show (plain JS) | `useChatContext().isOnline` / `connectionRecovering` are gone; `useNetworkConnectionState().isOnline` is tri-state (`undefined` = unknown) | `rg -e '\bisOnline\b' -e '\bconnectionRecovering\b'` | Test `isOnline === false`, never `!isOnline` | §R.2 | +| A `connection.recovered` listener now runs on every app foreground | It fires on every reconnect path, including background → foreground | `rg "connection\.recovered"` | Make the listener idempotent and cheap | §L.2 | +| Channels or the open channel load twice after a reconnect | The client recovers lists, active channels and threads itself; an app-side re-query or re-watch duplicates it | `rg -e 'connection\.changed' -e 'connection\.recovered' -e 'recoverState'` | Delete the app's reconnect re-query / re-watch | §L, §L.1 | +| A custom channel or thread screen not built on `` / `` is not refreshed after a reconnect | Recovery reloads only `active` channels and threads | Review screens that render messages without `` / `` | `useEffect(() => channel.activate(), [channel])` (same for `thread`); no `deactivate()` exists | §K.4, §L.4 | +| The channel list never updates live | Its `ChannelPaginator` is not registered on `client.channelManager` | `rg -e 'new ChannelPaginator' -e 'insertPaginator'` | `client.channelManager.insertPaginator({ paginator })` before `connectUser` | §18.6 | +| Channel-list callbacks (`onNewMessage`, `onAddedToChannel`, …) and `filters` / `sort` / `options` props do nothing (plain JS) | The props are removed; the list is a projection of its paginator's query | `rg '` props do nothing (plain JS): `do*Request`, `preSendMessageRequest`, throttle props, `messageId`, `maximumMessageLimit`, `allowSendBeforeAttachmentsUpload`, `enableOfflineSupport`, `isMessageAIGenerated`, `thread`, `threadList`, `isOnline` | Moved to `client.config`, ``, ``, or removed | `rg '` (`handleDelete`, `messageActions`, …) are ignored | The action menu reads them from `` props | `rg '` | §15.1 | +| Reactions or edits appear twice, or flicker | Reactions and edits are always optimistic now; an app-side optimistic layer double-applies | Review custom reaction / edit code | Remove the app's optimistic update | §9, §M.1 | +| An unhandled promise rejection after a failed send | `sendMessage` rejects instead of swallowing the error | `rg '\bsendMessage\('` | Catch the rejection in custom send paths | §16.2 | +| Opening a channel loads 25 messages instead of ~100 | `` applies a page size of 25 unless one is registered | Review | `client.config.set({ channel: { messagePaginator: { pageSize } } })` | §16.1 | +| A page size or other paginator value set at runtime reverts | Imperative `updateConfig()` / `pageSize =` is dropped when the configuration re-resolves | `rg -e 'messagePaginator\.pageSize\s*=' -e 'messagePaginator\.updateConfig\('` | Register it with `client.config.set`, or in a setup function | §13.1 | +| Link previews appear where they did not before | `linkPreviews.enabled` defaults to `true` (still gated by the channel type's URL enrichment) | Review | `client.config.set({ messageComposer: { linkPreviews: { enabled: false } } })` | §13.1 | +| Users without `upload-file` lose the attach button although uploads go to your own storage | A custom `doUploadRequest` no longer waives the capability | `rg -e 'doUploadRequest' -e 'doFileUploadRequest'` | `attachments: { customCdn: true }` | §13.1 | +| UI offers a feature the client has switched off | Reading raw `serverConfig` / `getConfig()` flags instead of the resolved config | `rg -e 'serverConfig\??\.' -e 'getConfig\(\)'` | Read `channel.config.*` / `composer.config.*` | §13.1 | +| `persistUserOnConnectionFailure` you set is overwritten | `` writes it from `enableOfflineSupport` on mount | `rg persistUserOnConnectionFailure` | Enable offline support, or set it after `` mounts | §17.7 | +| Sending while attachments upload is on (or off) unexpectedly | `` turns `pendingUploadsEnabled` on once, and never back off | `rg -e 'enableOfflineSupport' -e 'pendingUploadsEnabled'` | Register `pendingUploadsEnabled` yourself | §O.3 | +| A deleted channel keeps rendering | `` no longer unmounts on `channel.deleted` | `rg '` | P.15 | +| A thread screen reads `thread.text` / `thread.user` as `undefined` (plain JS) | `thread` is the `Thread` instance everywhere; the parent message is on `thread.state` | `rg -e '\bthread\??\.text\b' -e '\bthread\??\.user\b' -e '\bthread\??\.reply_count\b'` | `useStateStore(thread.state, (s) => ({ parentMessage: s.parentMessage }))` | §10 | +| A custom `UnreadMessagesNotification` stops marking the channel read on dismiss | Dismiss goes through its `markRead` prop | `rg UnreadMessagesNotification` | Forward `markRead` | §R.5 | +| `` still sends a request | It runs a non-watching query when the paginator is empty | `rg initializeOnMount` | Seed the paginator before mounting | §R.5 | +| A screen holds a stale channel after a direct message is created | `` may render `channel.supersededBy` | Review code that reads the channel from its own prop inside `` | Read it from `useChannelContext()` | §R.5 | +| A deep link lands on the newest messages instead of the target | `` / `` jump back to the newest page when opened on an older one, unless a jump is already landed or loading | Review deep-link code (`rg 'jumpToMessage'`) | Start the jump before `` mounts | §8 | +| Plain JS: `stream-chat` calls written for v9 (`addDevice`, `queryChannels` expecting `Channel[]`, `muteUser`, positional arguments) fail at runtime | Request objects, raw `queryChannels`, moved moderation methods, renamed device methods | §R.8 detection | Apply §R.8; anything else in the `stream-chat` guide | §R.8 | +| `channel.query()` no longer updates the message list | Only `watch()` and the paginator place pages | `rg 'channel\.query\('` | Page with `channel.messagePaginator.toTail()` / `toHead()` | §R.7 | +| A watch / presence request throws `No connection id is available…` | It waits for a socket instead of silently dropping `watch` | `rg -e '\.watch\(' -e 'presence:'` | `connectUser()` (or `openConnection()`) first | §R.7 | +| Console shows `stream-chat` info logs | `chatLoggerSystem` is on by default at `info` | Review | `chatLoggerSystem.configureLoggers({ default: { level: 'warn' } })` | §R.8 | +| First launch after the upgrade is cold; queued offline messages are lost | `dbVersion` 16 → 18 reinitializes the offline database | `rg enableOfflineSupport` | Nothing to fix; tell the human | §N.2 | +| Screen-reader dates are spelled out in full instead of "Today" / "Yesterday" | v9's `getDateStringForA11y` is `getCalendarDateStringForA11y` in v10; the name `getDateStringForA11y` now exports a different, more verbose variant | `rg getDateStringForA11y` | Rename to `getCalendarDateStringForA11y` (root import) | §19 | +| `getDateString({ date })` returns `null` (plain JS) | The parameter is `messageCreatedAt` | `rg 'getDateString' -A4` | Rename the parameter | §19 | --- @@ -304,10 +491,9 @@ const { channel } = useChannelContext(); const { typing } = useStateStore(channel.state, typingSelector) ?? { typing: {} }; ``` -`typing` has the same shape as before (`Record`). The selector is -unchanged from any earlier pre-release that used `channel.state.typingStore` — -`channel.state` is flat and carries the same `typing` key, so `(s: TypingUsersState) -=> …` stays assignable (see §K). +`typing` is still keyed by user id; each value is now the typed `typing.start` event +(`Record>`). `channel.state` is one flat store (§K), so a +`(s: TypingUsersState) => …` selector is assignable to it. ### 2.1 Typing prop/util changes (if you override the typing indicator) @@ -403,10 +589,10 @@ const { members, watcherCount } = useChannelContext(); ```tsx import { useChannelContext, useStateStore } from 'stream-chat-react-native'; -import type { MembersState, WatcherState } from 'stream-chat'; +import type { ChannelWatchState, MembersState } from 'stream-chat'; const membersSelector = (s: MembersState) => ({ members: s.members }); -const watcherSelector = (s: WatcherState) => ({ +const watcherSelector = (s: ChannelWatchState) => ({ watcherCount: s.watcherCount, watchers: s.watchers, }); @@ -548,7 +734,7 @@ has a direct equivalent on the paginator, which is where the state already lived — the context was forwarding it, and that forwarding re-rendered every consumer on channel traffic. -| v9 / early-v10 | v10 | +| v9 | v10 | |---|---| | `loadChannelAroundMessage({ messageId })` | `paginator.jumpToMessage(messageId, { focusReason: 'jump-to-message', focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION })` | | `` | the same `paginator.jumpToMessage(...)`, from a `useLayoutEffect` or before `` mounts — the prop is **removed** (see below) | @@ -574,11 +760,20 @@ useLayoutEffect(() => { **Use `useLayoutEffect`, not `useEffect`.** React runs every layout effect before any passive one, and the message list decides its initial anchor in a passive effect — so a layout effect lands first -and you get no scroll-to-bottom-then-jump. Starting the jump before `` mounts works too. +and you get no scroll-to-bottom-then-jump. Starting the jump before `` mounts (where you +navigate) works too, and is the safer order when the channel may have been left on an older page +(next paragraph): a layout effect in the screen runs after ``'s own layout effects. `` reads the paginator's focus signal, so it will not override a jump you have **already completed** with its own jump to the first unread. +**Reopening on an older page.** A channel's and a thread's paginator outlive the screen. When +`` or `` mounts on a paginator left on an older page (after a jump to a quoted, pinned +or searched message), it jumps back to the newest messages before the first paint. It skips this when a +jump has already landed or is still loading at mount, when it is about to jump to the first unread, and +under ``. With `initializeOnMount={false}`, call the exported +`useJumpToLatestOnOpen()` from a component inside `` / `` if you want the same behaviour. + **If you also use `initialScrollToFirstUnreadMessage`, turn it off while deep-linking:** ```tsx @@ -670,8 +865,7 @@ const MyMessage = ({ sendReaction, deleteMessage, updateMessage }) => { /* … * `update`, `retry`, `delete({ localMessage, options })`, `addReaction({ messageId, reaction })`, `deleteReaction({ messageId, type })` — runs the same optimistic lifecycle as the hook; `thread.messageOperations` does -the same for replies loaded in a thread. `stream-chat` v10 has **no** -`*WithLocalUpdate` methods. Plain `channel.sendMessage` / `channel.sendReaction` +the same for replies loaded in a thread. Plain `channel.sendMessage` / `channel.sendReaction` are HTTP-only and apply no optimistic update, so use the hook in UI code. ### 9.1 Optimistic lifecycle is LLC-owned (behavioral) @@ -927,13 +1121,11 @@ Also removed, and covered in §13.1: | `stateUpdateThrottleInterval` | `client.config.set({ channel: { messagePaginator: { stateThrottleMs } } })` | | `newMessageStateUpdateThrottleInterval` | same as above | -The two throttle props were declared but never read in v10 — they are now deleted -outright rather than left inert. `stateThrottleMs` is the real, reactive control. +`stateThrottleMs` replaces both throttle props. `doSendMessageRequest` is removed like every other `do*Request` prop — register a -`sendMessageRequest` through `client.config` instead (§13.1). It used to stay because the SDK -itself occupied that handler slot to run the attachment-upload step; `stream-chat` now awaits -uploads before any handler runs, so the slot is yours alone. +`sendMessageRequest` through `client.config` instead (§13.1). `stream-chat` awaits attachment +uploads before any handler runs. ## 13.1 Instance configuration → `client.config` @@ -1142,7 +1334,7 @@ and reads `client.mutedUsersStore`. // Before const muted = useMutedUsers(channel); // After -const muted = useMutedUsers(); // Mute[] +const muted = useMutedUsers(); // UserMuteResponse[] ``` ## 15. `useCreate*Context` signatures narrowed @@ -1220,26 +1412,24 @@ const handlers = useMessageActionHandlers(); // same shape: { handleCopyMessage, `MessageInput` catches the rejection and shows a notification. Any custom code that calls the context `sendMessage` (or `channel.messageOperations.send`) directly must handle the rejected promise itself. -- **16.3 `SendMessageDisallowedIndicator` is gated on `channel.initialized`** — - it no longer flashes during the pre-init window (before capabilities resolve). - No action needed unless you relied on the pre-init render. +- **16.3 `SendMessageDisallowedIndicator` renders only once the channel's capabilities are known** + (from a query or the offline database) and `sendMessage` is missing, and not while a message is being + edited. No action unless you relied on it rendering before capabilities arrived. --- -# Part H — Dependency (release-time) +# Part H — Dependency ## 17. `stream-chat` must be v10 v10 SDK code hard-depends on `stream-chat` v10 APIs (`channel.messagePaginator`, the unified reactive `channel.state`, `channel.messageOperations`, `messageDeliveryReporter`, -`messagePaginator.messageFocusSignal`). Ensure your app resolves `stream-chat` to `^10.x`. +`messagePaginator.messageFocusSignal`). `stream-chat` is a regular **dependency** of +`stream-chat-react-native-core` (not a peer): read the range from `dependencies` in +`node_modules/stream-chat-react-native-core/package.json`. If the app also lists `stream-chat` itself (it +does whenever it imports from it), set the app's range to that same range so exactly one copy resolves. Platform floors and other packaging changes are in §R.1. -> Repo note (for SDK maintainers, not integrators): the published `stream-chat` -> range in `package/package.json` must be bumped to `^10.x`, and the local -> development wiring removed — the root `resolutions` `portal:` entry and the -> `streamChatLocalPath` in `examples/SampleApp/metro.config.js`. - --- # Part I — stream-chat v10 type-surface (OpenAPI overhaul) @@ -1266,7 +1456,7 @@ The RN SDK augments `CustomChannelData` (`name`, `image`); `mime_type` and `file ## 17.2 `deleteMessage` options are snake_case `useMessageOperations().deleteMessage(message, options)` (and the `MessageContext` value) now takes -the LLC's `DeleteMessageOptions` (`{ hard?, delete_for_me?, deleted_by? }`) instead of the v9 RN +the LLC's `DeleteMessageOptions` (`{ hard?, delete_for_me? }`) instead of the v9 RN shape (`{ hardDelete?, deleteForMe? }`). The `boolean` shorthand (`deleteMessage(msg, true)` = hard delete) still works. @@ -1300,8 +1490,9 @@ detection won't fire.** If you call the `stream-chat` client/channel directly or annotate with its types, the reference is [`ai-docs/v10-breaking-changes.md`](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md) -in `stream-chat-js` (branch `release-v10`; re-pin to the release tag at v10 GA). The calls RN apps make most are in -§R.7 and §R.8. Highlights that hit integrator code: +in `stream-chat-js`, with every renamed type in its +[type rename table](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#type-rename-table). +The calls RN apps make most are in §R.7 and §R.8. Highlights that hit integrator code: - **Server-sent dates are unix-nanosecond numbers** on every response and event type (`created_at`, `updated_at`, `last_read`, …) — not `Date` objects and not ISO strings. They are typed @@ -1317,9 +1508,9 @@ in `stream-chat-js` (branch `release-v10`; re-pin to the release tag at v10 GA). What the compiler still does **not** catch: - **Date libraries.** A date library reads a bare number as **milliseconds**, so - `dayjs(ns).format()` returns the literal string `Invalid Date`. In this SDK that string is then - swallowed by the `withoutInvalidDate` guard in `src/i18n/utils.ts`, so a missed conversion shows - up as a **blank timestamp** rather than as an error or a wrong date. + `dayjs(ns).format()` returns the literal string `Invalid Date`. This SDK's date helpers turn that + string into `null`, so a missed conversion shows up as a **blank timestamp** rather than as an error + or a wrong date. - **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 @@ -1333,7 +1524,7 @@ in `stream-chat-js` (branch `release-v10`; re-pin to the release tag at v10 GA). What changed on **this SDK's** own surface: - **`findInMessagesByDate`** is removed (P.1); compare `created_at` numbers yourself, or jump with `channel.messagePaginator.jumpToMessage(id)`. - - **`getChannelUnreadState`** (internal) and the `ChannelUnreadState` type are removed (P.9). The + - **The `ChannelUnreadState` type is removed** (P.9). The unread snapshot's `lastReadAt` is a `TimestampNS | null`; the lists treat `null` as the epoch, meaning the whole channel is unread. Guard it with `!= null`, never with truthiness. - **`useIsChannelMuted`**'s `muteStatus` is core's `ChannelMuteStatus`: `{ createdAt: TimestampNS | @@ -1342,10 +1533,8 @@ in `stream-chat-js` (branch `release-v10`; re-pin to the release tag at v10 GA). (`mapStorableToTimestamp` / `mapStorableToRequiredTimestamp`), so hydrated state carries `TimestampNS` like live state. If you read the SQLite tables yourself, brand with `asTimestampNS`, never `msToNs` — the value is already in nanoseconds. - - **`getDateString` no longer rescales by magnitude.** The `normalizeTimestamp` helper in - `src/i18n/utils.ts` that used to convert an out-of-range number for you is gone, so a call site - that skips `convertTimestampToDate` now renders a **blank** timestamp (the `withoutInvalidDate` - guard turns `Invalid Date` into `null`) rather than being silently rescued. + - **`getDateString` does not rescale a nanosecond number.** A call site that skips + `convertTimestampToDate` renders a **blank** timestamp (the helper returns `null` for `Invalid Date`). - Presentational props are unchanged: `MessageFooter` / `MessageDeleted` still take `date?: string | Date`, and `getDateSeparatorValue` still returns a `Date`. Convert where core data enters the tree. @@ -1475,8 +1664,9 @@ const client = StreamChat.getInstance(apiKey, { ``` The same goes for `allowServerSideConnect`, `baseURL` and `browser`. `notifications` (a -`NotificationManager` instance) became `client.notificationManager`. The `stream-chat` guide -`v9-to-v10-migration-guide-client-construction.md` has the full mapping. +`NotificationManager` instance) became `client.notificationManager`. The full option mapping is +[`StreamChatOptions` keys -> `config.client`](https://github.com/GetStream/stream-chat-js/blob/release-v10/ai-docs/v10-breaking-changes.md#streamchat-options) +in the `stream-chat` reference. `useCreateChatClient({ options })` takes this tree too (`DeepPartial`, both exported from `stream-chat`), so move whatever you passed there under `client`, or drop the `config` @@ -1659,54 +1849,45 @@ Behaviour: # Part K — Unified channel state (`channel.state`) -> **Who this affects.** Only integrators who read the per-concern `channel.state.*Store` -> handles directly, subscribe to `channel.state.mutedUsersStore`, or mutate -> `channel.data.member_count` / `own_capabilities` in place. Everything the SDK's own -> components read is already migrated — this is additive for most apps. The sections above -> (§2, §4, §7) already point here. - -`channel.state` is now a **single `StateStore`** — subscribe to it directly -with `useStateStore(channel.state, selector)`, exactly like `thread.state`. Earlier v10 -pre-releases split it into per-concern handles (`channel.state.readStore`, `typingStore`, -`membersStore`, `watcherStore`, `ownCapabilitiesStore`); those are **removed**. The state is -**flat**: `read`, `typing`, `members`, `memberCount`, `watchers`, `watcherCount`, -`ownCapabilities` are all top-level keys of the one store. +> **Who this affects.** Integrators who read `channel.state.*` fields directly (`members`, `read`, +> `typing`, `watchers`, `watcher_count`, `mutedUsers`), mutate `channel.data.member_count` / +> `own_capabilities` in place, or read `channel.disconnected`. Everything the SDK's own components read +> is already migrated. §2, §4 and §7 point here. -## K.1 `channel.state.*Store` handles removed (breaking) +In v9 `channel.state` was a `ChannelState` class with mutable fields that you re-read after events. In +v10 it is a **single `StateStore`** — subscribe with +`useStateStore(channel.state, selector)`, exactly like `thread.state`. The state is **flat**: `read`, +`typing`, `members`, `memberCount`, `watchers`, `watcherCount`, `ownCapabilities` (and the slices in §K.3) +are all top-level keys of the one store. -**The recipe** — mechanical, one edit per call site: +## K.1 Reading `channel.state` reactively -> `useStateStore(channel.state.Store, selector)` → `useStateStore(channel.state, selector)` -> — delete the `.Store` segment and **keep the selector verbatim**. +The v9 field names still exist as **getters** — `channel.state.members` / `.read` / `.typing` / `.watchers` +/ `.member_count` / `.watcher_count` — and return the current value, but reading them does not subscribe. +A component that rendered from them and relied on a `channel.on(...)` listener (or a parent re-render) to +refresh must subscribe instead: -The selector does not change: `ChannelStateData` carries the same top-level keys the old -per-handle shapes did, so a `(s: ReadState) => O` (or `MembersState`, `WatcherState`, -`TypingUsersState`, `OwnCapabilitiesState`) stays contravariantly assignable to -`(s: ChannelStateData) => O`. +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { ReadState } from 'stream-chat'; -| Removed handle | Replacement | Slice key(s) it exposed | -|---|---|---| -| `channel.state.readStore` | `channel.state` | `read` | -| `channel.state.typingStore` | `channel.state` | `typing` | -| `channel.state.membersStore` | `channel.state` | `members`, `memberCount` | -| `channel.state.watcherStore` | `channel.state` | `watchers`, `watcherCount` | -| `channel.state.ownCapabilitiesStore` | `channel.state` | `ownCapabilities` | +const readSelector = (s: ReadState) => ({ read: s.read }); // module scope -```tsx -// Before (early v10 pre-release) -const { read } = useStateStore(channel.state.readStore, s => ({ read: s.read })); -// After (v10) -const { read } = useStateStore(channel.state, s => ({ read: s.read })); +// Before (v9): const read = channel.state.read; plus a 'message.read' listener to re-render +// After (v10): +const { channel } = useChannelContext(); +const { read } = useStateStore(channel.state, readSelector); ``` -The convenience getters `channel.state.members` / `.read` / `.typing` / `.watchers` / -`.member_count` / `.watcher_count` are **unchanged** (they proxy the one store). +Selector rules: define it at module scope, return an object (never a bare value), and return slice +references rather than freshly built arrays or objects. Per-concern selector types (`ReadState`, +`MembersState`, `ChannelWatchState`, `TypingUsersState`, `OwnCapabilitiesState`, all from `stream-chat`) +are assignable to `channel.state`'s full `ChannelStateData`. -## K.2 `channel.state.mutedUsersStore` removed → `client.mutedUsersStore` +## K.2 `channel.state.mutedUsers` removed → `client.mutedUsersStore` -The `MutedUsersState` type and the `channel.state.mutedUsersStore` handle are removed — muted -**users** are client-global, not per-channel. Read them from `client.mutedUsersStore` (via -`useMutedUsers()`, §14). Muted **channel** status is a new per-channel field — see K.3. +Muted **users** are client-global, not per-channel. Read them with `useMutedUsers()` (§14), or +`client.mutedUsersStore` outside React. Muted **channel** status is a per-channel slice — see K.3. ## K.3 New reactive slices on `channel.state` (additive) @@ -1716,9 +1897,9 @@ The `MutedUsersState` type and the `channel.state.mutedUsersStore` handle are re |---|---|---| | `data` | `Channel['data']` | The server channel data (name/image/frozen/hidden/blocked/config/…). Republished on `channel.updated` / `channel.hidden` / `channel.visible` and on query/watch. | | `membership` | `ChannelMemberResponse` | The current user's own membership (role, `pinned_at`, `archived_at`). | -| `muteStatus` | `{ muted: boolean; createdAt: number \| null; expiresAt: number \| null }` | Is **this channel** muted for the current user — mirrors `client.mutedChannels`. `channel.muteStatus()` (imperative) is unchanged. | +| `muteStatus` | `{ muted, createdAt, expiresAt }`: `muted` is a boolean; the two timestamps are `TimestampNS` or `null` | Is **this channel** muted for the current user — mirrors `client.mutedChannels`. `channel.muteStatus()` (imperative) is unchanged. | | `initialized` / `offlineMode` / `pendingDisposal` | `boolean` | Lifecycle flags, now store-backed. `channel.initialized` etc. are transparent getters over these. `pendingDisposal` replaces `disconnected` — see §K.8. | -| `watchStatus` | `ChannelWatchStatus` — `'watching'` \| `'wasWatching'` \| `'notWatching'` | Whether this client holds a server-side watch (i.e. whether channel events are flowing) and, when it doesn't, whether the watch should be restored. `Watching` once a `watch: true` query succeeds; `WasWatching` when the WS connection drops (the server keys watches by connection ID, so a reconnect issues a new id and every watch is gone — this records that a re-query is wanted); `NotWatching` when never watched, when the consumer called `stopWatching()`, or on teardown — a deliberate stop is never resurrected by a reconnect. Also makes `channel.watch()`'s silent downgrade — it drops to a non-watching query when there is no connection ID — observable. Read via `channel.watchStatus` or `useStateStore(channel.state, (s) => ({ watchStatus: s.watchStatus }))`; the `ChannelWatchStatus` const is exported from `stream-chat`. | +| `watchStatus` | `ChannelWatchStatus`: `'watching'`, `'wasWatching'` or `'notWatching'` | Whether this client holds a server-side watch (i.e. whether channel events are flowing) and, when it doesn't, whether the watch should be restored. `Watching` once a `watch: true` query succeeds; `WasWatching` when the WS connection drops (the server keys watches by connection ID, so a reconnect issues a new id and every watch is gone — this records that a re-query is wanted); `NotWatching` when never watched, when the consumer called `stopWatching()`, or on teardown — a deliberate stop is never resurrected by a reconnect. Also makes `channel.watch()`'s silent downgrade — it drops to a non-watching query when there is no connection ID — observable. Read via `channel.watchStatus` or `useStateStore(channel.state, (s) => ({ watchStatus: s.watchStatus }))`; the `ChannelWatchStatus` const is exported from `stream-chat`. | ```tsx // e.g. react to a channel rename @@ -1738,57 +1919,35 @@ can do the same (it refcounts, so a shared instance stays active until the last useEffect(() => channel.activate(), [channel]); // the returned release runs on unmount ``` -## K.5 In-place mutation of `channel.data.member_count` / `own_capabilities` no longer syncs (behavioral) +## K.5 In-place mutation of `channel.data` does not notify (behavioral) -These fields used to be backed by an `Object.defineProperty` accessor that pushed in-place writes -into the store. That accessor is gone. Reassign `channel.data` instead of mutating a field: +`channel.state` (and every SDK hook that reads it, such as the member count) updates only when the +store is written. Writing a field of `channel.data` in place changes the object but notifies nobody: +reassign `channel.data` instead. ```tsx -// Before — used to sync to channel.state +// Before (v9) — later reads of channel.data saw the new value channel.data.member_count = 12; -// After — reassign so the store (and channel.state.memberCount) updates +// After (v10) — reassign so the store (and channel.state.memberCount) updates channel.data = { ...channel.data, member_count: 12 }; ``` -`member_count` / `own_capabilities` are still read normally off `channel.data`; the values stay -sticky across data updates that omit them, and `own_capabilities` stays `undefined` until first -known. (Same applies to `channel.state.membership` — reassign it rather than mutating a nested field -if you need subscribers to update.) - -## K.8 `channel.disconnected` → `channel.pendingDisposal` (breaking) - -The flag is one-way and terminal: `channel.disconnect()` (v9's `_disconnect()`) disposes the paginators -and unregisters the subscriptions, and the instance is never reconnected — -`client.channelManager.ensure(...)` gives a fresh instance for the cid. `disconnected` read like something -that could come back, so the state key and the getter/setter are now `pendingDisposal`. - -```tsx -// Before -if (channel.disconnected) return; -// After -if (channel.pendingDisposal) return; -``` - -`channel.disconnected` is **removed outright** — no alias. v10 is a major, so the rename lands in one -step rather than carrying a deprecation through it. Rename every read and write; if you subscribe to -the slice, the **key** changed too: -`useStateStore(channel.state, (s) => ({ pendingDisposal: s.pendingDisposal }))`. +`member_count` / `own_capabilities` stay sticky across data updates that omit them, and +`own_capabilities` stays `undefined` until first known. The same applies to `channel.state.membership`: +reassign it rather than mutating a nested field. -## K.6 Built-in hooks now source from `channel.state` (behavioral) +## K.6 Built-in hooks read `channel.state` (behavioral) -These SDK hooks were rewired off ad-hoc `channel.on(...)` event subscriptions onto -`useStateStore(channel.state, …)` — same return shapes, finer-grained updates. No app change -needed unless you mock `channel.state` in tests (it must be a real `StateStore` now): `useChannelName`, `useChannelImage`, `useChannelMemberCount`, `useChannelMembershipState`, -`useIsChannelMuted`, `useChannelMuteActive`. `useMutedChannels` intentionally stays event-based -(it returns the client-global muted-channel **list**, not this channel's `muteStatus`). +`useIsChannelMuted` and `useChannelMuteActive` keep their return shapes. No action unless your tests mock +`channel.state` as a plain object: it must be a real `StateStore`. ## K.7 AI-indicator state → `channel.state.aiState` (additive slice; soft-breaking enum) The per-channel AI-indicator state (thinking / generating / …) is now a reactive slice on -`channel.state`, owned by the LLC and driven from the `ai_indicator.update` / `.clear` / `.stop` -events in `Channel._handleChannelEvent`. Previously the UI SDK kept it in `useAIState` via local -`useState` + `channel.on('ai_indicator.*')` listeners. +`channel.state`, owned by `stream-chat` and driven by the `ai_indicator.update` / `.clear` / `.stop` +events. In v9 the SDK kept it in `useAIState` with local state and `channel.on('ai_indicator.*')` +listeners. - **Subscribe:** `useStateStore(channel.state, s => ({ aiState: s.aiState }))`, seeded `AIStates.Idle`. If you ran your own `ai_indicator.*` listeners, drop them and read the slice. @@ -1811,6 +1970,24 @@ events in `Channel._handleChannelEvent`. Previously the UI SDK kept it in `useAI const { aiState } = useStateStore(channel.state, (s) => ({ aiState: s.aiState })); ``` +## K.8 `channel.disconnected` → `channel.pendingDisposal` (breaking) + +The flag is one-way and terminal: `channel.disconnect()` (v9's `_disconnect()`) disposes the paginators +and unregisters the subscriptions, and the instance is never reconnected — +`client.channelManager.ensure(...)` gives a fresh instance for the cid. `disconnected` read like something +that could come back, so the state key and the getter/setter are now `pendingDisposal`. + +```tsx +// Before +if (channel.disconnected) return; +// After +if (channel.pendingDisposal) return; +``` + +`channel.disconnected` is **removed outright** — no alias. Rename every read and write; if you subscribe to +the slice, the **key** changed too: +`useStateStore(channel.state, (s) => ({ pendingDisposal: s.pendingDisposal }))`. + --- # Part L — Connection recovery (`client.connectionRecovery`) @@ -1930,8 +2107,6 @@ therefore never exceed what it already had. The re-watch runs after routing and the row still relocates immediately off the event; it is idempotent, and concurrent watches for the same cid are deduped. -> Applies to `stream-chat-react` as well — this is a default `ChannelManager` handler, not RN code. - ## L.4 `` no longer runs any reconnect resync (behavioral) `` used to reload the channel on reconnect, and — when a thread was open — reload that @@ -2064,86 +2239,28 @@ reconnect refresh is not surfaced at all (§L.4) — the loaded window simply st If you want the old banner back, render your own from `lastQueryError` using the snippet above. -## L.8 Unsent messages now survive a reconnect rebuild (bug fix) - -`channel.reload()` and `thread.reload()` both preserve failed (locally unsent) messages across the -refresh. They already intended to, but the check for "did this one fall out?" read the paginator's -item *index* rather than its visible window — and on a disjoint rebuild (the loaded window shares no -id with the server's newest page, e.g. after scrolling up into old history) a message can sit in the -index while being absent from the rendered list. So the re-ingest was skipped on exactly the path -that needed it, and the user's unsent message disappeared from the list on reconnect. - -No API change and nothing to do — noted because if you worked around it by re-adding failed messages -yourself after a reconnect, that workaround is now redundant. - -For threads there was a second hole: the preservation relied on `Thread`'s `failedRepliesMap`, which -is only written by `upsertReplyLocally` — whose callers are the thread's own subscriptions and the -offline-DB path that looks threads up in `client.threads`. Neither covers a thread constructed directly -with `new Thread()` and never registered (`thread.activate()` and `client.threads.ensure()` register one -immediately; `client.threads.get(id)` resolves it). `thread.reload()` now reads the failed replies out of -the reply paginator instead, so it holds for managed and unmanaged threads alike. +## L.8 Unsent messages survive a reconnect refresh -## L.9 The reconnect refresh now finds messages you never had (bug fix) +No action. A reconnect refresh (`channel.reload()` / `thread.reload()`) keeps failed (unsent) messages in the list, also in a thread built with `new Thread()`. Remove any code that re-adds failed messages after a reconnect. -Two defects meant a reconnect could refresh a channel or thread and still miss content that arrived -while you were away. Both are fixed; no API change. +## L.9 The reconnect refresh finds messages that arrived while offline -- **The request was sized to what was already loaded.** `channel.reload()` / `thread.reload()` asked - for `loadedCount` items, so a window holding one message asked the server for one message — new - content was undiscoverable, and the single item that came back was disjoint from the loaded window, - so the fold rebuilt and dropped what was there. Now at least a page is requested. -- **A window nothing had ever anchored discarded the page entirely.** When the first query returns - empty and the only content arrives live — a thread or channel created in the current session — the - fetched page was thrown away rather than merged. It is now anchored instead. +No action. A reconnect refresh requests at least one full page, so content that arrived while you were away appears, also in a channel or thread created in the current session. -Most visible as: create a thread, send a reply, go offline, someone else replies, come back — the new -replies never appeared, and re-entering the thread did not help. +## L.10 A thread with no replies is not an error -## L.10 A brand-new thread no longer reports an error (bug fix) - -A parent with no replies has no server-side thread, so `getThread` answers "not found". That was being -treated as a failed refresh and reported as one, so simply opening a new thread raised an error state. -It is now recognised as the expected answer — `reload()` resolves quietly instead of rejecting. - -A genuine failure still rejects as before, including a thread that *did* have replies and comes back -not-found (its parent was deleted, possibly while you were offline). The two are told apart by -`replyCount`, which — unlike `deletedAt` — survives having missed the deletion event. - -Related: the SDK no longer issues a mark-read for a reply-less thread. There is nothing that could be -unread, and the call only ever 404'd. +No action. Opening a thread whose parent has no replies does not raise an error and sends no mark-read; `thread.reload()` resolves for it. A thread that had replies and comes back not found still rejects. --- # Part M — Optimistic edit, delete and unsent-message persistence -Three v9 capabilities did not survive the move of the message lifecycle out of `` and into -`stream-chat`'s `MessageOperations` engine. All are restored, all inside the LLC. No API was removed -and there is nothing to migrate — but if you worked around any of them, the workaround is now -redundant and will double-apply. - -## M.1 Editing a message is optimistic again, and persisted (bug fix) - -An edit shows immediately and is written to the offline DB before the request is made, so it survives -a cold start and the hydration `` performs on mount. v9 did both (`Channel.tsx` wrote the -optimistic copy through `db.updateMessage`); v10 kept neither. +The message lifecycle moved out of `` into `stream-chat`'s `channel.messageOperations`. Only +§M.2 changes what a user sees; §M.6 is additive; the rest need no action. -`message_text_updated_at` is stamped optimistically too, so the "edited" indicator appears at once — -except when the message being edited is itself `failed`, which never had a server-confirmed text -update to advertise. +## M.1 Editing a message is optimistic and persisted -**An edit is never rolled back.** Reverting would discard text the user typed. What changes is only -whether the failure is shown on the message: - -- **Offline support enabled and the request was queued for replay** — the message does **not** enter a - failed state. It is pending, not failed, and marking it failed lights up the retry affordance, which - re-*sends* the message rather than re-editing it. The request still rejects, so the composer still - reports the failed edit. -- **No offline DB, or a definitive rejection** (a server error that is not retryable, or a cancelled - request) — - the message keeps the edit and gains `status: 'failed'` plus `error`, as before. - -The predicate is the same one reactions already use: an offline DB is present **and** the error is -`isEphemeral`. +No action unless you added optimistic-edit code of your own (remove it). An edit shows at once, is written to the offline DB before the request and is never rolled back; with offline support a queued edit is not marked failed, otherwise a definitive failure sets `status: 'failed'`. ## M.2 Deleting a message is optimistic, and reverted if it definitively fails (behavioral) @@ -2170,44 +2287,17 @@ Now: The revert is guarded on object identity against the copy the optimistic step wrote, so a WS update landing mid-request is never clobbered by the rollback. -## M.3 Unsent messages survive closing the app (bug fix — data loss) - -Every send now writes the message to the offline DB **before** the request, pessimistically marked -`failed`, and overwrites it with `received` on success. A process death anywhere between composing and -the server's ack therefore leaves a message that hydrates as failed and retryable, instead of one that -silently disappears. This is v9's write-ahead (`Channel.tsx`), which v10 dropped when `sendMessage` -moved into `stream-chat`'s message operations (`channel.messageOperations.send`). +## M.3 Unsent messages survive closing the app -No schema change and no `dbVersion` bump: `status` already round-trips through the storable's -`extraData` blob. +No action. Every send is written to the offline DB before the request (as `failed`, then `received` on success), so a message survives the app being killed mid-send and stays retryable across restarts. -The retry payload does not need persisting alongside it. `MessageOperations.retry` reconstructs the -request from the message when its in-memory `failedSendCache` is cold, so a persisted failed message -stays retryable past that cache's 5-minute TTL and across restarts. +## M.4 An empty request response no longer overwrites local state -`channel.reload()` also consults the DB, not just the in-memory window, when deciding which failed -messages a reconnect has to put back — a message evicted from the paginator, or one on a cold boot that -the paginator never held, was previously unrecoverable. v9 got this from a second, lagging copy of the -message list (the SDK's own React state); with a single reactive source of truth the persisted row is -that buffer. +No action. A custom `sendMessageRequest` / `updateMessageRequest` / `deleteMessageRequest` that resolves without a `message` leaves local state alone. -## M.4 An empty request response no longer overwrites local state (bug fix) +## M.5 Editing or deleting a thread parent from inside a thread works -`formatMessage(undefined)` does not throw — it returns `{ status: 'received', created_at: , -updated_at: }`. Because that `updated_at` is *now*, it beat the "is the server copy newer?" check -and was ingested as an id-less message. v9 guarded every apply with `if (response?.message)`; the guard -is back. Reachable from a custom `sendMessageRequest` / `updateMessageRequest` / `deleteMessageRequest` -that resolves without a `message`. - -## M.5 Editing or deleting a thread parent from inside a thread works (bug fix) - -The reply paginator's local filter is `{ cid, parent_id }`, and a parent message has no `parent_id` — -so routing a parent edit or delete through the open thread's instance (which is what the SDKs do while -a thread is on screen) handed it to a collection that could not hold it, and the operation was silently -dropped. Optimistic writes now fall back to the client-global message store when the paginator does not -accept the message, reaching it wherever it is held and fanning out to every collection that holds it — -the same addressing `applyReactionLocally` uses. The SDK additionally routes by membership rather than -by "is a thread open", mirroring `sendReaction`. +No action. The change reaches every list that shows the parent message. ## M.6 Additive on `AbstractOfflineDB` @@ -2221,39 +2311,9 @@ inherits them and has nothing new to implement: `updateMessage` cannot serve this: it is an UPDATE and no-ops when the row does not exist, which is exactly the write-ahead case. -## M.7 The offline channel guard actually guards now (bug fix) - -`channelExists` ran `SELECT EXISTS(SELECT 1 FROM channels WHERE cid = ?)` and returned -`rows.length > 0`. `SELECT EXISTS` always returns exactly one row — holding `0` or `1` — so the row -count carried no information and the helper reported `true` for every cid, present or not. It has done -so since offline support v2 shipped; the LLC's tests mock `channelExists`, so the logic was covered and -the SQL never was. - -Its only consumer is `AbstractOfflineDB.queriesWithChannelGuard`, used by ten WS-event handlers -(`message.new`, `message.deleted`, `message.updated`, `message.read`, `member.*`, `reaction.*`, …). Its -gate is `forceUpdate || !(await channelExists({ cid }))`, which collapsed to `forceUpdate` — so the -branch that recreates a missing channel row from the event never ran, and those writes died on the -`messages.cid → channels.cid` foreign key and were swallowed by the detached query runner. - -Reachable whenever an event arrives for a channel the DB has no row for: after `resetDB()` (which the -sync-failure path and a >30-day-stale sync both trigger) until the next channel-list query; in the -window between `queryChannels` resolving and its persistence completing (and `channel.query`'s -persistence is detached, widening it); and after being added to a channel mid-session. The message list -self-heals on the next query — reactions, read state, member changes and older messages in those -windows did not. - -**Both channel guards are also lazy now.** `queriesWithChannelGuard` and -`upsertMessageWithChannelGuard` attempt the write first and only probe for the channel row when it -fails, repairing and retrying once; a failure with the channel row present is rethrown without a retry. -Every statement involved is an upsert, so the retry is idempotent. The eager probe is kept for -`execute: false` callers (collecting queries for someone else's batch, so there is no failure to catch) -and for `forceUpdate`. - -That inversion matters because the probe is a native round-trip, not a cheap read. Measured through -op-sqlite on device: the probe costs ~0.5ms against ~8-12ms for the message upsert it protects, and -roughly two thirds of that is the JS↔native crossing rather than the query. Removing it from the happy -path takes it off every message received as well as every message written — so message writes are -cheaper than they were before this release, not more expensive. +## M.7 The offline channel guard works + +No action. The offline DB stores WS events for channels it has no row for yet (it creates the row), and probes for the row only when a write fails. --- @@ -2289,7 +2349,7 @@ falls back to exactly the pre-v10 behaviour. ## N.2 `SqliteClient.dbVersion` 16 → 18 (not breaking; one-time cache loss) -v9 ships `dbVersion` 16; v10 ships 18 (17 was a v10 pre-release). Two schema changes are behind it: +v9 ships `dbVersion` 16; v10 ships 18. Two schema changes are behind it: - every date column that holds server data (channels, messages, members, reads, reactions, polls, drafts, reminders, locations, users) changed `TEXT` (ISO string) → `INTEGER` (unix nanoseconds, §17.5). @@ -2318,28 +2378,16 @@ The `SyncStatus` name family deliberately stays: `onSyncStatusChange`, `scheduleSyncStatusChangeCallback`, `invokeSyncStatusListeners`. "Sync status" is the concept, `isSynced` is its current value, and the listeners fire when that status changes. -## N.4 An empty `sort` now means the backend default (behavioral — affects every default-sorted list) +## N.4 A list without `sort` is ordered by recency (behavioral — affects every default-sorted list) -`ChannelPaginator` treated `sort` with `??`, which does not catch `[]`. An empty array is now read as -"unspecified" and resolves to `DEFAULT_BACKEND_SORT` — `[{ last_message_at: -1 }, { updated_at: -1 }]`. +A `ChannelPaginator` created without `sort` (or with `sort: []`) sorts by the backend default, +`[{ field: 'last_message_at', direction: -1 }, { field: 'updated_at', direction: -1 }]`, and re-sorts live +by it: a new message moves its channel up by recency. `paginator.sort` returns that array, not the `[]` +you passed, so a test asserting `toStrictEqual([])` on it fails. -A `ChannelPaginator` created without `sort` gets `[]`, so **this is the default integrator experience**, -offline support or not. Before, the comparator was built from zero sort fields: every comparison tied, the cid -tiebreaker decided, and the list held server order until the first live ingest re-sorted the whole -window alphabetically by cid. Now such a list is ordered by recency and reorders live. - -Visible delta for a list whose paginator has no `sort`: - -- a new message **moves its channel to the top**, where previously nothing moved -- the alphabetical-by-cid collapse on the first ingest is gone -- `paginator.sort` returns `DEFAULT_BACKEND_SORT` rather than the `[]` you passed, so an assertion of - `toStrictEqual([])` on it fails - -**→** nothing to do if recency is what you wanted. Pass an explicit `sort` to the `ChannelPaginator` to pin a -different order — including a `pinned_at`-first sort if you want pinned channels to stay on top, which -the backend default does not do. Note that "sort by nothing" is not expressible and never was: the -server applies its own default whenever a request carries no sort, so honouring `[]` client-side could -only ever produce an order the server would never return. +**→** nothing to do if recency is what you want. Pass an explicit `sort` to pin a different order — +including a `pinned_at`-first sort if pinned channels must stay on top, which the backend default does +not do. ## N.5 `InternalSearchControllerState` removed (breaking — `stream-chat`, documented-unstable) @@ -2353,50 +2401,6 @@ message in your own state; there is no replacement on `SearchController`. --- -## 19. Verify - -- Typecheck the customer app; removed symbols surface as "Property does not - exist" / "Cannot find name" errors — fix each per the section it maps to. -- Run the app and exercise: typing indicator; member/watcher counts; - message-list scroll pagination (both directions); mark-read on scroll-to-bottom - and banner dismiss; message send/edit/delete/react (online + offline); - thread open/reply/close (`` screens); jump-to-message highlight (quoted-reply - tap) and jump-to-first-unread; the unread separator, scroll-to-bottom button, - and unread notification; and any custom overrides of `Message`, the list - loading indicators, the thread footer, or the typing indicator. -- Exercise the **channel list**: initial load + skeleton; scroll pagination; - new-message reorder; add/remove-from-channel, hide/unhide, delete, truncate, - `channel.updated`; pull-to-refresh and reconnect (the list re-queries, no - blank); and confirm a pinned-first `sort` keeps pinned channels on top when - other channels receive messages. -- **Offline channel-list cache** (§N), with `enableOfflineSupport`: upgrade an existing - install and confirm the first launch reinitializes cleanly (§N.2 — expect one cold - load, and note any queued unsent messages are lost with it). With a - `predefined_filter` list, load it once online, kill the app, then cold-start: the - cached page must come back matching and ordered by the *server's* rule, not by your - local filters — a pinned channel stays on top even while a channel with a newer - message is replayed underneath it. With no `sort` prop, confirm a new message now - moves its channel to the top (§N.4). -- **Unified `channel.state`** (§K): a `channel.updated` rename/avatar reflects live - (header + preview); muting/unmuting a channel updates its muted indicator; - pin/archive updates membership-driven UI; and — with `` mounted — - focusing a channel with unreads marks it read and reconnect does not blank/re-seed - the open message list. -- **Connection recovery** (§L), on a real device, with offline support both on and - off: toggle airplane mode with the list open (it re-queries, never blanks) and - with a channel open (the window comes back byte-identical, scrolled into old - history included); background and foreground the app (same, and this is the path - v9 never recovered); open a channel while offline, then reconnect (its messages - load); send while offline, then reconnect (the queued message goes out); leave a - channel that was open, push it off page 1 of the list, reconnect, then message it - from another user (its preview updates — that is §L.3); open a thread and - reconnect (its replies refresh — §L.4); and cold-boot with a populated offline DB - (exactly one recovery, no double load). Also scroll up into old history, send a - message that fails, then reconnect — the unsent message must still be in the list - (§L.8). - ---- - # Part O — Message-list pruning (`maximumMessageLimit` → `maxLoadedItems`) ## O.1 `` removed; the cap is paginator configuration @@ -2429,12 +2433,10 @@ Three things the prop could not give you, which the config route does: **Affects:** anyone passing `maximumMessageLimit`. The prop is **removed, not deprecated** — TypeScript flags it, and there is no silent-fallback case to worry about. -## O.2 Pruning actually prunes again (behavioural) +## O.2 What a prune does (behavioural) In v9 the cap was enforced by `channel.state.pruneOldest()`, which went away with `channel.state.messages`. -For part of the v10 pre-release line `maximumMessageLimit` was therefore **inert** — it altered some -scroll behaviour but never bounded the window. If you set it during that period and saw no effect, that is -why. `maxLoadedItems` enforces it. +In v10 the paginator's `maxLoadedItems` enforces it. What a prune does now, which is worth knowing if you build on the paginator: @@ -2642,25 +2644,7 @@ To change how a page is fetched, configure the paginator instead: ## P.10 Message list behaviour fixes -These change what the user sees: - -- **Thread lists use the thread's paginator for everything.** They used to drive the channel's. So: - - closing a thread no longer leaves the channel counting new messages as unread while the user is at - its bottom (the "1 new messages" separator that stuck after returning from a thread); - - the scroll-to-bottom button in a thread jumps the thread to its latest replies, where it used to - move the hidden channel list; - - in `MessageFlashList` threads, a channel that had jumped away no longer turns autoscroll off; - - the sticky date header hides at the top of the thread; - - a spinner shows while newer replies load, and the empty-state check follows the thread; - - rows in a thread read the thread's unread separator and last-bubble state, and the - scroll-to-bottom button in a thread no longer marks the channel read. -- **A failed page no longer stops pagination.** The lists remembered every list length they had - requested at, so after a failed request that edge stayed dead until a new message arrived. -- **Removing a message no longer scrolls `MessageList` to the bottom.** Cancelling a giphy or a hard - delete used to jump a user who had scrolled up back to the newest message. (`MessageFlashList` never - scrolled; it only blocked paging for a moment.) -- The scroll-to-bottom accessibility action's unread count updates as reads arrive. - +No action. Thread lists page, mark read and show unread state from the thread's own paginator (closing a thread no longer leaves a stuck "new messages" separator in the channel); a failed page no longer stops pagination; removing a message no longer scrolls `MessageList` to the bottom. ## P.11 `ChatContext` holds only `client`, `getAppSettings` and `isMessageAIGenerated` (breaking) @@ -2697,7 +2681,7 @@ exactly those three. | Removed | Use instead | | --- | --- | | `useClientMutedUsers(client)` | `useMutedUsers()`. Same store, same result. | -| `useAppSettings(client, isOnline, enableOfflineSupport, dbReady)` | `useChatContext().getAppSettings()` | +| `useAppSettings(client, isOnline, enableOfflineSupport, initialisedDatabase)` | `useChatContext().getAppSettings()` | ## P.13 `MessagePropsWithContext.chatContext` removed (breaking, type-level) @@ -2715,10 +2699,7 @@ A message row still updates when its author is muted: the row reads the mutes it ## P.14 `` follows the connected user -`` used to read `client.userID` during render, so a `` mounted before `connectUser()` -resolved only noticed the user, and only set up the offline database for them, if something -unrelated re-rendered it. It now re-reads the user whenever the client's connection state changes, -and the offline database's state from the instance it attached. No API change. +No action. A `` mounted before `connectUser()` resolves picks the user (and their offline database) up when the connection changes. ## P.15 `` keeps rendering a deleted channel (breaking, behavioural) @@ -2736,8 +2717,9 @@ if (pendingDisposal) return null; // or your own "this channel is gone" view return …; ``` -The SampleApp's `useLeaveGoneChannel` (`examples/SampleApp/src/hooks/useLeaveGoneChannel.ts`) does this -on the channel and thread screens, returning to the channel list. +The SampleApp's +[`useLeaveGoneChannel`](https://github.com/GetStream/stream-chat-react-native/blob/V10/examples/SampleApp/src/hooks/useLeaveGoneChannel.ts) +does this on the channel and thread screens, returning to the channel list. Message rows no longer check `channel.pendingDisposal` each either, so a gone channel's messages stay visible. A disposed instance is never revived. To open the same conversation again, get a new instance @@ -2745,16 +2727,7 @@ with `client.channel(type, id)`. ## P.16 Channel behaviour fixes -- **The contexts follow the channel instance, not its id.** A disposed channel comes back as a new - instance under the same cid. `ChannelContext` kept handing out the old one, whose paginators were - already disposed. `useCreateChannelContext` now rebuilds when `channel` changes. -- **`useChannelContext().disabled` updates when the channel is frozen or unfrozen.** It used to update - only because an unrelated write happened to re-render ``. -- **`` no longer re-renders on channel updates that leave its own capabilities unchanged.** -- **`overrideOwnCapabilities` applies when a different capability is overridden.** `{ sendMessage: false }` - followed by `{ uploadFile: false }` used to keep the first override. -- **Going to the background sends `typing.stop` only if the user was typing.** It used to send one for - every mounted `` each time. +No action unless you key on `channel.cid` where you mean the instance: `ChannelContext` rebuilds when the `channel` instance changes (a disposed channel comes back as a new instance under the same cid). `disabled` follows freeze state, changed `overrideOwnCapabilities` apply, and backgrounding sends `typing.stop` only while typing. ## P.17 The composer submits through `stream-chat` (breaking) @@ -2803,21 +2776,11 @@ text, add a composition middleware through `client.config.setSetupFunction('mess ## P.18 Editing a message -- Editing a message again after cancelling an edit starts from the message, not from the text the - cancelled edit left behind. -- Saving or cancelling an edit leaves edit mode by itself; the composer clearing is the signal. -- `useMessageComposer()` returns the same composer as before, read from context. Calling it no longer - subscribes the composer once per component. +No action. Re-editing after a cancelled edit starts from the message; saving or cancelling leaves edit mode by itself; `useMessageComposer()` returns the composer from context. ## P.19 Composer behaviour fixes -- `SendMessageDisallowedIndicator` shows as soon as the channel's capabilities are known, including a - channel loaded from the offline database. It used to wait for the channel to be initialized, which - could arrive after the composer had already rendered. -- The commands button, the attach button and the commands sheet follow capabilities and server config - that arrive after the composer mounted. -- Attachment picker cells no longer re-render on every upload progress tick; a cell re-renders only - when its own selection changes. +No action. See §16.3 for `SendMessageDisallowedIndicator`. The commands button, the attach button and the commands sheet follow capabilities and server config that arrive after mount. ## P.20 `ChannelsContext` holds the list's configuration, not its state (breaking) @@ -2851,23 +2814,15 @@ subscribing, no longer picks up changes through other rows' updates. Subscribe i ## P.21 Channel list behaviour fixes -- **Changing `onSelect`, `getChannelActionItems`, `maxUnreadCount`, `additionalFlatListProps`, - `loadMoreThreshold`, `numberOfSkeletons` or `setFlatListRef` on a mounted `ChannelList` takes effect.** - They used to be kept until the list happened to change. `setFlatListRef` is no longer called with - `null` and then the list again on every list update. -- **A mounted `ChannelList` loads again after `client.disconnectUser()` and the next `connectUser()`.** - It used to stay on its loading skeleton. -- **A channel row shows a draft that has only attachments**, as thread rows already did. +No action. Changed `ChannelList` props (`onSelect`, `getChannelActionItems`, `maxUnreadCount`, `additionalFlatListProps`, `loadMoreThreshold`, `numberOfSkeletons`, `setFlatListRef`) apply while mounted; a mounted list loads again after a user switch, and loads its first page even when an event already put channels into its paginator. ## P.22 Thread list behaviour fix -A thread list row takes its latest reply, which sets its avatar and time, from -`thread.messagePaginator.aggregateState`. It used to take the last reply of the thread's loaded page, -which is an older reply once the thread has been scrolled back. +No action. A thread list row takes its latest reply from `thread.messagePaginator.aggregateState`. --- -# Part Q — Bug fixes after the state cleanup +# Part Q — Plural count keys, behaviour fixes, `isMessageAIGenerated` ## Q.1 Count strings are plural keys (breaking for translations) @@ -2898,26 +2853,7 @@ updated. ## Q.2 Behaviour fixes -- **Reactions sheet** (`MessageUserReactions` / `useFetchReactions`): - - reactions added to other messages no longer appear in an open sheet; - - the sheet no longer refetches, and briefly empties, on every reaction to its message; - - offline, the sheet shows the reactions stored in the offline database again, through - `client.queryReactionsAndHydrate` (as in v9); - - the counts come from `reaction_groups`, so a reaction added offline or not yet confirmed is - counted. -- **Reminder header:** setting or clearing the time of an existing reminder switches the message - header between "Saved for later" and the reminder right away. -- **`useHandleLiveLocationEvents`:** another message's ended share no longer marks the watched - location as stopped. -- **`usePollAnswersPagination` / `usePollOptionVotesPagination`:** votes and answers that arrive - together are all kept. Only the last one used to survive. -- **Thread list:** a failed refresh from the "new threads" banner shows "Couldn't load new threads. Tap - to retry", and tapping it retries. The tile could not appear before, and was not pressable. -- **Channel list swipe sheet:** a direct chat shows "Online" when the other member is online. It used to - read the watcher count, which includes the current user, so the label was inverted. -- **Sticky date header:** it shows again after scrolling back down from the oldest message. -- **`MessageFlashList`:** a channel whose messages fit on screen no longer shows the date twice (the - sticky header over the inline separator). +No action. Fixes in the reactions sheet, reminder header, `useHandleLiveLocationEvents`, poll vote/answer pagination, the thread list's "new threads" retry, the channel-list swipe sheet's online label, the sticky date header and `MessageFlashList`'s duplicate date. ## Q.3 `isMessageAIGenerated` lives on `` only (breaking) @@ -2945,11 +2881,11 @@ the message contexts. --- -# Part R — Gaps found in the v10 breaking-change audit +# Part R — Platform, connection status, receipts and other changes -A v9 → v10 audit of the public surface, the PRs and the sample apps (the v9 SampleApp and ExpoMessaging compiled -against v10) found the changes below that the parts above did not cover. Each one is checked against the v10 source. -Detection greps are in §1; the quick-reference table has rows for the common ones. +Platform floors, connection status, delivery receipts, more removed props and context fields, and the +`stream-chat` calls RN apps make most. Detection greps are in §1; the quick-reference table has rows for the +common ones. ## R.1 Platform floors and packaging (breaking) @@ -3002,9 +2938,10 @@ Related changes: `connection.recovered` (§L.2). - **The banner delay is configuration**, not a fixed 5 s inside the client: `client.config.set({ client: { wsConnection: { offlineNotificationDisplayDelayMs: 3000 } } })`. -- **`` registers `netInfoStatusReporter` on every mount** through - `client.config.set({ client: { networkConnection: { statusReporter } } })`, with no teardown. A custom - `statusReporter` you registered is replaced; register it again after `` mounts. +- **`` registers `netInfoStatusReporter`** through + `client.config.set({ client: { networkConnection: { statusReporter } } })` when no reporter is registered + yet, with no teardown. A `statusReporter` you registered (at construction or with `client.config.set`) wins. + `useCreateChatClient` passes `netInfoStatusReporter` at construction unless its `options` name one. - **`OutputButtons` / `MessageComposer` `isOnline` prop** is `boolean` (settled socket health), not `boolean | null` from `ChatContext`. - **`ChannelListHeaderNetworkDownIndicator`** shows when the device is offline or the settled socket health is false; @@ -3102,6 +3039,8 @@ Retyped: - **Deleting a failed *edit* deletes the message on the server.** v9 removed every `failed` message locally only; v10 does that only for a failed *send* (a failed message with no `message_text_updated_at`). - **Initial page size is 25 unless you register one** through `client.config`: §16.1. +- **Reopening a channel left on an older page shows the newest messages** (also for ``), unless a jump + already landed or is loading, or `initializeOnMount={false}`: §8. Message rows and previews: @@ -3296,7 +3235,8 @@ Not covered by §19 or `i18n-v10-migration.md`: streami18n.registerTranslation('en', appFormatterExpressions); ``` - `examples/SampleApp/src/i18n/appKeys.ts` does this. `duration/Message reminder` is still bundled, as + The SampleApp's [`appKeys.ts`](https://github.com/GetStream/stream-chat-react-native/blob/V10/examples/SampleApp/src/i18n/appKeys.ts) + does this. `duration/Message reminder` is still bundled, as `duration.messageReminder`. - **`t()` only accepts catalog keys.** `TranslationContextValue.t` is `StreamTFunction`, so an app key needs `asDynamicKey('…')` (`timestamp.*` / `duration.*` keys are accepted as they are). See the i18n guide's "Custom keys @@ -3325,15 +3265,15 @@ Still there but still deep-only, with changed signatures: `filterTypingUsers` ( --- -# Part I — i18n +# Part S — i18n ## 19. English-only bundle, dotted keys, shared runtime -**The full guide is `ai-docs/i18n-v10-migration.md`.** Read it for the migration; this section exists so an -agent grepping this file finds i18n at all, and knows the three shapes of change: +**The full guide is `i18n-v10-migration.md`** (see Companion documents). Fetch it when the app registers +translations, calls `t()` with its own keys, or constructs `Streami18n`. The three shapes of change: 1. **Keys are stable dotted identifiers**, not the English copy, with the English inline as i18next's - `defaultValue`. The reviewed old→new table is `ai-docs/i18n-v10-key-map.json` (389 rows). Renaming is not + `defaultValue`. The reviewed old→new table is `i18n-v10-key-map.json` (see Companion documents). Renaming is not optional and **it fails silently** — an old key simply never matches, so the override stops applying and English renders with no error. Type your dictionary as `TranslationDictionary` to turn that into a compile error. @@ -3353,3 +3293,94 @@ Two dependency rules that produce silent breakage rather than errors: extends an instance the SDK never formats with — dates stay English, nothing throws. - **Do not declare `i18next`.** It arrives through `@stream-io/i18n`, which registers dictionaries on its own `createInstance()` rather than a global — so unlike dayjs, a second copy would be harmless. Skip it anyway. + +--- + +## Verification + +**1. Static checks.** Run the app's own scripts (read `scripts` in `package.json`; typical names are +`typecheck`, `tsc`, `lint`, `test`). For a TypeScript app without a typecheck script, run `npx tsc --noEmit`. +Compare with the Phase 0 baseline. Removed symbols surface as "Property does not exist" / "Cannot find name" / +"has no exported member" errors: fix each per the section its name maps to (Ctrl-F the name in the +Quick reference). For a `stream-chat` error this guide does not cover, see Rule 8. + +**2. Leftover v9 identifiers.** These commands list every v9 name that v10 removed or renamed. Outside comments +and changelogs they must return **zero** hits; this is the only check that catches them in plain JavaScript. +A hit inside a string or an unrelated identifier of the app's own is fine once you have confirmed it. +Names that still exist in v10 with a different meaning or on a different object (`banUser`, `muteUser`, +`doFileUploadRequest`, `readBy`) are not listed here; §R.8, §13.1 and §R.3 detect them. + +```bash +# stream-chat-react-native / -expo / -core names removed or renamed in v10 +rg -n -t js -t ts -g '!**/node_modules/**' '\b(useTypingContext|TypingProvider|TypingContextValue|useCreateTypingContext|usePaginatedMessageListContext|PaginatedMessageListProvider|PaginatedMessageListContextValue|useCreatePaginatedMessageListContext|useTargetedMessage|setTargetedMessage|channelUnreadStateStore|setChannelUnreadState|ChannelUnreadState|loadChannelAroundMessage|loadChannelAtFirstUnreadMessage|reloadChannel|highlightedMessageId|loadMoreThread|loadMoreRecentThread|reloadThread|threadMessages|setThreadMessages|threadHasMore|threadLoadingMore|threadLoadingMoreRecent|threadInstance|closeThreadOnDismount|ThreadMessageComposer|ThreadType|additionalMessageListProps|additionalMessageFlashListProps|additionalMessageComposerProps|shouldUseFlashList|shouldSyncChannel|MessageActionsHookProps|uploadAbortControllerRef|deliveredToCount|connectionRecovering)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' '\b(doMarkReadRequest|doSendMessageRequest|doUpdateMessageRequest|stateUpdateThrottleInterval|newMessageStateUpdateThrottleInterval|preSendMessageRequest|patchMessageTextCommand|maximumMessageLimit|allowSendBeforeAttachmentsUpload|useChannelUpdated|queryChannelsOverride|lockChannelOrder|onAddedToChannel|onRemovedFromChannel|onChannelDeleted|onChannelHidden|onChannelVisible|onChannelUpdated|onChannelTruncated|onChannelMemberUpdated|onNewMessage|onNewMessageNotification|useSelectedChannelState|reduceMessagesToString|findInMessagesById|findInMessagesByDate|getLastReceivedMessage|DebugContextProvider|useDebugContext|DebugContextValue|DebugDataType|useIsOnline|isChannelActive|scrollToFirstUnreadThreshold|deleteReactionsForMessage|useClientMutedUsers|useAppSettings)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' '\b(geti18Instance|getAvailableLanguages|getTranslations|addOnTFunctionOverrideListener|addOnLanguageChangeListener|getTranslators|relativeCompactDateFormatter|getDateStringForA11y|(en|es|fr|he|hi|it|ja|ko|nl|ptBR|ru|tr)Translations)\b' . +rg -n -t js -t ts -t json -g '!**/node_modules/**' "'a11y/|\"a11y/" . +rg -n -t js -t ts -g '!**/node_modules/**' '\bthreadList\s*[=:,}]|\bhardDelete\s*:|\buse(MutedUsers|MutedChannels)\(\s*\w|\buse(MessageActions|MessageActionHandlers)\(\s*\{|\buseMessageList\(\{\s*threadList' . + +# stream-chat names removed or renamed in v10 (the full list is the stream-chat type rename table) +rg -n -t js -t ts -g '!**/node_modules/**' '\b(activeChannels|hydrateActiveChannels|threadsById|recoverState|recoverStateOnReconnect|createChannelManager|setLocalDevice|addDevice|getDevices|removeDevice|shadowBan|ErrorFromResponse|moderation_details|FormatMessageResponse|EventTypes|AppSettingsAPIResponse|MessageResponseBase|TranslationLanguages|ReadResponse|DeliveredMessageConfirmation|createAbortControllerForNextRequest|sendFile|sendImage|StreamChatOptions|setMessageComposerSetupFunction|syncStatus|InternalSearchControllerState)\b' . +rg -n -t js -t ts -g '!**/node_modules/**' "connection\.changed|\.disconnected\b|\.lastMessage\(\)|\.getConfig\(\)|\bclient\.configs\b|channel\.state\.(messages|latestMessages|threads|pinnedMessages|isUpToDate|findMessage|loadMessageIntoState|mutedUsers)\b" . +``` + +**3. Runtime checks.** Builds cannot prove these; run the ones that match what the app uses, on a device or +simulator, and list the rest in the report as still to do. + +- **Messages:** send, edit, delete (soft and hard) and react, online and offline; a failed send shows a retry; + typing indicator; member and watcher counts; the unread separator, scroll-to-bottom button and unread banner; + mark-read on reaching the bottom and on dismissing the banner; any custom `Message`, list loading indicator, + thread footer or typing indicator override. +- **Message list:** pagination in both directions; jump to a quoted reply (highlight), jump to the first unread; + a deep link to a message lands on it; reopening a channel after a jump shows the newest messages. +- **Threads:** open from a message, reply, close; open from the thread list; open from a push notification + or deep link (`client.threads.ensure`). +- **Channel list:** first load and skeleton; pagination; new-message reordering; added to / removed from a + channel, hide / unhide, delete, truncate, rename; pull-to-refresh; a pinned-first `sort` keeps pinned + channels on top; log out and in as another user (the list reloads). +- **Connection recovery** (with offline support on and off): airplane mode with the list open and with a + channel open (nothing blanks, content refreshes); background and foreground the app; send while offline, + then reconnect (the message goes out); open a thread and reconnect (replies refresh). +- **Offline support:** the first launch after the upgrade reinitializes the database once (§N.2); with a + `predefined_filter` list, a cold start restores the cached page in the server's order. +- **i18n:** every registered language renders translated, including dates and plural strings. + +## Index of breaking changes + +Maps each section of the RN reference, +[`v10-breaking-changes.md`](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md), +to the sections of this guide that cover it. `i18n guide` is `i18n-v10-migration.md` (Companion documents). + +| Reference section | Covered in this guide | +| --- | --- | +| [1. Dependencies and platform](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#1-dependencies-and-platform) | §17, §R.1, §19, §N.2, §R.9, i18n guide | +| [2. Removed exports](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#2-removed-exports) | §2, §3, P.1, §15, §6, §18.2, P.2, §R.2, P.12, P.17, §17.6, §10.1, §7, P.9, §15.1, §19, i18n guide | +| [3 › ``](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#chat) | §L.1, §17.7, §R.2 | +| [3 › ``](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#channel) | §13, §8, §13.1, P.17, §O.1, §O.3, Q.3, P.5, §10.1, §10.2, §17.5, §R.10 | +| [3 › ``](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#channellist) | §18.6, §18.1, §17.5, §N.4, §R.6, §18 | +| [3 › ``, ``](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#channellistview-channelpreview) | §R.4, P.20 | +| [3 › `` and message components](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#message-and-message-components) | §R.3, §R.4, Q.3, P.13, §10.1, §9, §15.1, §10.2, §4, §17.1 | +| [3 › `` / ``](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#messagelist--messageflashlist) | §12, P.5, P.7, §O.1, §10.2, §10.1, §12.1, §O.3, §7, §2.1, §R.4, §R.5 | +| [3 › `` and thread components](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#thread-and-thread-components) | §10.1, §10.2 | +| [3 › Composer](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#composer) | §R.2, §R.10, §R.4 | +| [3 › Indicators, polls, misc](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#indicators-polls-misc) | §R.4, §17.5, §19 | +| [4 › Removed contexts](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#removed-contexts) | §2, §3, P.1 | +| [4 › `ChannelContext` (`useChannelContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#channelcontext-usechannelcontext) | §10.2, §4, §5, §6, §7, §8, §L.7, P.4, §R.4 | +| [4 › `ChatContext` (`useChatContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#chatcontext-usechatcontext) | §R.2, P.11 | +| [4 › `ChannelsContext` (`useChannelsContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#channelscontext-usechannelscontext) | P.20, §18.3 | +| [4 › `MessagesContext` (`useMessagesContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#messagescontext-usemessagescontext) | §9, §17.2, §6, P.4, Q.3, §10.2 | +| [4 › `MessageContext` (`useMessageContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#messagecontext-usemessagecontext) | §4, §R.3, Q.3, §10.2, §10.1, §15.1 | +| [4 › `ThreadContext` (`useThreadContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#threadcontext-usethreadcontext) | §10, §10.2, §10.1, §R.4 | +| [4 › `ThreadsContext`](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#threadscontext) | §10.1 | +| [4 › `MessageInputContext` (`useMessageInputContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#messageinputcontext-usemessageinputcontext) | §11, P.17, §O.3, §R.4 | +| [4 › `TranslationContext` (`useTranslationContext()`)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#translationcontext-usetranslationcontext) | §R.10, i18n guide | +| [5. Hooks — changed signatures](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#5-hooks--changed-signatures) | §14, P.3, §R.4, §17.5, §17.7, §R.8, P.8, §15.1, §9, §10.1, §15, §K.6 | +| [6. Utilities and functions — changed](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#6-utilities-and-functions--changed) | §17.6, §R.10, §17.4 | +| [7. Types](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#7-types) | §K.7, §R.4, §17.1, §17.6, §17.5, §19 | +| [8. Classes and state stores](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#8-classes-and-state-stores) | §19, §R.10, §R.9, §N.1, P.6, i18n guide | +| [9. Component slots, icons and theme](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#9-component-slots-icons-and-theme) | §10.2, §15.1, §L.7, §R.2 | +| [10. i18n and translations](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#10-i18n-and-translations) | §19, Q.1, §R.10, §17.5, i18n guide | +| [11. Offline storage](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#11-offline-storage) | §N.2, §17.5, §N.1, §N.3, §R.9, §M.7 | +| [12. `stream-chat` v10 changes an RN integrator hits](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#12-stream-chat-v10-changes-an-rn-integrator-hits) | Part K, §R.7, §K.1, §K.2, §K.8, P.15, §K.4, §L.4, Part L, §R.2, §L.1, §L.6, §L.2, §13.1, §17.7, §17.1, §17.4, §17.5, §R.8, §17.2, §17.3, §5, §9 | +| [13. Behavioural changes (no symbol changed)](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#13-behavioural-changes-no-symbol-changed) | §9.1, §16, Part M, §R.5, §16.2, §16.1, §15.1, §L.4, §8, §10.3, §L.10, §18.5, §N.4, §R.6, §18.6, P.21, §O.3, §13.1 | +| [14. Deep imports](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#14-deep-imports) | §R.11 | +| [17. No-action fixes: detail moved from the migration guide](https://github.com/GetStream/stream-chat-react-native/blob/V10/ai-docs/v10-breaking-changes.md#17-no-action-fixes-detail-moved-from-the-migration-guide) | the "No action" notes in L.8–L.10, M.1, M.3–M.5, M.7, P.10, P.14, P.16, P.18, P.19, P.21, P.22, Q.2 | diff --git a/ai-docs/ai-migration.md b/ai-docs/ai-migration.md index fe270625da..af86801856 100644 --- a/ai-docs/ai-migration.md +++ b/ai-docs/ai-migration.md @@ -1,3 +1,6 @@ +> **Upgrading from v9 to v10?** This file covers v8 → v9 only. Use +> https://raw.githubusercontent.com/GetStream/stream-chat-react-native/V10/ai-docs/ai-migration-v9-to-v10.md instead. + # stream-chat-react-native v8 → v9 — Agent Migration Guide > Machine-oriented migration reference for AI coding agents. The prose-heavy diff --git a/ai-docs/channel-state-ui-migration.md b/ai-docs/channel-state-ui-migration.md index b0a71d827f..3511fcc4d3 100644 --- a/ai-docs/channel-state-ui-migration.md +++ b/ai-docs/channel-state-ui-migration.md @@ -22,8 +22,8 @@ Beyond the flattening, three things changed name or shape rather than location: - **`channel.disconnected` → `channel.pendingDisposal`**, and the old name is **removed outright — there is no deprecated alias.** (V10 is a major, so the LLC is not carrying aliases through it.) The flag is one-way - and terminal: the paginators are disposed, the subscriptions unregistered, and the client drops the channel - from `activeChannels`, so it never revives. `getClient()` throws on such an instance. RN had five call + and terminal: the paginators are disposed, the subscriptions unregistered, and `client.channelManager` drops + the channel, so it never revives. `getClient()` throws on such an instance. RN had five call sites, all plain property reads. - **`WatcherState` → `ChannelWatchState`**, also with no alias. The slice no longer describes only *other* people watching (`watchers`, `watcherCount`) — it now also answers whether *this client* is watching, so @@ -49,9 +49,9 @@ ownCapabilities consumers. `client.on(...)` subscription + imperative `channel.muteStatus()` call. `useMutedChannels` stayed event-based on purpose (it's the client-global muted-channel *list*, not this channel's status). -**3. Channel lifecycle wired.** `` calls `channel.activate()` on mount and -`channel.deactivate()` on unmount (refcounted). This is what gates the reconnect no-destructive-reseed -of an open channel's message list. +**3. Channel lifecycle wired.** `` calls `channel.activate()` on mount and the release +function it returns on unmount (refcounted; there is no `deactivate()`). This is what gates the +reconnect no-destructive-reseed of an open channel's message list. **4. AI indicator.** `useAIState` became a thin `useStateStore(channel.state, (s) => ({ aiState: s.aiState }))` reader — the public `{ aiState }` shape is unchanged, and it now honors `ai_indicator.stop`. The diff --git a/ai-docs/i18n-v10-migration.md b/ai-docs/i18n-v10-migration.md index 03c03691d9..b9f6879e92 100644 --- a/ai-docs/i18n-v10-migration.md +++ b/ai-docs/i18n-v10-migration.md @@ -184,9 +184,9 @@ where a plural is the bare ``; a dictionary needs the `_one` / `_other` ent > number, and the `t('timestamp.X', { timestamp })` path is **not type-checked** — i18next's > interpolation bag is untyped, so a raw wire number compiles. This SDK's `getDateString` wrapper > suppresses the resulting `Invalid Date`, so a missed conversion renders a **blank** timestamp rather -> than an error. The magnitude-based rescue that used to cover this (`normalizeTimestamp` in -> `src/i18n/utils.ts`) is gone. If a timestamp is blank, check the conversion first; see -> [Part I](./ai-migration-v9-to-v10.md) on server-sent dates. +> than an error. Nothing rescales an out-of-range number for you. If a timestamp is blank, check the +> conversion first; see §17.5 (Part I) of [`ai-migration-v9-to-v10.md`](./ai-migration-v9-to-v10.md) on +> server-sent dates. Two steps, and the second is the one that gets missed. diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md index 32c210a417..216829399c 100644 --- a/ai-docs/v10-breaking-changes.md +++ b/ai-docs/v10-breaking-changes.md @@ -4,8 +4,9 @@ The single list of every breaking change between v9 and v10 of `stream-chat-reac `stream-chat-react-native` and `stream-chat-expo`. It is the source of truth for the v10 migration guide and the docs update. Each entry says what changed and how to migrate in one line, and links to the detailed write-up: -- **`§…`** → [`ai-migration-v9-to-v10.md`](./ai-migration-v9-to-v10.md), the agent migration ledger (Before/After code, - detection greps). `§R.x` sections were added by the audit that produced this file. +- **`§…`** → [`ai-migration-v9-to-v10.md`](./ai-migration-v9-to-v10.md), the agent migration guide (Before/After code, + detection greps, the breaks that compile). Its [index](./ai-migration-v9-to-v10.md#index-of-breaking-changes) maps + every section of this file to the guide sections that cover it. - **i18n** → [`i18n-v10-migration.md`](./i18n-v10-migration.md) and [`i18n-v10-key-map.json`](./i18n-v10-key-map.json). - **LLC** → `stream-chat` v10's own list: `stream-chat-js` `ai-docs/v10-breaking-changes.md` on `release-v10`. - **#NNNN** → the `GetStream/stream-chat-react-native` pull request that made the change. @@ -19,6 +20,11 @@ Docs impact (which getstream.io pages change) is in [`v10-docs-impact.md`](./v10 `251a76dfe`: message actions built on demand, a standalone ``, the `` lifecycle refactor and an app-owned ``). The trees were compared tip to tip; `V10` squash-merges `develop`, so ancestry diffs are not reliable. The three `develop` commits not yet on `V10` (#3862, #3863, #3864) change no public API. +- **Re-verified** against `V10` @ `74884ff55` (`stream-chat` `^10.0.0-rc.30`), 2026-11-03: the changes after the + last ledger update (#3910, #3916 and the `stream-chat` / `@stream-io/*` bumps) were reviewed against this list; the + additions are `useJumpToLatestOnOpen` and the reopen-at-latest behaviour (sections 13 and 15), plus the no-action fixes in + [section 17](#17-no-action-fixes-detail-moved-from-the-migration-guide). Names that existed only in v10 pre-releases + (`channel.state.*Store` handles, `WatcherState`, `MutedUsersState`) were dropped from the guide. - **Public API** = everything reachable from `package/src/index.ts`, the `ai-components` entry, the wrappers' `src/index.js`, `DEFAULT_COMPONENTS`, the theme, translations and `package.json` peer/dependency ranges. - **How it was collected:** @@ -37,7 +43,7 @@ Docs impact (which getstream.io pages change) is in [`v10-docs-impact.md`](./v10 ## 1. Dependencies and platform -- **`stream-chat` ^9 → ^10** (currently `^10.0.0-rc.23`). Every `stream-chat` change in [§12](#12-stream-chat-v10-changes-an-rn-integrator-hits) +- **`stream-chat` ^9 → ^10** (`^10.0.0-rc.30` at `74884ff55`; a regular dependency of core, not a peer). Every `stream-chat` change in [§12](#12-stream-chat-v10-changes-an-rn-integrator-hits) applies. Apps that depend on `stream-chat` directly must move to the same major. — §17 - **`react-native` peer `>=0.76` → `>=0.79`** (core, native and expo packages). `@stream-io/i18n` and `stream-chat` resolve through package `exports`, which needs Metro ≥0.82. — §R.1 · #3777 @@ -110,8 +116,8 @@ its slot type in `ComponentOverrides`. Every component and hook that accepts `t` `client.preventThreadCleanup = true` (recovery is `client.connectionRecovery`, on by default). — §L.1 · #3785 - `` writes `persistUserOnConnectionFailure: enableOfflineSupport` into `client.config` on mount, overwriting a value you registered. — §17.7 · #3855 -- `` registers the NetInfo `statusReporter` on every mount, replacing a custom one you set on the client. — - §R.2 · #3808 +- `` registers the NetInfo `statusReporter` when none is registered; a reporter the app registered (at + construction or with `client.config.set`) wins. `useCreateChatClient` passes it at construction. — §R.2 · #3808 ### `` @@ -149,8 +155,8 @@ Retyped props: - New required `paginator` prop: the app creates a `ChannelPaginator`, registers it with `client.channelManager.insertPaginator({ paginator })` and removes and disposes it on logout. The list never disposes it and does not query again when it remounts. — §18.6 · #3869 -- Removed `filters`, `sort`, `options`, `lockChannelOrder`, `queryChannelsOverride` (and the - `ChannelListQueryChannelsOverride` type) → set them on the `ChannelPaginator`: `filters` / `sort` / +- Removed `filters`, `sort`, `options`, `lockChannelOrder`, `queryChannelsOverride` (v9 typed it with `stream-chat`'s + `QueryChannelsRequestType`) → set them on the `ChannelPaginator`: `filters` / `sort` / `requestOptions` in its constructor, `paginatorOptions.lockItemOrder`, `paginatorOptions.doRequest`. To change the query, update the paginator's filters / sort and `reload()`, or pass a new paginator. — §18.1 · §18.6 · #3869 - What moved onto the paginator also changed shape since v9: `sort` is `SortParamRequest[]` @@ -350,7 +356,7 @@ message input). - `useCreateMessagesContext`, `useCreateThreadContext`, `useCreateMessageInputContext`, `useCreateMessageContext`: inputs narrowed to the new context shapes. — §15 · §R.4 - `useChannelOwnCapabilities(channel)`: one overload `(channel?)`. — K.6 -- `usePollState` / `usePollStateSelector` / `usePollAnswers` / `usePollVotes`: poll types renamed (see §3); +- `usePollState` / `usePollStateStore` / `usePollAnswersPagination` / `usePollOptionVotesPagination`: poll types renamed (see section 3); `addComment()` → `Promise`, `endVote()` → `Promise`. — §17.5 ## 6. Utilities and functions — changed @@ -471,8 +477,9 @@ apps' own code. `isUpToDate`, `findMessage`, `addMessageSorted`, `loadMessageIntoState`, …) → `channel.messagePaginator`, `channel.pinnedMessagesPaginator`, `client.messageStore`, `client.threads.get(id)`. - `channel.lastMessage()` → `channel.messagePaginator.latestMessage ?? undefined`. -- `channel.state.*Store` handles (pre-release) → `channel.state`; `mutedUsersStore` → `client.mutedUsersStore`. — §K.1 · - §K.2 +- `channel.state` was a `ChannelState` class of mutable fields; its v9 field names are now non-reactive getters + (`members`, `read`, `typing`, `watchers`, `member_count`, `watcher_count`). `channel.state.mutedUsers` → + `client.mutedUsersStore`. — §K.1 · §K.2 - `channel.disconnected` → `channel.pendingDisposal`; `` keeps rendering a deleted channel. — §K.8 · P.15 - `client.activeChannels` → `client.channelManager.get(cid)` / `.values()`; `releaseUnusedChannels()` frees them. - `client.threads.state.threads` / `loadNextPage()` / `threadsById` → `client.threads.paginator`, `client.threads.get(id)`; @@ -526,7 +533,7 @@ apps' own code. `stopLiveLocationSharing({ message_id })`, `LiveLocationManager.dispose()`. - `channel.markRead()` is the direct request; the reporter/handler path is `channel.markReadViaReporter()` (or `useMarkRead`). `markRead` / `markUnread` throw when read events are disabled. — §5 -- No `*WithLocalUpdate` methods: optimistic send/edit/delete/reactions are `channel.messageOperations.*` (UI: +- Optimistic send/edit/delete/reactions are `channel.messageOperations.*` (UI: `useMessageOperations()`). — §9 - `deleteMessage` options `{ hardDelete, deleteForMe }` → `{ hard, delete_for_me }`. — §17.2 - Uploads: `uploadFile` / `uploadImage({ file: { uri, name, type } })`. — §17.5 @@ -557,6 +564,9 @@ apps' own code. - `initializeOnMount={false}` still runs a non-watching query when the paginator is empty. - A provisional (members-only, id-less) channel is not queried on mount. - `` may render `channel.supersededBy` instead of the instance you passed. +- Reopening a channel whose paginator was left on an older page (after a jump to a quoted, pinned or searched + message) shows the newest messages; skipped while a jump has landed or is loading, before a first-unread jump, and + with `initializeOnMount={false}` (call `useJumpToLatestOnOpen()` yourself then). Same for ``. — §8 · #3916 - `client.options.isLocalUnreadCountEnabled` is no longer read → `channel.config.readEvents.localUnreadCountEnabled`. - Poll creation button shows before the server `polls` flag arrives. - Composer `maxLength` follows `messageComposer.config.text.maxLengthOnSend`. @@ -576,6 +586,8 @@ apps' own code. mount, after `disconnectUser` empties it, or a new paginator), never re-queries on prop changes and never disposes it. `disconnectUser` empties registered paginators but keeps them registered. — §18.6 · #3869 - Recovery re-runs each list's first page; `` has no reconnect listener. +- A list whose paginator was never queried loads its first page even when an event already put channels into it. + — P.21 · #3916 - An empty `sort` means the backend default. - Channel preview typing indicator ignores typing inside threads; `MessageFooter` shows the sender name when `memberCount !== 2`. @@ -598,7 +610,7 @@ Only affects imports from `stream-chat-react-native-core/src/…` or `lib/…`. These replace removed APIs and need documentation: - Components: `` as a standalone, sibling-of-`` component that renders its children. -- Hooks: `useMessagePaginator`, `useMessageOperations` (+ `MessageOperations` type), `useMarkRead`, +- Hooks: `useJumpToLatestOnOpen` (#3916), `useMessagePaginator`, `useMessageOperations` (+ `MessageOperations` type), `useMarkRead`, `useIsTargetedMessage`, `useChannel`, `useIsMessageReadByOthers`, `useIsMessageDeliveredToOthers`, `usePendingUploadsEnabled`, `useConnectionLifecycle`, `useNetworkConnectionState(Selector)`, `useWSConnectionState(Selector)`, `useSettledWSConnectionHealth`. @@ -619,8 +631,273 @@ These replace removed APIs and need documentation: Fix these before release, or document them as limitations: -- `stream-chat-js`'s consolidated breaking-change branch deletes the root `v9-to-v10-migration-guide-*.md` files that - the ledger links to; repoint the links when it merges. +- None open. (The guide's links into `stream-chat-js` now target anchors of its `ai-docs/v10-breaking-changes.md` on + `release-v10` instead of the deleted root `v9-to-v10-migration-guide-*.md` files.) + +## 17. No-action fixes: detail moved from the migration guide + +The migration guide keeps a one-line "No action" note for each of these; the full write-up is here. The fixes made +on `V10` after the guide's last update are listed first. + +### #3916 (V10 @ `74884ff55`) + +- `setupCommandUIMiddlewares` inserts all four middleware with `unique: true`, so running it again (the composer + setup function re-runs on every composer subscribe and every `client.config` change) replaces them rather than + stacking duplicates. A draft saved with an active command no longer comes back as `/giphy /giphy cats`. No action; + an app that calls it from its own setup function gets the fix too. +- `` treats a paginator that was never queried as unloaded, so a list that mounts after + `ChannelManager.ingestChannel()` routed an event into its paginator (opened from a push notification, or a second + list such as archived channels) runs its first query instead of showing only the routed channels. — P.21 +- `useJumpToLatestOnOpen` (exported): `` / `` (through `ChannelShell`) call + `paginator.jumpToTheLatestMessage()` in a layout effect when a channel or thread opens on an older page, because + `watch()` returns early for a channel that is already watched and loaded. It does nothing when the paginator has no + items, is at the head, is loading, or `` has a pending initial target (a focus signal, or the first-unread + jump); not run under `initializeOnMount={false}`. — §8 +- The `useChannelPreviewData` selector returns the `pinned` flag instead of the membership object (#3910). No API + change. + +### Guide L.8 Unsent messages now survive a reconnect rebuild (bug fix) + +`channel.reload()` and `thread.reload()` both preserve failed (locally unsent) messages across the +refresh. They already intended to, but the check for "did this one fall out?" read the paginator's +item *index* rather than its visible window — and on a disjoint rebuild (the loaded window shares no +id with the server's newest page, e.g. after scrolling up into old history) a message can sit in the +index while being absent from the rendered list. So the re-ingest was skipped on exactly the path +that needed it, and the user's unsent message disappeared from the list on reconnect. + +No API change and nothing to do — noted because if you worked around it by re-adding failed messages +yourself after a reconnect, that workaround is now redundant. + +For threads there was a second hole: the preservation relied on `Thread`'s `failedRepliesMap`, which +is only written by `upsertReplyLocally` — whose callers are the thread's own subscriptions and the +offline-DB path that looks threads up in `client.threads`. Neither covers a thread constructed directly +with `new Thread()` and never registered (`thread.activate()` and `client.threads.ensure()` register one +immediately; `client.threads.get(id)` resolves it). `thread.reload()` now reads the failed replies out of +the reply paginator instead, so it holds for managed and unmanaged threads alike. + +### Guide L.9 The reconnect refresh now finds messages you never had (bug fix) + +Two defects meant a reconnect could refresh a channel or thread and still miss content that arrived +while you were away. Both are fixed; no API change. + +- **The request was sized to what was already loaded.** `channel.reload()` / `thread.reload()` asked + for `loadedCount` items, so a window holding one message asked the server for one message — new + content was undiscoverable, and the single item that came back was disjoint from the loaded window, + so the fold rebuilt and dropped what was there. Now at least a page is requested. +- **A window nothing had ever anchored discarded the page entirely.** When the first query returns + empty and the only content arrives live — a thread or channel created in the current session — the + fetched page was thrown away rather than merged. It is now anchored instead. + +Most visible as: create a thread, send a reply, go offline, someone else replies, come back — the new +replies never appeared, and re-entering the thread did not help. + +### Guide L.10 A brand-new thread no longer reports an error (bug fix) + +A parent with no replies has no server-side thread, so `getThread` answers "not found". That was being +treated as a failed refresh and reported as one, so simply opening a new thread raised an error state. +It is now recognised as the expected answer — `reload()` resolves quietly instead of rejecting. + +A genuine failure still rejects as before, including a thread that *did* have replies and comes back +not-found (its parent was deleted, possibly while you were offline). The two are told apart by +`replyCount`, which — unlike `deletedAt` — survives having missed the deletion event. + +Related: the SDK no longer issues a mark-read for a reply-less thread. There is nothing that could be +unread, and the call only ever 404'd. + +### Guide M.1 Editing a message is optimistic again, and persisted (bug fix) + +An edit shows immediately and is written to the offline DB before the request is made, so it survives +a cold start and the hydration `` performs on mount. v9 did both (`Channel.tsx` wrote the +optimistic copy through `db.updateMessage`); v10 kept neither. + +`message_text_updated_at` is stamped optimistically too, so the "edited" indicator appears at once — +except when the message being edited is itself `failed`, which never had a server-confirmed text +update to advertise. + +**An edit is never rolled back.** Reverting would discard text the user typed. What changes is only +whether the failure is shown on the message: + +- **Offline support enabled and the request was queued for replay** — the message does **not** enter a + failed state. It is pending, not failed, and marking it failed lights up the retry affordance, which + re-*sends* the message rather than re-editing it. The request still rejects, so the composer still + reports the failed edit. +- **No offline DB, or a definitive rejection** (a server error that is not retryable, or a cancelled + request) — + the message keeps the edit and gains `status: 'failed'` plus `error`, as before. + +The predicate is the same one reactions already use: an offline DB is present **and** the error is +`isEphemeral`. + +### Guide M.3 Unsent messages survive closing the app (bug fix — data loss) + +Every send now writes the message to the offline DB **before** the request, pessimistically marked +`failed`, and overwrites it with `received` on success. A process death anywhere between composing and +the server's ack therefore leaves a message that hydrates as failed and retryable, instead of one that +silently disappears. This is v9's write-ahead (`Channel.tsx`), which v10 dropped when `sendMessage` +moved into `stream-chat`'s message operations (`channel.messageOperations.send`). + +No schema change and no `dbVersion` bump: `status` already round-trips through the storable's +`extraData` blob. + +The retry payload does not need persisting alongside it. `MessageOperations.retry` reconstructs the +request from the message when its in-memory `failedSendCache` is cold, so a persisted failed message +stays retryable past that cache's 5-minute TTL and across restarts. + +`channel.reload()` also consults the DB, not just the in-memory window, when deciding which failed +messages a reconnect has to put back — a message evicted from the paginator, or one on a cold boot that +the paginator never held, was previously unrecoverable. v9 got this from a second, lagging copy of the +message list (the SDK's own React state); with a single reactive source of truth the persisted row is +that buffer. + +### Guide M.4 An empty request response no longer overwrites local state (bug fix) + +`formatMessage(undefined)` does not throw — it returns `{ status: 'received', created_at: , +updated_at: }`. Because that `updated_at` is *now*, it beat the "is the server copy newer?" check +and was ingested as an id-less message. v9 guarded every apply with `if (response?.message)`; the guard +is back. Reachable from a custom `sendMessageRequest` / `updateMessageRequest` / `deleteMessageRequest` +that resolves without a `message`. + +### Guide M.5 Editing or deleting a thread parent from inside a thread works (bug fix) + +The reply paginator's local filter is `{ cid, parent_id }`, and a parent message has no `parent_id` — +so routing a parent edit or delete through the open thread's instance (which is what the SDKs do while +a thread is on screen) handed it to a collection that could not hold it, and the operation was silently +dropped. Optimistic writes now fall back to the client-global message store when the paginator does not +accept the message, reaching it wherever it is held and fanning out to every collection that holds it — +the same addressing `applyReactionLocally` uses. The SDK additionally routes by membership rather than +by "is a thread open", mirroring `sendReaction`. + +### Guide M.7 The offline channel guard actually guards now (bug fix) + +`channelExists` ran `SELECT EXISTS(SELECT 1 FROM channels WHERE cid = ?)` and returned +`rows.length > 0`. `SELECT EXISTS` always returns exactly one row — holding `0` or `1` — so the row +count carried no information and the helper reported `true` for every cid, present or not. It has done +so since offline support v2 shipped; the LLC's tests mock `channelExists`, so the logic was covered and +the SQL never was. + +Its only consumer is `AbstractOfflineDB.queriesWithChannelGuard`, used by ten WS-event handlers +(`message.new`, `message.deleted`, `message.updated`, `message.read`, `member.*`, `reaction.*`, …). Its +gate is `forceUpdate || !(await channelExists({ cid }))`, which collapsed to `forceUpdate` — so the +branch that recreates a missing channel row from the event never ran, and those writes died on the +`messages.cid → channels.cid` foreign key and were swallowed by the detached query runner. + +Reachable whenever an event arrives for a channel the DB has no row for: after `resetDB()` (which the +sync-failure path and a >30-day-stale sync both trigger) until the next channel-list query; in the +window between `queryChannels` resolving and its persistence completing (and `channel.query`'s +persistence is detached, widening it); and after being added to a channel mid-session. The message list +self-heals on the next query — reactions, read state, member changes and older messages in those +windows did not. + +**Both channel guards are also lazy now.** `queriesWithChannelGuard` and +`upsertMessageWithChannelGuard` attempt the write first and only probe for the channel row when it +fails, repairing and retrying once; a failure with the channel row present is rethrown without a retry. +Every statement involved is an upsert, so the retry is idempotent. The eager probe is kept for +`execute: false` callers (collecting queries for someone else's batch, so there is no failure to catch) +and for `forceUpdate`. + +That inversion matters because the probe is a native round-trip, not a cheap read. Measured through +op-sqlite on device: the probe costs ~0.5ms against ~8-12ms for the message upsert it protects, and +roughly two thirds of that is the JS↔native crossing rather than the query. Removing it from the happy +path takes it off every message received as well as every message written — so message writes are +cheaper than they were before this release, not more expensive. + +### Guide P.10 Message list behaviour fixes + +These change what the user sees: + +- **Thread lists use the thread's paginator for everything.** They used to drive the channel's. So: + - closing a thread no longer leaves the channel counting new messages as unread while the user is at + its bottom (the "1 new messages" separator that stuck after returning from a thread); + - the scroll-to-bottom button in a thread jumps the thread to its latest replies, where it used to + move the hidden channel list; + - in `MessageFlashList` threads, a channel that had jumped away no longer turns autoscroll off; + - the sticky date header hides at the top of the thread; + - a spinner shows while newer replies load, and the empty-state check follows the thread; + - rows in a thread read the thread's unread separator and last-bubble state, and the + scroll-to-bottom button in a thread no longer marks the channel read. +- **A failed page no longer stops pagination.** The lists remembered every list length they had + requested at, so after a failed request that edge stayed dead until a new message arrived. +- **Removing a message no longer scrolls `MessageList` to the bottom.** Cancelling a giphy or a hard + delete used to jump a user who had scrolled up back to the newest message. (`MessageFlashList` never + scrolled; it only blocked paging for a moment.) +- The scroll-to-bottom accessibility action's unread count updates as reads arrive. + +### Guide P.14 `` follows the connected user + +`` used to read `client.userID` during render, so a `` mounted before `connectUser()` +resolved only noticed the user, and only set up the offline database for them, if something +unrelated re-rendered it. It now re-reads the user whenever the client's connection state changes, +and the offline database's state from the instance it attached. No API change. + +### Guide P.16 Channel behaviour fixes + +- **The contexts follow the channel instance, not its id.** A disposed channel comes back as a new + instance under the same cid. `ChannelContext` kept handing out the old one, whose paginators were + already disposed. `useCreateChannelContext` now rebuilds when `channel` changes. +- **`useChannelContext().disabled` updates when the channel is frozen or unfrozen.** It used to update + only because an unrelated write happened to re-render ``. +- **`` no longer re-renders on channel updates that leave its own capabilities unchanged.** +- **`overrideOwnCapabilities` applies when a different capability is overridden.** `{ sendMessage: false }` + followed by `{ uploadFile: false }` used to keep the first override. +- **Going to the background sends `typing.stop` only if the user was typing.** It used to send one for + every mounted `` each time. + +### Guide P.18 Editing a message + +- Editing a message again after cancelling an edit starts from the message, not from the text the + cancelled edit left behind. +- Saving or cancelling an edit leaves edit mode by itself; the composer clearing is the signal. +- `useMessageComposer()` returns the same composer as before, read from context. Calling it no longer + subscribes the composer once per component. + +### Guide P.19 Composer behaviour fixes + +- `SendMessageDisallowedIndicator` shows as soon as the channel's capabilities are known, including a + channel loaded from the offline database. It used to wait for the channel to be initialized, which + could arrive after the composer had already rendered. +- The commands button, the attach button and the commands sheet follow capabilities and server config + that arrive after the composer mounted. +- Attachment picker cells no longer re-render on every upload progress tick; a cell re-renders only + when its own selection changes. + +### Guide P.21 Channel list behaviour fixes + +- **Changing `onSelect`, `getChannelActionItems`, `maxUnreadCount`, `additionalFlatListProps`, + `loadMoreThreshold`, `numberOfSkeletons` or `setFlatListRef` on a mounted `ChannelList` takes effect.** + They used to be kept until the list happened to change. `setFlatListRef` is no longer called with + `null` and then the list again on every list update. +- **A mounted `ChannelList` loads again after `client.disconnectUser()` and the next `connectUser()`.** + It used to stay on its loading skeleton. +- **A channel row shows a draft that has only attachments**, as thread rows already did. + +### Guide P.22 Thread list behaviour fix + +A thread list row takes its latest reply, which sets its avatar and time, from +`thread.messagePaginator.aggregateState`. It used to take the last reply of the thread's loaded page, +which is an older reply once the thread has been scrolled back. + +### Guide Q.2 Behaviour fixes + +- **Reactions sheet** (`MessageUserReactions` / `useFetchReactions`): + - reactions added to other messages no longer appear in an open sheet; + - the sheet no longer refetches, and briefly empties, on every reaction to its message; + - offline, the sheet shows the reactions stored in the offline database again, through + `client.queryReactionsAndHydrate` (as in v9); + - the counts come from `reaction_groups`, so a reaction added offline or not yet confirmed is + counted. +- **Reminder header:** setting or clearing the time of an existing reminder switches the message + header between "Saved for later" and the reminder right away. +- **`useHandleLiveLocationEvents`:** another message's ended share no longer marks the watched + location as stopped. +- **`usePollAnswersPagination` / `usePollOptionVotesPagination`:** votes and answers that arrive + together are all kept. Only the last one used to survive. +- **Thread list:** a failed refresh from the "new threads" banner shows "Couldn't load new threads. Tap + to retry", and tapping it retries. The tile could not appear before, and was not pressable. +- **Channel list swipe sheet:** a direct chat shows "Online" when the other member is online. It used to + read the watcher count, which includes the current user, so the label was inverted. +- **Sticky date header:** it shows again after scrolling back down from the oldest message. +- **`MessageFlashList`:** a channel whose messages fit on screen no longer shows the date twice (the + sticky header over the inline separator). ## Regenerating this list diff --git a/ai-docs/v10-docs-impact.md b/ai-docs/v10-docs-impact.md index fae211452c..eff97c8a7a 100644 --- a/ai-docs/v10-docs-impact.md +++ b/ai-docs/v10-docs-impact.md @@ -646,7 +646,7 @@ Format: severity · what must change · scanner-detected breaking items with lin #### `17-channel-read-state.md` — Major rewrite -- **Change:** Whole model changed: `channelUnreadState` in ChannelContext -> `channel.messagePaginator.unreadStateSnapshot`; `read` from context -> `useStateStore(channel.state, s => ({ read: s.read }))`; `markRead` from context + `updateChannelUnreadState` option -> `useMarkRead(channel)` / `channel.markRead()` (reporter-driven cadence); `loadChannelAtFirstUnreadMessage(...)` (L264) -> `paginator.jumpToTheFirstUnreadMessage()`; `last_read: Date` (L23, L34) -> `TimestampNS`; `isLocalUnreadCountEnabled` (L146) -> config `channel.readEvents.localUnreadCountEnabled`; `getInstance(key, { timeout })` (L275) -> `{ client: { axiosRequestConfig: { timeout } } }`; `client.activeChannels` (L294) -> `client.channelManager.values()`; event union narrowing for `total_unread_count`. Change (stacked PRs): the thread note (L118-122: "none of the UI components related to read state are rendered in threads") and best practice L9 are outdated - a `` marks the thread read while it is open, its list shows an unread separator from `thread.messagePaginator.unreadStateSnapshot`, its scroll-to-bottom button does not mark the channel read, and opening a thread no longer marks the channel read. +- **Change:** Whole model changed: `channelUnreadState` in ChannelContext -> `channel.messagePaginator.unreadStateSnapshot`; `read` from context -> `useStateStore(channel.state, s => ({ read: s.read }))`; `markRead` from context + `updateChannelUnreadState` option -> `useMarkRead(channel)` / `channel.markReadViaReporter()` (reporter-driven cadence; `channel.markRead()` is the direct request); `loadChannelAtFirstUnreadMessage(...)` (L264) -> `paginator.jumpToTheFirstUnreadMessage()`; `last_read: Date` (L23, L34) -> `TimestampNS`; `isLocalUnreadCountEnabled` (L146) -> config `channel.readEvents.localUnreadCountEnabled`; `getInstance(key, { timeout })` (L275) -> `{ client: { axiosRequestConfig: { timeout } } }`; `client.activeChannels` (L294) -> `client.channelManager.values()`; event union narrowing for `total_unread_count`. Change (stacked PRs): the thread note (L118-122: "none of the UI components related to read state are rendered in threads") and best practice L9 are outdated - a `` marks the thread read while it is open, its list shows an unread separator from `thread.messagePaginator.unreadStateSnapshot`, its scroll-to-bottom button does not mark the channel read, and opening a thread no longer marks the channel read. - **Detected:** thread read state (L9, L120); `ChannelContext.read` (L49); `ChannelContext.markRead` (L73); `localUnread` (L147); `setChannelUnreadState` (L264); `loadChannelAtFirstUnreadMessage` (L264); `setTargetedMessage` (L264); `eventMe` (L283, L291, L292); `activeChannels` (L294) #### `18-draft-message.md` — Snippet / prop-table update From cf3a47a626ff95413e8a3cd43fdbf76d18081b4a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 3 Nov 2026 11:02:28 +0100 Subject: [PATCH 2/2] docs(ai-migration): fix the moderation.flagMessage call shape client.moderation.flagMessage and flagUser take an object ({ message_id } / { user_id }), not a positional id. --- ai-docs/ai-migration-v9-to-v10.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 291267ff67..49ff58898b 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -386,7 +386,7 @@ means changed. Details in the linked section. | `client.threads.state` `threads` / `client.threads.loadNextPage()` | `client.threads.paginator.state` `items` / `client.threads.paginator.toTail()` | §R.7 | | `client.addDevice(token, provider)` / `getDevices()` / `removeDevice(id)` | `client.createDevice({ id: token, push_provider })` / `listDevices()` / `deleteDevice({ id })` | §R.8 | | `client.queryChannels(filters, sort, options)` returning `Channel[]` | `client.queryChannelsAndHydrate({ filter_conditions: filters, sort, ...options })` | §R.8 | -| `client.muteUser(id)` / `client.banUser(id)` / `client.flagMessage(id)` | `client.moderation.mute({ target_ids: [id] })` / `.ban({ target_user_id: id })` / `.flagMessage(id)` | §R.8 | +| `client.muteUser(id)` / `client.banUser(id)` / `client.flagMessage(id)` | `client.moderation.mute({ target_ids: [id] })` / `.ban({ target_user_id: id })` / `.flagMessage({ message_id: id })` | §R.8 | | `message.ai_generated` | `message.custom?.ai_generated` | §R.8 | | custom `AbstractOfflineDB.getChannelsForQuery({ filters, sort })` | `getChannelsForQuery({ userId, options })` — `options.filter_conditions`, `options.sort` | §R.9 | | `i18n.geti18Instance()` | `i18n.i18nInstance` | §R.10 | @@ -3147,8 +3147,8 @@ The ones the v9 sample apps hit. The full list is `stream-chat`'s own (§17.5). `sort` (an array), `limit`, `offset`, … - **Moderation moved to `client.moderation`** — `client.muteUser(id)` → `client.moderation.mute({ target_ids: [id] })`, `client.banUser(id)` → - `client.moderation.ban({ target_user_id: id })`, `flagMessage` / `flagUser` → `client.moderation.flagMessage` / - `flagUser`. + `client.moderation.ban({ target_user_id: id })`, `flagMessage(id)` / `flagUser(id)` → + `client.moderation.flagMessage({ message_id: id })` / `flagUser({ user_id: id })`. - **Reminders** — `client.reminders.upsertReminder({ message_id }, { remind_at: date })` (`remind_at` is a `Date`; omit it to clear), sort as `[{ field: 'remind_at', direction: 1 }]`, `ReminderResponse` → `ReminderResponseData`. - **Live location** — `channel.stopLiveLocationSharing({ message_id })` (it took the location object),