diff --git a/AGENTS.md b/AGENTS.md index 38eac2f279..d1f4c8f46b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ Each is a `StateStore`. Components read them with `useStateStore(store, selector └─ ``` -`Channel` takes a single `channel` prop (`src/components/Channel/Channel.tsx`). It activates the channel on mount, deactivates it on unmount, seeds the paginator's unread snapshot, and re-queries on `user.deleted` — nothing else. It does not query the channel for you: initialize it first (`channel.ensureWatched()` sends one watch for overlapping calls, and none for a channel already watched). There is no `Window` component in v15; compose layout yourself or through `ChatView` slots. +`Channel` takes a single `channel` prop (`src/components/Channel/Channel.tsx`). It activates the channel on mount, deactivates it on unmount, seeds the paginator's unread snapshot, and re-queries on `user.deleted` — nothing else. It does not query the channel for you: initialize it first (`channel.watch()` sends one watch for overlapping calls, and none for a channel already watched and loaded). There is no `Window` component in v15; compose layout yourself or through `ChatView` slots. Read the bound channel with `useChannel()` (`src/context/useChannel.ts`, thread's channel first, then the `Channel` subtree's) and the active message list with `useMessagePaginator()` (`src/hooks/useMessagePaginator.ts`, thread first, then channel). `useMessagePaginator()` is also exported from the package root, so integrator-facing docs and examples can use it too. @@ -282,7 +282,7 @@ const { // Manual setup when you need control over the API responses const client = await getTestClientWithUser({ id: 'test-user' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); -const channel = client.channelManager.ensure({ id: channelId, type: 'messaging' }); +const channel = client.channel('messaging', channelId); await channel.watch(); ``` diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 8f59825d0c..79aadb5873 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -9,7 +9,7 @@ Every breaking change confirmed in [`ai-docs/v15-breaking-changes.md`](https://g Never rely on training knowledge. The v14 surface is heavily represented in pretraining data and will mislead you. Before writing any code, verify against the user's installed packages: 1. `node_modules/stream-chat-react/dist/types/index.d.ts`: the public React type surface (props, override keys, hook signatures). Entry points other than the root have their own folders: `dist/types/plugins/SlotLayout/` for `stream-chat-react/slot-layout`, and so on. -2. `node_modules/stream-chat/dist/types/index.d.ts`: the core client types. `Channel` (`channel.d.ts`: `messagePaginator`, `messageOperations`, `configState`, `ensureWatched`), `ChannelManager.d.ts`, `thread.d.ts`, `pagination/paginators/`, `messageOperations/types.d.ts`. +2. `node_modules/stream-chat/dist/types/index.d.ts`: the core client types. `Channel` (`channel.d.ts`: `messagePaginator`, `messageOperations`, `configState`, `watch`), `ChannelManager.d.ts`, `thread.d.ts`, `pagination/paginators/`, `messageOperations/types.d.ts`. 3. `node_modules/stream-chat-react/package.json`: the `exports` map and peer dependencies. Rule: before claiming a symbol, prop or method exists, or writing a replacement snippet, grep the installed `.d.ts` and read the current signature. Do not reconstruct it from memory. @@ -59,15 +59,15 @@ These compile cleanly, or are ignored in plain JS, and change behavior at runtim | A thread renders below the channel instead of beside it, or a panel collapses to a narrow column | The channel no longer contains a flex row, and `ChatView` slots are `flex: 1 1 0` siblings | Make your container a row; size panels by slot ([section 19](#panel-layout)) | | A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | | A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | -| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | Watch it first: keep `channel.watch()`, or `ensureWatched()` ([section 5](#channel-no-longer-watches-the-channel)) | -| A direct message the app just created renders, but new messages never arrive | The created members-only instance was superseded by the store's instance for the same cid | Render the instance `ensureWatched()` resolves with ([section 5](#channel-no-longer-watches-the-channel)) | +| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | Watch it first: `channel.watch()` ([section 5](#channel-no-longer-watches-the-channel)) | +| A direct message the app just created renders, but new messages never arrive | The created members-only instance was superseded by the store's instance for the same cid | Render the instance `watch()` resolves with ([section 5](#channel-no-longer-watches-the-channel)) | | After a failed send, the composer is empty instead of holding the text and attachments | The composer is released before the request and nothing is put back | Nothing to do; the failed message is retried from the list ([section 13](#a-failed-send-or-edit-no-longer-restores-the-composer)) | | A component that rendered blank outside its provider now throws | Required context hooks throw instead of warning and returning `{}` | Render it inside the provider ([section 10](#required-context-hooks-throw-outside-their-provider)) | | A standalone `` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | | The channel is not marked read | Mark-read moved from `Channel` mount to the message list | Render a message list, or mark read yourself ([section 5](#markreadonmount-is-removed-the-list-marks-read)) | | The tab title no longer shows `(3) Title` | `Channel` no longer writes `document.title` | Write it in app code ([section 5](#channel-no-longer-writes-documenttitle)) | | Mount effects in a channel's children stop re-running when the channel changes | `Channel` no longer remounts its subtree per channel | Depend on the `channel` instance ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | -| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `ensureWatched()` on open ([section 4](#search-results)) | +| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `watch()` on open ([section 4](#search-results)) | | Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | | CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Retarget ([section 19](#19-css-and-markup)) | | Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | @@ -652,11 +652,11 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) - `useWorkspaceNavigation().openChannel(channel)`. - To also add it to the matching channel lists without a re-query: `client.channelManager.ingestChannel(channel)`. -- v14's `setActiveChannel(channel, watchers)` `watchers` argument: watch the channel yourself before opening it with the same options, `await channel.watch({ watchers: { limit: 10 } })` (or `ensureWatched(...)`, which skips the request when the channel is already watched). +- v14's `setActiveChannel(channel, watchers)` `watchers` argument: watch the channel yourself before opening it with the same options, `await channel.watch({ watchers: { limit: 10 } })` (it sends nothing when the channel is already watched and loaded). ### Search results -- `stream-chat` v10 queries channel search results with `watch: false`. The SDK's result items watch on open (`ensureWatched()`, or `jumpToMessage(id, { watchChannel: true })` for a message result). **A custom search result component that only opens the channel now opens an unwatched channel**: no events, possibly empty. Call `channel.ensureWatched()` when opening. +- `stream-chat` v10 queries channel search results with `watch: false`. The SDK's result items watch on open (`watch()`, or `jumpToMessage(id, { watchChannel: true })` for a message result). **A custom search result component that only opens the channel now opens an unwatched channel**: no events, possibly empty. Call `channel.watch()` when opening. - Default result items call `openChannel` and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` and `ChannelListContext.setChannels`. - Selecting a message result jumps with `channel.messagePaginator.jumpToMessage(...)`. The "jumped here" marker reads `messagePaginator.messageFocusSignal`, not `SearchController._internalState.focusedMessage`. @@ -716,13 +716,13 @@ export const Main = ({ activeChannel }: { activeChannel?: StreamChannel }) => What to change depends on whether the app already watched the channel, and on whether the channel has an id: - **Channels from `ChannelList` or any `queryChannels` call arrive watched.** Nothing changes for them. -- **The app already watches a channel it created by id** (`client.channel(type, id)` + `await channel.watch()`): **nothing changes.** `client.channel(...)` and `channel.watch(options)` still exist with the same roles; `client.channel()` is shorthand for `client.channelManager.ensure({ type, id, data })`. Do not rewrite these calls. The only edit at such call sites is the `stream-chat` v10 input shape ([below](#channel-input-shape)). -- **Members-only channels** (`client.channel(type, { members })`, no id, as most "start a direct message" flows create): keep `client.channel()`, but **render the instance `ensureWatched()` resolves with**, not the one you created. The created instance is provisional until the server assigns the id, and the client may already hold a channel with that id (event routing can store it while the request is in flight). The store then keeps its own instance and marks yours superseded (`state.supersededBy` set, `watchStatus: NotWatching`), and yours receives no events. `ensureWatched()` resolves with the surviving instance; `watch()` resolves with the response, so code that keeps the created instance renders a channel that silently stops updating. `ChatView` swaps superseded channels in its slots, so this bites when the app holds the channel itself. -- **The app relied on `Channel` to watch** (rendered an instance it never watched, possibly with `channelQueryOptions`): watch it before rendering. `await channel.watch(options)` works. `await channel.ensureWatched(options)` is the better fit where the call can repeat, such as an effect: it sends nothing for a channel already watched and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. `channel.watch()` always sends its request. `ensureWatched()` resolves with the channel. +- **The app already watches a channel it created by id** (`client.channel(type, id)` + `await channel.watch()`): **nothing changes.** `client.channel(...)` and `channel.watch(options)` still exist with the same roles; `client.channel()` is the public way to get a channel (it calls the internal `channelManager.ensure()`). Do not rewrite these calls. The only edit at such call sites is the `stream-chat` v10 input shape ([below](#channel-input-shape)). +- **Members-only channels** (`client.channel(type, { members })`, no id, as most "start a direct message" flows create): keep `client.channel()`, but **render the instance `watch()` resolves with**, not the one you created. The created instance is provisional until the server assigns the id, and the client may already hold a channel with that id (event routing can store it while the request is in flight). The store then keeps its own instance and marks yours superseded (`state.supersededBy` set, `watchStatus: NotWatching`), and yours receives no events. `watch()` resolves with the surviving instance, so code that keeps the created instance renders a channel that silently stops updating. `ChatView` swaps superseded channels in its slots, so this bites when the app holds the channel itself. +- **The app relied on `Channel` to watch** (rendered an instance it never watched, possibly with `channelQueryOptions`): watch it before rendering: `await channel.watch(options)`. It fits where the call can repeat, such as an effect: it sends nothing for a channel already watched and loaded and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. It resolves with the channel; refresh a watched channel with `channel.reload()`. - **`channelQueryOptions`** → pass them to that watch call (`ChannelGetOrCreateRequest`: `messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...). -- **`getChannel({ client, id, type })`** → `await client.channel(type, id).ensureWatched()`. +- **`getChannel({ client, id, type })`** → `await client.channel(type, id).watch()`. -A members-only channel the app already watched: keep `client.channel()`, take the instance from `ensureWatched()`, and fix the member shape: +A members-only channel the app already watched: keep `client.channel()`, take the instance from `watch()`, and fix the member shape: ```tsx // v14 @@ -735,7 +735,7 @@ setActiveChannel(channel); // v15 const channel = await client .channel('messaging', { members: [{ user_id: userId }, { user_id: otherUserId }] }) - .ensureWatched(); // the surviving instance; the created one can be superseded + .watch(); // the surviving instance; the created one can be superseded openChannel(channel); // useWorkspaceNavigation(), section 4 ``` @@ -755,7 +755,7 @@ const channel = client.channel('messaging', 'general'); ```tsx // v15, before rendering const channel = client.channel('messaging', 'general'); -await channel.ensureWatched({ messages: { limit: 30 } }); // or channel.watch(...) +await channel.watch({ messages: { limit: 30 } }); {/* ... */}; ``` @@ -767,12 +767,12 @@ const channel = await getChannel({ client, id: 'general', type: 'messaging' }); ```ts // v15 -const channel = await client.channel('messaging', 'general').ensureWatched(); +const channel = await client.channel('messaging', 'general').watch(); ``` -**Channel input shape (`stream-chat` v10).** The third argument of `client.channel()` (and `data` of `channelManager.ensure()`) is `ChannelInput`. `members` is `ChannelMemberRequest[]`, `{ user_id }` objects instead of an id list, and custom fields such as `name` go under `custom` (`{ custom: { name } }`). Plain ids now fail to compile (`TS2769`), though the API still accepts them at runtime (checked with `stream-chat@10.0.0-rc.22`), so this is a type fix, not a behavior change. +**Channel input shape (`stream-chat` v10).** The third argument of `client.channel()` is `ChannelInput`. `members` is `ChannelMemberRequest[]`, `{ user_id }` objects instead of an id list, and custom fields such as `name` go under `custom` (`{ custom: { name } }`). Plain ids now fail to compile (`TS2769`), though the API still accepts them at runtime (checked with `stream-chat@10.0.0-rc.22`), so this is a type fix, not a behavior change. [`examples/vite`](https://github.com/GetStream/stream-chat-react/tree/master/examples/vite) and the tutorial steps show the full flow. @@ -1840,7 +1840,7 @@ rather than a count of `message.new` events received while scrolled up. `Message` resolves its channel with `useChannel()`, which throws when there is no `Channel`, `Thread` or `ChannelInstanceProvider` ancestor. In v14 the missing contexts only warned. Wrapping a preview `` in `ChannelStateProvider` / `ChannelActionProvider` no longer compiles. -- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. [`examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx) is the reference. +- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channel(type, id)`), or render it inside `` / ``. [`examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx) is the reference. - `highlighted` is no longer derived from `ChannelStateContext.highlightedMessageId`. Pass `highlighted` yourself (`MessageProps.highlighted?: boolean`). ```tsx @@ -1856,7 +1856,7 @@ export const MessagePreview = ({ }) => ( @@ -2440,7 +2440,7 @@ A panel your layout renders without `` (for example a `ThreadSlot` placed ## 20. Tests -**Mock real stores.** `channel.state` is a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with `getLatestValue is not a function`. Build a real channel (`client.channelManager.ensure(...)`) and seed state on it. Build a `Thread` mock from a real `client.threads` instance too. +**Mock real stores.** `channel.state` is a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with `getLatestValue is not a function`. Build a real channel (`client.channel(...)`) and seed state on it. Build a `Thread` mock from a real `client.threads` instance too. | v14 mock | v15 | | --------------------------------- | ------------------------------------------------------------------------- | diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index 4a3f411758..5864eca514 100644 --- a/ai-docs/instance-configuration.md +++ b/ai-docs/instance-configuration.md @@ -45,7 +45,7 @@ export const App = () => ( ``` **Not in an effect.** Some configuration is read once when an instance is constructed, and channels -are constructed by `client.channelManager.ensure()` / `client.queryChannels()` — which an app typically calls before +are constructed by `client.channel()` / `client.queryChannels()` — which an app typically calls before or during the same commit that mounts ``. Registering from `useEffect` runs after that, so those values would arrive too late for instances that already exist. @@ -120,7 +120,7 @@ does differ, so the per-parent slices override the shared one field by field. ### `pageSize` is not the initial query's `messages.limit` -`channel.ensureWatched({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code +`channel.watch({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code that opens the channel now sends (`` no longer queries, and its `channelQueryOptions` prop is removed). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. They are different numbers and you usually want both: @@ -128,9 +128,9 @@ are different numbers and you usually want both: ```tsx chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); -const channel = await chatClient.channelManager - .ensure({ id: 'general', type: 'messaging' }) - .ensureWatched({ messages: { limit: 30 } }); +const channel = await chatClient + .channel('messaging', 'general') + .watch({ messages: { limit: 30 } }); …; ``` diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 3304fedfbf..daa58bbee7 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -842,7 +842,7 @@ A threads view is declared the same way (a `{ id: 'threads', slots: [...] }` lay - Without `ChatView`: hold the open channel in your own state and provide `WorkspaceNavigationProvider` (main entry) with an object built from `defaultWorkspaceNavigation`, overriding at least `openChannel`, `isChannelActive` and `openChannels` (and `openThread` / `openThreads` / `isThreadActive` if you render threads). Alternatively pass `onSelect` to `ChannelListItem` / search result items. - Reading "the active channel": inside the channel subtree use `useChannel()`; elsewhere `useWorkspaceNavigation().openChannels` (main entry) or `useSlotChannel({ slot })` (`slot-layout`). - Opening a channel programmatically: `useWorkspaceNavigation().openChannel(channel)`; to also list it without a re-query, `client.channelManager.ingestChannel(channel)` (stream-chat). - - The `watchers` argument: query the channel yourself (`channel.ensureWatched({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it. + - The `watchers` argument: query the channel yourself (`channel.watch({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it. - Evidence: - `release-v14:src/context/ChatContext.tsx:45-49` (`setActiveChannel`), `:54` (`channel?: Channel`); `release-v14:src/components/Chat/hooks/useChat.ts:116-131,137-144` - `master:src/context/ChatContext.tsx:27-46` (no `channel` / `setActiveChannel`); `master:src/components/Chat/hooks/useChat.ts:68-71` @@ -1160,11 +1160,11 @@ import { - `ChannelProps.initializeOnMount?: boolean` (default `true`: `Channel` called `channel.watch()`) - `ChannelProps.channelQueryOptions?: ChannelQueryOptions` - `getChannel({ client, channel?, id?, members?, options?, type? })` -- Replacement: initialize the channel before handing it to `Channel`. App code that already calls `await channel.watch(options)` on a channel created by id needs no change (`client.channel()` and `channel.watch()` keep their roles; `client.channel()` is shorthand for `client.channelManager.ensure({ type, id, data })`). A members-only channel (`client.channel(type, { members })`) must be rendered from the instance `ensureWatched()` resolves with: the created instance can be superseded by a stored instance for the server-assigned cid (`supersededBy`, `watchStatus: NotWatching`, no events). Code that relied on `Channel` to watch adds the call: `channel.watch(options)`, or `await channel.ensureWatched(options)` (stream-chat; sends nothing for an already-watched channel and joins an in-flight watch with the same options). Channels delivered by `ChannelList` arrive watched. v10 also changed the input shape (`members` as `{ user_id }` objects, custom fields under `custom`); that part is a type change only. +- Replacement: initialize the channel before handing it to `Channel`. App code that already calls `await channel.watch(options)` on a channel created by id needs no change (`client.channel()` and `channel.watch()` keep their roles; `client.channel()` is the public way to get a channel; it calls the internal `channelManager.ensure()`). A members-only channel (`client.channel(type, { members })`) must be rendered from the instance `watch()` resolves with: the created instance can be superseded by a stored instance for the server-assigned cid (`supersededBy`, `watchStatus: NotWatching`, no events). Code that relied on `Channel` to watch adds the call: `await channel.watch(options)` (stream-chat; sends nothing for a channel already watched and loaded and joins an in-flight watch with the same options). Channels delivered by `ChannelList` arrive watched. v10 also changed the input shape (`members` as `{ user_id }` objects, custom fields under `custom`); that part is a type change only. - Evidence: - `release-v14:src/components/Channel/Channel.tsx:107,141`; `release-v14:src/utils/getChannel.ts:34` - `master:src/components/Channel/Channel.tsx:17-23` (prop doc: "Initialize it before passing it in; `Channel` does not query"); `master:src/utils/getChannel.ts` removed - - `node_modules/stream-chat/dist/types/channel.d.ts:747` (`ensureWatched(options?: ChannelGetOrCreateRequest): Promise`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`) + - `node_modules/stream-chat/dist/types/channel.d.ts` (`watch(options?: ChannelGetOrCreateRequest, requestOptions?: StreamRequestOptions): Promise`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`) - commits: `088c795c1` (PR #3286); `a41eed56f feat!: replace StreamChat.activeChannels with entity item index for loaded channels` (PR #3310, removes `getChannel`) - corpus: `docs/02-ui-components__05-channel-list__02-channel_list_context__2.tsx:11` TS2724 (`getChannel`) - Docs impact: @@ -1184,17 +1184,14 @@ const channel = client.channel('messaging', { members: [userId, otherUserId] }); After (v15): ```tsx -const channel = await client.channelManager - .ensure({ - data: { members: [{ user_id: userId }, { user_id: otherUserId }] }, - type: 'messaging', - }) - .ensureWatched({ messages: { limit: 30 } }); +const channel = await client + .channel('messaging', { members: [{ user_id: userId }, { user_id: otherUserId }] }) + .watch({ messages: { limit: 30 } }); {/* ... */}; ``` -`ensureWatched` takes stream-chat's `ChannelGetOrCreateRequest` (`messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...), and `data.members` is `ChannelMemberRequest[]` (`{ user_id }` objects) in stream-chat v10 (LLC change; the React SDK builds DMs the same way, `SearchResultItem.tsx:222`). +`watch` takes stream-chat's `ChannelGetOrCreateRequest` (`messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...), and `data.members` is `ChannelMemberRequest[]` (`{ user_id }` objects) in stream-chat v10 (LLC change; the React SDK builds DMs the same way, `SearchResultItem.tsx:222`). --- @@ -1209,9 +1206,7 @@ const channel = await getChannel({ client, id: 'general', type: 'messaging' }); After (v15): ```ts -const channel = await client.channelManager - .ensure({ id: 'general', type: 'messaging' }) - .ensureWatched(); +const channel = await client.channel('messaging', 'general').watch(); ``` ### BC-021: `Channel` no longer writes `document.title`; `activeUnreadHandler` was removed @@ -1933,15 +1928,15 @@ const CustomThreadHeader = () => { - Status: confirmed - Area: search - User impact: silent. - - stream-chat v10 queries channel results (and the channels message search fetches) with `watch: false`. The SDK result items watch on open (`ensureWatched()`, or `jumpToMessage(id, { watchChannel: true })` for messages). A custom search result component that only opened the channel now opens an unwatched channel (no events, possibly empty). + - stream-chat v10 queries channel results (and the channels message search fetches) with `watch: false`. The SDK result items watch on open (`watch()`, or `jumpToMessage(id, { watchChannel: true })` for messages). A custom search result component that only opened the channel now opens an unwatched channel (no events, possibly empty). - default result items call `openChannel` (workspace navigation: no-op outside `ChatView`, BC-013) and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` + `ChannelListContext.setChannels`. - selecting a message result jumps with `channel.messagePaginator.jumpToMessage(...)`; `Channel` no longer performs the jump (BC-027). The "jumped here" marker reads `messagePaginator.messageFocusSignal`, not `SearchController._internalState.focusedMessage`. - Old API: - default `ChannelSearchResultItem` / `MessageSearchResultItem` / `UserSearchResultItem` behavior -- Replacement: in a custom result component, call `channel.ensureWatched()` (or `jumpToMessage(id, { watchChannel: true })`) when opening; open via `useWorkspaceNavigation().openChannel(channel, { event })`. The default items gained an optional `onSelect` prop that replaces their selection handler. +- Replacement: in a custom result component, call `channel.watch()` (or `jumpToMessage(id, { watchChannel: true })`) when opening; open via `useWorkspaceNavigation().openChannel(channel, { event })`. The default items gained an optional `onSelect` prop that replaces their selection handler. - Evidence: - `release-v14:src/components/Search/SearchResults/SearchResultItem.tsx:23-30,45-74,99-114` - - `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `ensureWatched`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243` + - `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `watch`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243` - commit: `a41eed56f` (PR #3310; "BREAKING CHANGE: channel search results ... are no longer watched; a custom search result component watches the channel when it is opened"); `088c795c1` (PR #3286) - Docs impact: - `02-ui-components/05-channel-list/05-channel_search.md`, `02-ui-components/05-channel-list/08-advanced-search.md`, `03-ui-cookbook/02-channel-list/02-channel_search.md` @@ -2590,7 +2585,7 @@ export const Stop = ({ channel }: { channel: Channel }) => { - Old API: - ``, or `` with no channel context - implicit `highlighted` from `useChannelStateContext().highlightedMessageId` -- Replacement: wrap it in `` (any real `stream-chat` `Channel`, e.g. `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`). +- Replacement: wrap it in `` (any real `stream-chat` `Channel`, e.g. `client.channel(type, id)`), or render it inside `` / ``. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`). - Evidence: - `master:src/context/useChannel.ts` throws when neither a thread nor `ChannelInstanceContext` supplies a channel - `master:src/components/Message/hooks/useOpenThreadHandler.ts:16` and `master:src/components/Message/hooks/useRetryHandler.ts:11` call `useChannel()` during every `Message` render diff --git a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx index 056d540438..fa940d7376 100644 --- a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx +++ b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx @@ -92,18 +92,13 @@ const ChatContent = ({ }, [channel?.id, searchParams, router]); // Load initial channel from URL on mount. `Channel` no longer queries the channel it is given, - // so it is watched here first. `channelManager.ensure()` returns the stored instance for the cid, - // and `ensureWatched()` de-duplicates concurrent watches of it. + // so it is watched here first. `client.channel()` returns the stored instance for the cid, + // and `watch()` sends nothing for it once watched and loaded, and joins a watch in flight. useEffect(() => { if (initialChannelId && client && !channel) { const loadChannel = async () => { - const targetChannel = client.channelManager.ensure({ - id: initialChannelId, - type: 'messaging', - }); - if (!targetChannel.initialized) { - await targetChannel.ensureWatched(); - } + const targetChannel = client.channel('messaging', initialChannelId); + await targetChannel.watch(); setActiveChannel(targetChannel); }; loadChannel().catch((err) => { diff --git a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx index 2ffefc413a..69992dd79f 100644 --- a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx +++ b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx @@ -130,10 +130,9 @@ export const MessageInputBar = () => { composer.textComposer.setText(message as string); // A new conversation exists only locally until now; `Channel` no longer creates it. - // Both setup steps run before `send()`, so a failure here leaves the draft untouched. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // `watch()` sends nothing for a conversation already watched and loaded. Both setup steps + // run before `send()`, so a failure here leaves the draft untouched. + await channel.watch(); const [platform, model] = (platformModel as string).split('|'); diff --git a/examples/tutorial/src/3-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx index 8fd5bd5f33..6238a0e091 100644 --- a/examples/tutorial/src/3-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -68,29 +68,23 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'custom_channel_id', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react', + name: 'Talk about React', }, - id: 'custom_channel_id', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one. Whoever supplies the // channel initializes it. // - // `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of this effect - // can hand back a channel that is already loaded -- query only when it is not. When a query - // is needed, `channel.ensureWatched()` de-duplicates concurrent calls for the same channel, so two - // overlapping runs still produce a single request. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // `client.channel()` returns the stored instance for this cid, so a re-run of + // this effect can hand back a channel that is already loaded. `channel.watch()` sends nothing + // for a channel already watched and loaded, and joins a watch in flight, so two overlapping + // runs still produce a single request. + await channel.watch(); setChannel(channel); }; diff --git a/examples/tutorial/src/6-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx index b914fd9db8..200c4ca392 100644 --- a/examples/tutorial/src/6-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -214,25 +214,19 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'react-tutorial', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', }, - id: 'react-tutorial', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); setIsReady(true); }; diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index c4bee8cc0e..34af08963c 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -138,25 +138,19 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'react-tutorial', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', }, - id: 'react-tutorial', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); setIsReady(true); }; diff --git a/examples/tutorial/src/optional-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx index 4afd40469c..e6ec0dea3e 100644 --- a/examples/tutorial/src/optional-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -181,24 +181,18 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - custom: { - image: 'https://getstream.io/random_png/?name=products', - name: 'Product recommendations', - }, + const channel = client.channel('messaging', 'react-tutorial-products', { + members: [userId], + custom: { + image: 'https://getstream.io/random_png/?name=products', + name: 'Product recommendations', }, - id: 'react-tutorial-products', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); // messages are no longer kept on channel.state — the paginator owns the list const hasProductMessage = (channel.messagePaginator.items ?? []).some((message) => diff --git a/examples/tutorial/src/optional-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx index 2073f6e3c6..3d3fcf5b21 100644 --- a/examples/tutorial/src/optional-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -41,24 +41,19 @@ const App = () => { if (!chatClient) return; const initChannel = async () => { - const spaceChannel = chatClient.channelManager.ensure({ - data: { - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://goo.gl/Zefkbx', - name: 'SpaceX launch discussion', - }, + const spaceChannel = chatClient.channel('livestream', 'spacex', { + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://goo.gl/Zefkbx', + name: 'SpaceX launch discussion', }, - id: 'spacex', - type: 'livestream', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!spaceChannel.initialized) { - await spaceChannel.ensureWatched(); - } + // caller's job. `spaceChannel.watch()` sends nothing for a channel already watched and + // loaded, and joins a watch in flight, so a re-run of this effect still sends one request at + // most. + await spaceChannel.watch(); setChannel(spaceChannel); }; diff --git a/examples/vite/docs-playwright/screenshot-attachment-actions.ts b/examples/vite/docs-playwright/screenshot-attachment-actions.ts index 0a7821a646..223210918e 100644 --- a/examples/vite/docs-playwright/screenshot-attachment-actions.ts +++ b/examples/vite/docs-playwright/screenshot-attachment-actions.ts @@ -45,10 +45,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Attachment Actions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index 9167e969b7..d64eb75f01 100644 --- a/examples/vite/docs-playwright/screenshot-misc.ts +++ b/examples/vite/docs-playwright/screenshot-misc.ts @@ -46,10 +46,10 @@ async function createChannel( await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(channelId)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(channelId)}, { name: 'Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); diff --git a/examples/vite/docs-playwright/screenshot-reactions.ts b/examples/vite/docs-playwright/screenshot-reactions.ts index e77359a4f8..de3119ac29 100644 --- a/examples/vite/docs-playwright/screenshot-reactions.ts +++ b/examples/vite/docs-playwright/screenshot-reactions.ts @@ -46,10 +46,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Reactions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-system-message.ts b/examples/vite/docs-playwright/screenshot-system-message.ts index acfb52c67b..bf62dae5e0 100644 --- a/examples/vite/docs-playwright/screenshot-system-message.ts +++ b/examples/vite/docs-playwright/screenshot-system-message.ts @@ -41,10 +41,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'System Message Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-variants.ts b/examples/vite/docs-playwright/screenshot-variants.ts index 56c1867145..cfd6098fa7 100644 --- a/examples/vite/docs-playwright/screenshot-variants.ts +++ b/examples/vite/docs-playwright/screenshot-variants.ts @@ -44,10 +44,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Cookbook Screenshots', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-voice-recording.ts b/examples/vite/docs-playwright/screenshot-voice-recording.ts index 1b7b88b98d..be35fa5366 100644 --- a/examples/vite/docs-playwright/screenshot-voice-recording.ts +++ b/examples/vite/docs-playwright/screenshot-voice-recording.ts @@ -48,10 +48,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Voice Recording Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs index 98a21581f0..37d652c3c1 100644 --- a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs +++ b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs @@ -636,13 +636,9 @@ async function run() { const client = findClient(); if (!client) return { ok: false, reason: 'no client' }; try { - const ch = client.channelManager.ensure({ - type: 'messaging', - id: channelId, - data: { - members: [userA, userB], - name: 'Design redesign — v14', - }, + const ch = client.channel('messaging', channelId, { + members: [userA, userB], + name: 'Design redesign — v14', }); await ch.watch(); const existingCount = (ch.state.messages || []).length; @@ -761,19 +757,13 @@ async function run() { const results = []; for (const [channelId, data] of Object.entries(channelImages)) { try { - const ch = client.channelManager.ensure({ - type: 'public', - id: channelId, - }); + const ch = client.channel('public', channelId); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok'); } catch (e) { // Try messaging type too try { - const ch = client.channelManager.ensure({ - type: 'messaging', - id: channelId, - }); + const ch = client.channel('messaging', channelId); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok (messaging)'); } catch (e2) { diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index b8b4cdaabe..9f5db4a600 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -388,9 +388,10 @@ const App = () => { }, [chatClient]); // `?focus=:` (repeatable) — open each named channel at the message it names. - // `jumpToMessage` loads the window and leaves a focus signal on the channel's paginator; the - // signal's countdown only starts once a message list has actually rendered it, so running this - // before the layout has mounted is fine — the highlight is still there when the list appears. + // `jumpToMessage` loads the page around the message and the channel state in one request, which + // also watches the channel (`watchChannel`), and leaves a focus signal on the channel's + // paginator. The signal's countdown only starts once a message list has rendered it, so running + // this before the layout has mounted is fine: the highlight is still there when the list appears. useEffect(() => { if (!chatClient) return; @@ -399,15 +400,12 @@ const App = () => { targets.forEach(({ cid, messageId }) => { const separatorIndex = cid.indexOf(':'); - const channel = chatClient.channelManager.ensure({ - id: cid.slice(separatorIndex + 1), - type: cid.slice(0, separatorIndex), - }); + const channel = chatClient.channel( + cid.slice(0, separatorIndex), + cid.slice(separatorIndex + 1), + ); - void (async () => { - if (!channel.initialized) await channel.ensureWatched(); - await channel.messagePaginator.jumpToMessage(messageId); - })(); + void channel.messagePaginator.jumpToMessage(messageId, { watchChannel: true }); }); }, [chatClient]); diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts index bab97b096d..e034af481e 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts @@ -16,7 +16,7 @@ import { StreamChat } from 'stream-chat'; * header. * * 2. The client constructor takes `(key, options)` only — there is no secret parameter — and - * `client.channelManager.ensure()` throws without a connected user. So callers must not build `Channel` + * `client.channel()` throws without a connected user. So callers must not build `Channel` * objects; they issue requests through `client.api.sendRequest` instead (see serverSideMethods). * * `crypto.subtle` requires a secure context — fine on localhost and https. diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts index 54a51aabd3..260aa9147b 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts @@ -7,7 +7,7 @@ import type { StreamChat } from 'stream-chat'; * member's channel-specific data, which a browser client cannot do. * * Everything goes through `client.api.sendRequest` rather than the generated helpers, for two - * reasons: `client.channelManager.ensure()` throws without a connected user, and the generated + * reasons: `client.channel()` throws without a connected user, and the generated * `updateMemberPartial` sends no `user_id`, so it can only ever write the caller's own * membership. `sendRequest` is the same primitive the generated APIs use internally and it * accepts query params, which is where `user_id` belongs. diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index da9ff76d6b..dede5bda9e 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -27,7 +27,7 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => { // `useUserRole` -> `useChannelCapabilities`, which subscribes to it as a StateStore. A plain // object shaped like channel state crashes there, which is what this preview used to pass. const previewChannel = useMemo( - () => client.channelManager.ensure({ id: 'reactions-preview', type: 'messaging' }), + () => client.channel('messaging', 'reactions-preview'), [client], ); diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index bef1f07f38..d51fab10b3 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -185,7 +185,7 @@ const writeWorkspaceToUrl = (encoded: string, mode: 'push' | 'replace') => { // ---- resolve: token → live binding (async) ------------------------------------------------------ -// `client.channelManager.ensure()` returns the stored instance for that cid (the same one the +// `client.channel()` returns the stored instance for that cid (the same one the // channel-list query watches), or builds one when none is stored. When the caller has already // waited for the list to settle (see `waitForChannelList`), that instance is `initialized`, so the // bound `` skips its own watch — no duplicate `/query`. A channel absent from every loaded @@ -197,7 +197,7 @@ const resolveChannel = (client: StreamChat, cid: string): Channel | undefined => const colon = cid.indexOf(':'); const type = cid.slice(0, colon); const id = cid.slice(colon + 1); - return type && id ? client.channelManager.ensure({ id, type }) : undefined; + return type && id ? client.channel(type, id) : undefined; }; /** @@ -229,14 +229,10 @@ const resolveBinding = async ( // `membership`, mute status etc. aren't loaded yet. Ownership resolution (`ingestChannel` // → `matchesFilter`) reads exactly that state, so ingesting now would only match the // empty-filter fallback list (`channels:opened`), never `channels:default`. Watch first so - // the channel is classified into its real owning list. Already-initialized channels - // (paginator-first / warm Back-Forward) skip this — and this is the same single watch - // `` would otherwise issue, just moved earlier, so it stays a single `/query`. - // Through `ensureWatched()`, so a `?focus=` entry naming this same channel shares this one - // watch instead of racing a second one. - if (!channel.initialized) { - await channel.ensureWatched().catch(() => undefined); - } + // the channel is classified into its real owning list. `watch()` sends nothing for a channel + // already watched and loaded (paginator-first / warm Back-Forward), and a `?focus=` entry + // naming this same channel joins this watch instead of racing a second one. + await channel.watch().catch(() => undefined); return { binding: { key: channel.cid, kind: 'channel', source: channel }, channel }; } case 'thread': { @@ -262,9 +258,9 @@ const resolveBinding = async ( const channel = resolveChannel(client, parentMessage.cid); if (!channel) return undefined; - // Same watch the bound `` would issue (see the channel case) — moved earlier so the - // thread's channel config, members and read state are loaded when the panel renders. - if (!channel.initialized) await channel.watch().catch(() => undefined); + // The same watch as in the channel case, so the thread's channel config, members and read + // state are loaded when the panel renders. + await channel.watch().catch(() => undefined); // Through `ensure()`, so the thread is registered with the `ThreadManager` and receives // events. It starts stale only when the parent has replies, and `` loads it then. @@ -377,7 +373,7 @@ export const WorkspaceUrlSync = () => { // Resolve a parsed workspace by entity id and apply it to the controller in ONE atomic write. // - // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channelManager.ensure()` + // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channel()` // and threads from `client.threads.get()`, both populated by the list paginators. Entities // already paginated are reused with NO network round-trip — so navigating Back/Forward between // already-visited workspaces (which keeps those paginators warm, unlike a reload) fetches nothing. diff --git a/examples/vite/src/Debug/scenarioChannel.ts b/examples/vite/src/Debug/scenarioChannel.ts index 56d2f7d3f9..435c93f3db 100644 --- a/examples/vite/src/Debug/scenarioChannel.ts +++ b/examples/vite/src/Debug/scenarioChannel.ts @@ -27,7 +27,7 @@ const NAME = 'Pagination scenarios (local)'; * server does not have it. */ export const ensureScenarioChannel = async (client: StreamChat) => { - const channel = client.channelManager.ensure({ id: ID, type: TYPE }); + const channel = client.channel(TYPE, ID); channel.data = { ...channel.data, custom: { ...channel.data?.custom, name: NAME } }; // You are its member, as in the channels you open from the list; the fake backend's answers // carry the channel's members on. diff --git a/examples/vite/src/NewConversation/createConversation.ts b/examples/vite/src/NewConversation/createConversation.ts index ebea70bcf1..b603c106d3 100644 --- a/examples/vite/src/NewConversation/createConversation.ts +++ b/examples/vite/src/NewConversation/createConversation.ts @@ -82,7 +82,8 @@ export const openOneToOne = async ( * Dev switch: `false` skips the lookup, so an existing 1:1 that isn't loaded yet is opened as a * local channel. Once that 1:1 is loaded meanwhile (by scrolling the channel list to it, or an * event), the server answers the local channel's first query with its cid, and the loaded - * instance supersedes the local one. A 1:1 already loaded is returned by `ensure` itself. + * instance supersedes the local one. A 1:1 already loaded is returned by `client.channel()` + * itself. */ lookUpExisting = true, }: { lookUpExisting?: boolean } = {}, @@ -105,14 +106,11 @@ export const openOneToOne = async ( const existing = candidates.find((channel) => channel.id?.startsWith('!members-')); if (existing) return { channel: existing, existed: true }; - const channel = client.channelManager.ensure({ - data: { - custom: withoutEmpty(details), - members: memberIds.map((user_id) => ({ user_id })), - }, - type: CHANNEL_TYPE, + const channel = client.channel(CHANNEL_TYPE, { + custom: withoutEmpty(details), + members: memberIds.map((user_id) => ({ user_id })), }); - // `ensure` returns a loaded 1:1 with these members rather than a new local channel + // `client.channel()` returns a loaded 1:1 with these members rather than a new local channel if (!channel.isProvisional) return { channel, existed: true }; createOnFirstSend(channel, client, [other]); return { channel, existed: false }; @@ -129,17 +127,11 @@ export const createGroup = async ( others: UserResponse[], details: ConversationDetails, ): Promise => { - const channel = client.channelManager.ensure({ - data: { - custom: withoutEmpty(details), - members: [client.userId as string, ...others.map(({ id }) => id)].map( - (user_id) => ({ - user_id, - }), - ), - }, - id: crypto.randomUUID(), - type: CHANNEL_TYPE, + const channel = client.channel(CHANNEL_TYPE, crypto.randomUUID(), { + custom: withoutEmpty(details), + members: [client.userId as string, ...others.map(({ id }) => id)].map((user_id) => ({ + user_id, + })), }); await channel.watch(); return { channel, existed: false }; diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index 1c576fa1bb..86d947b6b1 100644 --- a/examples/vite/src/SingleChannel/SingleChannelApp.tsx +++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx @@ -48,7 +48,7 @@ export const resolveSingleChannel = ({ separatorIndex === -1 ? ['messaging', channelKey] : [channelKey.slice(0, separatorIndex), channelKey.slice(separatorIndex + 1)]; - return client.channelManager.ensure({ id, type }); + return client.channel(type, id); } const loadedChannel = channelManager?.paginators.flatMap( @@ -56,10 +56,7 @@ export const resolveSingleChannel = ({ )[0]; if (loadedChannel) return loadedChannel; - return client.channelManager.ensure({ - id: SINGLE_CHANNEL_DEFAULT_ID, - type: 'messaging', - }); + return client.channel('messaging', SINGLE_CHANNEL_DEFAULT_ID); }; const channelDisplayName = (channel: StreamChannel) => diff --git a/src/components/Attachment/__tests__/Card.test.tsx b/src/components/Attachment/__tests__/Card.test.tsx index 61222fe73e..3e2059c0f6 100644 --- a/src/components/Attachment/__tests__/Card.test.tsx +++ b/src/components/Attachment/__tests__/Card.test.tsx @@ -61,10 +61,7 @@ describe('Card', () => { beforeAll(async () => { chatClient = await getTestClientWithUser({ id: user.id }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channelManager.ensure({ - id: mockedChannel['id'], - type: 'messaging', - }); + channel = chatClient.channel('messaging', mockedChannel['id']); channel.query(); }); diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index aa42138427..60e43a9a75 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -122,10 +122,7 @@ const initClient = async ({ const chatClient = await getTestClientWithUser(user); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); // `Channel` does not query: the caller initializes the channel. await channel.watch(); @@ -213,10 +210,7 @@ describe('Channel', () => { // `Channel` binds and subscribes to the channel but does not fetch: an unqueried channel stays // unqueried and its children render what an empty channel renders. const { chatClient } = await setup(); - const unqueried = chatClient.channelManager.ensure({ - id: 'never-queried', - type: 'messaging', - }); + const unqueried = chatClient.channel('messaging', 'never-queried'); const watchSpy = vi.spyOn(unqueried, 'watch'); await renderComponent({ channel: unqueried, chatClient }); diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx index 0d44a7db7a..c36c1ece73 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx @@ -86,11 +86,7 @@ async function renderComponent({ testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - data: channelData, - id: testChannel1.channel.id, - type: channelType, - }); + const channel = client.channel(channelType, testChannel1.channel.id, channelData); await channel.query(); if (typing) channel.state.typing = typing; @@ -225,10 +221,7 @@ describe('ChannelHeader', () => { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState }); useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - id: testChannel1.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', testChannel1.channel.id); await channel.query(); renderComponentBase({ @@ -252,10 +245,7 @@ describe('ChannelHeader', () => { testChannel1 = generateChannel({ ...defaultChannelState }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - id: testChannel1.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', testChannel1.channel.id); await channel.query(); render( diff --git a/src/components/ChannelList/__tests__/ChannelLists.test.tsx b/src/components/ChannelList/__tests__/ChannelLists.test.tsx index 608b7bed53..7367d68149 100644 --- a/src/components/ChannelList/__tests__/ChannelLists.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelLists.test.tsx @@ -38,9 +38,7 @@ const seededPaginator = (client: StreamChat, id: string) => { const paginator = new ChannelPaginator({ client, id }); paginator.setItems({ isLastPage: true, - valueOrFactory: [ - client.channelManager.ensure({ id: id.replace(':', '-'), type: 'messaging' }), - ], + valueOrFactory: [client.channel('messaging', id.replace(':', '-'))], }); return paginator; }; diff --git a/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts b/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts index ab9c3dba87..c102e061c8 100644 --- a/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts +++ b/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts @@ -13,7 +13,7 @@ const member = (id: string) => ({ describe('channel member state hooks', () => { it('useChannelMembersState follows members applied without an event, such as a query response', async () => { const client = await getTestClientWithUser({ id: 'ann' }); - const channel = client.channelManager.ensure({ id: 'dm', type: 'messaging' }); + const channel = client.channel('messaging', 'dm'); const { result } = renderHook(() => useChannelMembersState(channel)); expect(result.current).toEqual({}); @@ -29,7 +29,7 @@ describe('channel member state hooks', () => { it('useChannelMembershipState follows the membership applied without an event', async () => { const client = await getTestClientWithUser({ id: 'ann' }); - const channel = client.channelManager.ensure({ id: 'dm', type: 'messaging' }); + const channel = client.channel('messaging', 'dm'); const { result } = renderHook(() => useChannelMembershipState(channel)); act(() => { diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx index dbc048b18c..a30145665c 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx @@ -87,7 +87,7 @@ describe('ChannelPreviewMessenger', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - channel = chatClient.channelManager.ensure({ type: 'messaging' }); + channel = chatClient.channel('messaging'); await channel.watch(); }; diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 9535b6d515..749309c21e 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -36,7 +36,7 @@ describe('ChannelPreview utils', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - const channel = chatClient.channelManager.ensure({ type: 'messaging' }); + const channel = chatClient.channel('messaging'); await channel.watch(); diff --git a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx index bf4e2a1008..00ba39f720 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx @@ -32,10 +32,7 @@ const getClientAndChannel = async (channelOverrides = {}) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { channel, client }; diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx index e6a7e70a86..b156db6756 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -32,10 +32,7 @@ describe('useIsChannelMuted', () => { // A channel that was never watched is not initialized, and the imperative // `channel.muteStatus()` throws `_checkInitialized` on it. The reactive `muteStatus` slice is // safe to read. - const channel = client.channelManager.ensure({ - id: 'never-watched-channel', - type: 'messaging', - }); + const channel = client.channel('messaging', 'never-watched-channel'); expect(channel.initialized).toBe(false); @@ -50,10 +47,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { @@ -67,10 +61,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); expect(channel.initialized).toBe(true); @@ -90,10 +81,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { diff --git a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx index b8d71d3d31..8b26585cc3 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -41,10 +41,7 @@ const getClientAndChannel = async (channelData = {}, user = ownUser) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index baf7ffcff0..eecd6d6e1f 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -346,7 +346,7 @@ describe('Chat', () => { // The search results and the threads view replace the channel list, and the lists must keep // receiving events meanwhile. const client = await getTestClientWithUser({ id: 'user_x' }); - const channel = client.channelManager.ensure({ id: 'routed', type: 'messaging' }); + const channel = client.channel('messaging', 'routed'); const ingestChannel = vi.spyOn(client.channelManager, 'ingestChannel'); const channelUpdated = () => client.dispatchEvent({ diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index b41f4cae9b..c9f3f0e472 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -111,7 +111,7 @@ async function renderComponent({ }); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ id: channelData.channel.id, type }); + channel = client.channel(type, channelData.channel.id); await channel.watch(); client.channelServerConfigsStore.partialNext({ configs: { ...client.channelServerConfigs, [channel.cid]: config as never }, diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx index 8051810780..049c596b1d 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx @@ -47,10 +47,7 @@ describe('useDeleteHandler custom hook', () => { client = await getTestClientWithUser(generateUser()); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ - id: channelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', channelData.channel.id); }); afterEach(() => { diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index 3590dd9245..1193291589 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -55,10 +55,7 @@ describe('useReactionHandler custom hook', () => { client = await getTestClientWithUser(alice); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ - id: channelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', channelData.channel.id); }); afterEach(() => { diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx index a691849e8b..ef894b95c4 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -65,10 +65,7 @@ describe('useMessageComposerController', () => { client = await getTestClientWithUser({ id: 'test-user' }); const mockedChannelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - channel = client.channelManager.ensure({ - id: mockedChannelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', mockedChannelData.channel.id); await channel.watch(); }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 1e38903bef..efb799ff58 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -209,10 +209,7 @@ describe('MessageList', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); - channel = chatClient.channelManager.ensure({ - id: mockedChannelData['id'], - type: 'messaging', - }); + channel = chatClient.channel('messaging', mockedChannelData['id']); await channel.watch(); markReadMock = vi @@ -2089,10 +2086,7 @@ describe('MessageList notification area', () => { it('stays on screen when the channel being opened is still bootstrapping', async () => { const { channelA, client } = await setup(); // Never watched: `Channel` renders it without querying, so the switch commits immediately. - const bootstrapping = client.channelManager.ensure({ - id: 'never-watched', - type: 'messaging', - }); + const bootstrapping = client.channel('messaging', 'never-watched'); const { rerender } = render(renderChannel(client, channelA)); raise(client); @@ -2162,10 +2156,7 @@ describe('MessageList NewMessageNotification', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(windowChannelData)]); - channel = chatClient.channelManager.ensure({ - id: windowChannelData.channel.id, - type: 'messaging', - }); + channel = chatClient.channel('messaging', windowChannelData.channel.id); await channel.watch(); vi.spyOn(channel, 'markRead').mockResolvedValue(fromPartial(markReadApi(channel))); }); diff --git a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx index b270dd772e..cc6a78e634 100644 --- a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx @@ -100,7 +100,7 @@ const requeryWithOwnUnreadCount = async (unreadCount: number) => { ), ]); await act(async () => { - await channel.watch(); + await channel.reload(); }); }; diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 3bad81eabb..7303a816fd 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -114,10 +114,7 @@ async function createChannel(empty = false) { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { channel, client }; @@ -251,10 +248,7 @@ describe('VirtualizedMessageList', () => { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); // With newer pages still to load, scrollToBottom jumps to the latest message set instead of // scrolling, so pin it to the branch that targets a Virtuoso index. @@ -396,10 +390,7 @@ describe('VirtualizedMessageList', () => { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); await act(() => { diff --git a/src/components/MessageList/__tests__/messageSourceKey.test.ts b/src/components/MessageList/__tests__/messageSourceKey.test.ts index 7204ef6aa3..5e9237f942 100644 --- a/src/components/MessageList/__tests__/messageSourceKey.test.ts +++ b/src/components/MessageList/__tests__/messageSourceKey.test.ts @@ -17,7 +17,7 @@ describe('getMessageSourceKey', () => { }); // A replacement instance for the same cid is a different object, and must key differently. client.channelManager.removeChannel(first.cid); - const second = client.channelManager.ensure({ id: 'channel-a', type: 'messaging' }); + const second = client.channel('messaging', 'channel-a'); expect(getMessageSourceKey({ channel: first })).toBe( getMessageSourceKey({ channel: first }), diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 048c224502..0aadb27cb5 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -87,7 +87,7 @@ export const ChannelSearchResultItem = ({ // honor ⌘/ctrl-click. openChannel(item, { event }); // Neither channel search nor `Channel` watches, so watch here to receive the channel events. - item.ensureWatched().catch((error) => + item.watch().catch((error) => reportLoadFailed({ channel: item, client, @@ -155,22 +155,19 @@ export const MessageSearchResultItem = ({ if (!current || current.pendingDisposal) return; openChannel(current, { event }); channelManager.ingestChannel(current); - void (async () => { - // Load a never-loaded channel first, through the watch shared by anything opening it: a - // first page landing after the jump would move the list off the message. - if (!current.initialized) await current.ensureWatched(); - // `watchChannel` also watches a stored but unwatched channel (e.g. one a thread created), - // so it receives its events. - await current.messagePaginator.jumpToMessage(item.id, { watchChannel: true }); - })().catch((error) => - reportLoadFailed({ - channel: current, - client, - emitter: 'MessageSearchResultItem', - error, - t, - }), - ); + // One request loads the page around the message and the channel state, and watches a + // channel not watched yet (e.g. one a thread created), so it receives its events. + current.messagePaginator + .jumpToMessage(item.id, { watchChannel: true }) + .catch((error) => + reportLoadFailed({ + channel: current, + client, + emitter: 'MessageSearchResultItem', + error, + t, + }), + ); }, [cid, item, openChannel, channelManager, client, onSelect, t], ); @@ -211,17 +208,14 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro onSelect(event); return; } - const newChannel = channelManager.ensure({ - data: { - members: [{ user_id: client.userId as string }, { user_id: item.id }], - }, - type: directMessagingChannelType, + const newChannel = client.channel(directMessagingChannelType, { + members: [{ user_id: client.userId as string }, { user_id: item.id }], }); // The event is forwarded so an `openChannel` override can honor ⌘/ctrl-click. openChannel(newChannel, { event }); - newChannel.ensureWatched().then( + newChannel.watch().then( // Listed only once the watch gives a new DM its ID, since lists refuse a channel without - // one. `ensureWatched()` resolves with the stored instance if another took that ID + // one. `watch()` resolves with the stored instance if another took that ID // meanwhile. (channel) => channelManager.ingestChannel(channel), (error) => diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 8b198e1d31..acfc34826b 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -182,7 +182,7 @@ describe('SearchResultItem Components', () => { ); }); - it('does not watch an opened channel that is already watched', async () => { + it('sends no watch query for an opened channel already watched and loaded', async () => { const channelSearchData = generateChannel(); const { client } = await renderComponent({ channelSearchData, @@ -190,12 +190,16 @@ describe('SearchResultItem Components', () => { }); const channel = client.channelManager.get(channelSearchData.channel.cid); if (!channel) throw new Error('the result channel is not stored'); + // watched, with its state and messages loaded: `watch()` has nothing to send channel.watchStatus = ChannelWatchStatus.Watching; - const watch = vi.spyOn(channel, 'watch'); + channel.initialized = true; + channel.messagePaginator.placeExternalQueryPage([], 25); + const getOrCreate = vi.spyOn(channel, 'getOrCreate'); fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); - expect(watch).not.toHaveBeenCalled(); + await act(() => Promise.resolve()); + expect(getOrCreate).not.toHaveBeenCalled(); }); it('runs a custom onSelect instead of the default open', async () => { @@ -241,10 +245,7 @@ describe('SearchResultItem Components', () => { }); const { id, type } = messageResponseData.channel; const jumpToMessage = vi - .spyOn( - client.channelManager.ensure({ id, type }).messagePaginator, - 'jumpToMessage', - ) + .spyOn(client.channel(type, id).messagePaginator, 'jumpToMessage') .mockResolvedValue(true); await act(() => { @@ -257,7 +258,7 @@ describe('SearchResultItem Components', () => { expect(mockIngestChannel).toHaveBeenCalledTimes(1); }); - it('loads a channel that was never loaded before jumping to the message', async () => { + it('loads a channel that was never loaded with the jump to the message alone', async () => { const message = generateMessage(); const messageResponseData = { id: message.id, @@ -268,24 +269,27 @@ describe('SearchResultItem Components', () => { SearchResultItemComponent, }); const { id, type } = messageResponseData.channel; - const channel = client.channelManager.ensure({ id, type }); + const channel = client.channel(type, id); channel.state.partialNext({ initialized: false }); const calls: string[] = []; - vi.spyOn(channel, 'ensureWatched').mockImplementation(() => { - calls.push('ensureWatched'); + vi.spyOn(channel, 'watch').mockImplementation(() => { + calls.push('watch'); return Promise.resolve(channel); }); - vi.spyOn(channel.messagePaginator, 'jumpToMessage').mockImplementation(() => { - calls.push('jumpToMessage'); - return Promise.resolve(true); - }); + const jumpToMessage = vi + .spyOn(channel.messagePaginator, 'jumpToMessage') + .mockImplementation(() => { + calls.push('jumpToMessage'); + return Promise.resolve(true); + }); await act(async () => { fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); await Promise.resolve(); }); - expect(calls).toEqual(['ensureWatched', 'jumpToMessage']); + expect(calls).toEqual(['jumpToMessage']); + expect(jumpToMessage).toHaveBeenCalledWith(message.id, { watchChannel: true }); }); it('renders its stored channel without a connected user', async () => { @@ -434,11 +438,11 @@ describe('SearchResultItem Components', () => { it('adds the direct-message channel to the lists only once its watch resolves', async () => { await renderComponent({ SearchResultItemComponent, userData: user }); let resolveWatch: () => void = () => undefined; - const ensureWatched = vi - .spyOn(Channel.prototype, 'ensureWatched') - .mockImplementation(function (this: Channel) { - return new Promise((resolve) => (resolveWatch = () => resolve(this))); - }); + const watch = vi.spyOn(Channel.prototype, 'watch').mockImplementation(function ( + this: Channel, + ) { + return new Promise((resolve) => (resolveWatch = () => resolve(this))); + }); await act(() => { fireEvent.click(screen.getByRole('option')); @@ -451,8 +455,8 @@ describe('SearchResultItem Components', () => { await Promise.resolve(); }); - expect(mockIngestChannel).toHaveBeenCalledWith(ensureWatched.mock.contexts[0]); - ensureWatched.mockRestore(); + expect(mockIngestChannel).toHaveBeenCalledWith(watch.mock.contexts[0]); + watch.mockRestore(); }); it('adds the stored instance when the direct message was superseded during its watch', async () => { @@ -460,17 +464,15 @@ describe('SearchResultItem Components', () => { SearchResultItemComponent, userData: user, }); - const stored = client.channelManager.ensure({ id: 'stored-dm', type: 'messaging' }); - const ensureWatched = vi - .spyOn(Channel.prototype, 'ensureWatched') - .mockResolvedValue(stored); + const stored = client.channel('messaging', 'stored-dm'); + const watch = vi.spyOn(Channel.prototype, 'watch').mockResolvedValue(stored); await act(() => { fireEvent.click(screen.getByRole('option')); }); await vi.waitFor(() => expect(mockIngestChannel).toHaveBeenCalledWith(stored)); - ensureWatched.mockRestore(); + watch.mockRestore(); }); it('runs a custom onSelect instead of the default DM open', async () => { diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx index 968f1f2b89..9af1934bf9 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx @@ -221,7 +221,7 @@ describe('TypingIndicator', () => { channel: { config: fromPartial({ typing_events: true }) }, }); useMockedApis(client, [getOrCreateChannelApi(ch)]); - channel = client.channelManager.ensure({ id: ch.id, type: 'messaging' }); + channel = client.channel('messaging', ch.id); await channel.watch(); }); diff --git a/src/mock-builders/utils.ts b/src/mock-builders/utils.ts index b2bc70ea7b..7541725215 100644 --- a/src/mock-builders/utils.ts +++ b/src/mock-builders/utils.ts @@ -48,10 +48,7 @@ export async function createClientWithChannel({ const client = existingClient || (await getTestClientWithUser({ id: users[0].id })); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel['id'], - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel['id']); await channel.watch(); return { channel, client, users }; @@ -73,10 +70,10 @@ export const initChannelFromData = async ({ // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - const channel = client.channelManager.ensure({ - id: mockedChannelData.channel.id, - type: mockedChannelData.channel.type, - }); + const channel = client.channel( + mockedChannelData.channel.type, + mockedChannelData.channel.id, + ); await channel.watch(); // Written to the client's store, which `channel.serverConfig` reads and `channel.config` is // derived from; stubbing an accessor would leave `channel.config` stale. Keyed by cid: a diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index c0aeb7f4ab..c6f9a12752 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -232,15 +232,12 @@ const SendDirectMessageAction = () => { setIsSending(true); try { - const directMessageChannel = client.channelManager.ensure({ - data: { - members: [client.userID, targetUserId].map((user_id) => ({ user_id })), - }, - type: channel.type, + const directMessageChannel = client.channel(channel.type, { + members: [client.userID, targetUserId].map((user_id) => ({ user_id })), }); // The stored instance, which differs from `directMessageChannel` if another instance took the // DM's `cid` while the watch was in flight. - const dm = await directMessageChannel.ensureWatched(); + const dm = await directMessageChannel.watch(); // Open the DM in the workspace (a `ChatView` slot, or whatever a custom // `WorkspaceNavigationProvider` does), then ingest it into the channel list(s) that should // own it so it appears without a re-query. diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx index 39f0330412..9bfe4e58be 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx @@ -38,7 +38,7 @@ describe('SendDirectMessage action', () => { const renderAction = (created: Channel) => { vi.mocked(useChatContext).mockReturnValue({ channelManager: { ingestChannel }, - client: { channelManager: { ensure: () => created }, userID: 'user-me' }, + client: { channel: () => created, userID: 'user-me' }, } as unknown as ReturnType); render( ({ type: 'messaging' })}> @@ -72,8 +72,7 @@ describe('SendDirectMessage action', () => { it('opens and lists the direct message once it is watched', async () => { const created = fromPartial({ cid: 'messaging:dm' }); - (created as { ensureWatched: () => Promise }).ensureWatched = () => - Promise.resolve(created); + (created as { watch: () => Promise }).watch = () => Promise.resolve(created); renderAction(created); await act(async () => { @@ -88,9 +87,8 @@ describe('SendDirectMessage action', () => { it('opens and lists the stored instance when the direct message was superseded during its watch', async () => { const stored = fromPartial({ cid: 'messaging:dm' }); const created = fromPartial({ cid: 'messaging:dm', supersededBy: stored }); - // as stream-chat does: `ensureWatched()` resolves with the instance that superseded it - (created as { ensureWatched: () => Promise }).ensureWatched = () => - Promise.resolve(stored); + // as stream-chat does: `watch()` resolves with the instance that superseded it + (created as { watch: () => Promise }).watch = () => Promise.resolve(stored); renderAction(created); await act(async () => { diff --git a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx index d83e2e3f13..ec3cf0b4e6 100644 --- a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx @@ -19,7 +19,7 @@ describe('AvatarWithChannelDetail', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'alice' }); // Created locally (e.g. a "new conversation" flow), so the server does not have it yet. - channel = client.channelManager.ensure({ id: 'local', type: 'messaging' }); + channel = client.channel('messaging', 'local'); }); const renderAvatar = () => diff --git a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx index 4097c047bf..9fc9bcc2ac 100644 --- a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx +++ b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx @@ -212,7 +212,7 @@ describe('ChatView built-in workspace layout empty state', () => { initialState: { activeView, availableSlots: ['slot1', 'slot2'] }, }); if (bindChannel) { - const channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + const channel = client.channel('messaging', 'general'); layoutController.bind( 'slot1', createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), diff --git a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx index 57803f192f..10d4e19b85 100644 --- a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx +++ b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx @@ -24,7 +24,7 @@ describe('DisposedChannelRelease', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'ann' }); - channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + channel = client.channel('messaging', 'general'); layoutController = new LayoutController({ initialState: { availableSlots: ['slot1'] }, }); @@ -90,7 +90,7 @@ describe('DisposedChannelRelease', () => { }); it('leaves a slot that shows another channel', () => { - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); renderInChatView(); act(() => { diff --git a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx index 5b6d9d7c37..bbfd86d7a4 100644 --- a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx +++ b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx @@ -15,8 +15,8 @@ describe('SupersededChannelSwap', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'ann' }); - previous = client.channelManager.ensure({ id: 'previous', type: 'messaging' }); - successor = client.channelManager.ensure({ id: 'successor', type: 'messaging' }); + previous = client.channel('messaging', 'previous'); + successor = client.channel('messaging', 'successor'); layoutController = new LayoutController({ initialState: { availableSlots: ['slot1'] }, }); @@ -43,9 +43,8 @@ describe('SupersededChannelSwap', () => { ); it('binds the slot again under the real cid once a channel created from members gets it', () => { - const created = client.channelManager.ensure({ - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, - type: 'messaging', + const created = client.channel('messaging', { + members: [{ user_id: 'ann' }, { user_id: 'bob' }], }); const temporaryCid = created.cid; layoutController.bind( @@ -109,7 +108,7 @@ describe('SupersededChannelSwap', () => { }); it('keeps a binding the slot got after its channel was superseded', () => { - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); renderSwap(); // The slot is bound to another channel before the swap's effect runs. @@ -125,11 +124,10 @@ describe('SupersededChannelSwap', () => { }); it('keeps a binding the slot got before its channel took its real cid', () => { - const created = client.channelManager.ensure({ - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, - type: 'messaging', + const created = client.channel('messaging', { + members: [{ user_id: 'ann' }, { user_id: 'bob' }], }); - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); layoutController.bind( 'slot1', createChatViewSlotBinding({ key: created.cid, kind: 'channel', source: created }),