From 50b6c9fac84d2b89144b7ef476e72a6e4b887251 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 10:36:28 +0100 Subject: [PATCH 1/4] feat: add useOpenFirstChannel and createWorkspaceNavigation useOpenFirstChannel(paginator) restores v14's setActiveChannelOnMount default: it opens the paginator's first channel when its first page loads, only while no channel is open, so a remount (a ChatView view switch unmounts the list) does not replace the channel the user picked. createWorkspaceNavigation({ channel, thread, setChannel, setThread }) builds a complete WorkspaceNavigation for apps without ChatView (one channel panel and one thread panel), so list rows mark themselves active and ThreadHeader renders its close button without the integrator knowing every member. The v14 -> v15 guide's section 4 and 6 snippets use them. --- ai-docs/ai-migration-v14-v15.md | 71 +++---- .../__tests__/useOpenFirstChannel.test.tsx | 184 ++++++++++++++++++ src/components/ChannelList/hooks/index.ts | 1 + .../ChannelList/hooks/useOpenFirstChannel.ts | 50 +++++ .../createWorkspaceNavigation.test.ts | 141 ++++++++++++++ src/context/createWorkspaceNavigation.ts | 73 +++++++ src/context/index.ts | 1 + 7 files changed, 472 insertions(+), 49 deletions(-) create mode 100644 src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx create mode 100644 src/components/ChannelList/hooks/useOpenFirstChannel.ts create mode 100644 src/context/__tests__/createWorkspaceNavigation.test.ts create mode 100644 src/context/createWorkspaceNavigation.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 79aadb5873..7dacc2019f 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -536,7 +536,7 @@ Nothing to wire. `ChatView` provides the navigation and binds what is opened int ### Without `ChatView` -Hold what is open in your own state and provide `WorkspaceNavigationProvider` with an object built from `defaultWorkspaceNavigation`. Override at least `openChannel`, `isChannelActive` and `openChannels`, plus `openThread` / `closeThread` / `openThreads` / `isThreadActive` if you render threads. +Hold what is open in your own state and provide `WorkspaceNavigationProvider` with the navigation `createWorkspaceNavigation` (root export) builds from it. It models one channel panel with one thread panel beside it and derives every member the SDK reads, so list rows are marked active and the thread header's close button renders. Spread its result to override members. A layout with more panels needs its own `WorkspaceNavigation`, built from `defaultWorkspaceNavigation`. ```tsx // v14 @@ -560,19 +560,18 @@ import { ChannelPaginator, type SortParamRequest, type StreamChat, - Thread as StreamThread, + type Thread as StreamThread, } from 'stream-chat'; import { Channel, ChannelList, ChannelPlaceholder, Chat, - defaultWorkspaceNavigation, + createWorkspaceNavigation, MessageComposer, MessageList, Thread, ThreadHeader, - type WorkspaceNavigation, WorkspaceNavigationProvider, } from 'stream-chat-react'; @@ -594,29 +593,15 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) }; }, [client, paginator]); - const navigation = useMemo( - () => ({ - ...defaultWorkspaceNavigation, - closeThread: () => setActiveThread(undefined), - isChannelActive: (cid) => !!cid && cid === activeChannel?.cid, - isThreadActive: (threadId) => !!threadId && threadId === activeThread?.id, - openChannel: (channel) => { - setActiveChannel(channel); - setActiveThread(undefined); - }, - openChannels: activeChannel ? [activeChannel] : [], - openThread: (target) => - setActiveThread( - target instanceof StreamThread - ? target - : client.threads.ensure({ - channel: target.channel, - parentMessage: target.message, - }), - ), - openThreads: activeThread ? [activeThread] : [], - }), - [activeChannel, activeThread, client], + const navigation = useMemo( + () => + createWorkspaceNavigation({ + channel: activeChannel, + setChannel: setActiveChannel, + setThread: setActiveThread, + thread: activeThread, + }), + [activeChannel, activeThread], ); return ( @@ -644,7 +629,9 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) }; ``` -- An `openThread` target is either a `Thread` or `{ channel, message }`. `client.threads.ensure({ channel, parentMessage })` turns the latter into a registered `Thread`. +- An `openThread` target is either a `Thread` or `{ channel, message }`. `createWorkspaceNavigation` turns the latter into a registered `Thread` with `client.threads.ensure({ channel, parentMessage })`; a navigation of your own has to do the same. +- Opening a different channel closes the thread, and closing the channel closes both. `canGoBack` is always `false`: nothing is stacked. +- A navigation built by hand from `defaultWorkspaceNavigation` inherits `isThreadDismissable: () => false`, which hides `ThreadHeader`'s close button. Override it too. - Instead of a navigation provider, `ChannelListItem` and the search result items take an `onSelect` prop that replaces their selection handler. That covers selection only: thread opening and the header buttons still read the navigation. - **Drop held channels on `disconnectUser()`.** It disposes the user's channels, and a disposed channel throws `Channel is pending disposal and cannot be used` from `getClient()`, so rendering it (as `activeChannel` above, after a logout or a user switch) throws. Clear the state that holds channels and threads when you disconnect, or re-`ensure` them on the new connection. This also shows up in development when Fast Refresh keeps a stale channel in state across a reconnect. @@ -946,33 +933,19 @@ export const createPaginator = (client: StreamChat, userId: string) => These hit apps that never passed the props, because they were v14 defaults: -- **No channel opens on load.** `setActiveChannelOnMount` defaulted to `true`, so every v14 app opened the first channel. v15 has no default selection: nothing is open until you call `useWorkspaceNavigation().openChannel(channel)`. To keep the v14 behavior, open the paginator's first item once: +- **No channel opens on load.** `setActiveChannelOnMount` defaulted to `true`, so every v14 app opened the first channel. v15 has no default selection: nothing is open until you call `useWorkspaceNavigation().openChannel(channel)`. To keep the v14 behavior, call `useOpenFirstChannel(paginator)` (root export) where the list renders: ```tsx - import { useEffect, useRef } from 'react'; import type { ChannelPaginator } from 'stream-chat'; - import { useWorkspaceNavigation } from 'stream-chat-react'; - - export const useOpenFirstChannel = (paginator: ChannelPaginator) => { - const { openChannel } = useWorkspaceNavigation(); - const opened = useRef(false); - useEffect( - () => - paginator.state.subscribeWithSelector( - ({ items }) => ({ first: items?.[0] }), - ({ first }) => { - if (first && !opened.current) { - opened.current = true; - openChannel(first); - } - }, - ), - [paginator, openChannel], - ); + import { ChannelList, useOpenFirstChannel } from 'stream-chat-react'; + + export const Sidebar = ({ paginator }: { paginator: ChannelPaginator }) => { + useOpenFirstChannel(paginator); + return ; }; ``` - Call it below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. + It opens the first channel when the list's first page loads, only if no channel is open, so a remount (a `ChatView` view switch unmounts the list) does not replace the channel the user picked. A guard of your own in a `useRef` resets on every remount; check `openChannels` instead. Call it below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. - **Unfiltered channels no longer join the list.** `allowNewMessagesFromUnfilteredChannels` defaulted to `true`: a new message in a channel that was not in the list added it, whatever the list's filters. In v15 the paginator adds a channel only if it matches the paginator's own filters, and there is no opt-out. If channels used to appear this way, widen the filters to cover them. - **Infinite scroll, not "Load more".** `Paginator` defaulted to `LoadMorePaginator` (a button). The v15 `ChannelList` always scrolls infinitely, loading the next page near the end of the list. To keep a button, render your own list: diff --git a/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx b/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx new file mode 100644 index 0000000000..db458933a4 --- /dev/null +++ b/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx @@ -0,0 +1,184 @@ +import React, { useMemo, useState } from 'react'; +import { act, render } from '@testing-library/react'; +import type { Mock } from 'vitest'; +import { ChannelPaginator } from 'stream-chat'; +import type { + Channel as StreamChannel, + StreamChat, + Thread as StreamThread, +} from 'stream-chat'; + +import { getTestClientWithUser } from '../../../../mock-builders'; +import { createWorkspaceNavigation } from '../../../../context/createWorkspaceNavigation'; +import { + useWorkspaceNavigation, + WorkspaceNavigationProvider, +} from '../../../../context/WorkspaceNavigationContext'; +import { useOpenFirstChannel } from '../useOpenFirstChannel'; +import { Chat } from '../../../Chat'; +import { ChatView, useChatViewContext } from '../../../../plugins/SlotLayout'; + +import type { PropsWithChildren } from 'react'; +import type { WorkspaceNavigation } from '../../../../context/WorkspaceNavigationContext'; + +describe('useOpenFirstChannel', () => { + let client: StreamChat; + let paginator: ChannelPaginator; + let channelA: StreamChannel; + let channelB: StreamChannel; + let navigation: WorkspaceNavigation; + let openChannel: Mock<(channel: StreamChannel) => void>; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + paginator = new ChannelPaginator({ client, id: 'list' }); + channelA = client.channel('messaging', 'a'); + channelB = client.channel('messaging', 'b'); + openChannel = vi.fn(); + }); + + // Navigation held in React state, as an app without ChatView holds it, so `openChannels` + // reflects what the hook opened. `openChannel` is spied to count the opens. + const NavigationRoot = ({ children }: PropsWithChildren) => { + const [channel, setChannel] = useState(); + const [thread, setThread] = useState(); + navigation = useMemo(() => { + const base = createWorkspaceNavigation({ channel, setChannel, setThread, thread }); + return { + ...base, + openChannel: (next, options) => { + openChannel(next); + base.openChannel(next, options); + }, + }; + }, [channel, thread]); + return ( + + {children} + + ); + }; + + const Sidebar = () => { + useOpenFirstChannel(paginator); + return null; + }; + + const renderSidebar = () => { + const Root = ({ showSidebar }: { showSidebar: boolean }) => ( + {showSidebar && } + ); + const result = render(); + return { + mountSidebar: () => result.rerender(), + unmountSidebar: () => result.rerender(), + }; + }; + + const loadPage = (items: StreamChannel[] | undefined) => + act(() => { + paginator.state.partialNext({ items }); + }); + + it('opens the first channel once the first page loads', () => { + renderSidebar(); + expect(openChannel).not.toHaveBeenCalled(); + + loadPage([channelA, channelB]); + + expect(openChannel).toHaveBeenCalledTimes(1); + expect(openChannel).toHaveBeenCalledWith(channelA); + expect(navigation.openChannels).toEqual([channelA]); + }); + + it('opens the first channel of a page loaded before it mounted', () => { + loadPage([channelA, channelB]); + + renderSidebar(); + + expect(openChannel).toHaveBeenCalledWith(channelA); + }); + + it('does not re-open the first channel over the picked one when remounted', () => { + const { mountSidebar, unmountSidebar } = renderSidebar(); + loadPage([channelA, channelB]); + act(() => navigation.openChannel(channelB)); + openChannel.mockClear(); + + unmountSidebar(); + mountSidebar(); + + expect(openChannel).not.toHaveBeenCalled(); + expect(navigation.openChannels).toEqual([channelB]); + }); + + it('does not open a channel that moves to the top of a loaded list', () => { + renderSidebar(); + loadPage([channelA]); + act(() => navigation.closeChannel(channelA.cid)); + openChannel.mockClear(); + + loadPage([channelB, channelA]); + + expect(openChannel).not.toHaveBeenCalled(); + expect(navigation.openChannels).toEqual([]); + }); + + it('opens nothing for an empty first page, nor for a channel that arrives later', () => { + renderSidebar(); + + loadPage([]); + loadPage([channelA]); + + expect(openChannel).not.toHaveBeenCalled(); + }); + + it('opens the first channel again when the list reloads after a reset', () => { + renderSidebar(); + loadPage([channelA]); + act(() => navigation.closeChannel(channelA.cid)); + openChannel.mockClear(); + + loadPage(undefined); + loadPage([channelB]); + + expect(openChannel).toHaveBeenCalledWith(channelB); + }); + + it('does not throw without a navigation provider', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + render(); + + expect(() => loadPage([channelA])).not.toThrow(); + warn.mockRestore(); + }); + + it('does not re-open the first channel when a ChatView view switch remounts it', () => { + let setActiveView: ReturnType['setActiveView'] = () => + undefined; + // Like a channel list, rendered only in the channels view. + const ChannelsViewSidebar = () => { + const chatView = useChatViewContext(); + setActiveView = chatView.setActiveView; + navigation = useWorkspaceNavigation(); + return chatView.activeView === 'channels' ? : null; + }; + render( + + + + + , + ); + + loadPage([channelA, channelB]); + expect(navigation.openChannels).toEqual([channelA]); + act(() => navigation.openChannel(channelB)); + expect(navigation.openChannels).toEqual([channelB]); + + act(() => setActiveView('threads')); + act(() => setActiveView('channels')); + + expect(navigation.openChannels).toEqual([channelB]); + }); +}); diff --git a/src/components/ChannelList/hooks/index.ts b/src/components/ChannelList/hooks/index.ts index c125255483..65d947b8d6 100644 --- a/src/components/ChannelList/hooks/index.ts +++ b/src/components/ChannelList/hooks/index.ts @@ -2,3 +2,4 @@ export * from './useChannelListKeyboardNavigation'; export * from './useChannelMembershipState'; export * from './useChannelMembersState'; export * from './useChannelPaginatorState'; +export * from './useOpenFirstChannel'; diff --git a/src/components/ChannelList/hooks/useOpenFirstChannel.ts b/src/components/ChannelList/hooks/useOpenFirstChannel.ts new file mode 100644 index 0000000000..0780d3c443 --- /dev/null +++ b/src/components/ChannelList/hooks/useOpenFirstChannel.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react'; +import type { ChannelPaginator, ChannelPaginatorState } from 'stream-chat'; + +import { useWorkspaceNavigation } from '../../../context/WorkspaceNavigationContext'; + +const firstChannelSelector = ({ items }: ChannelPaginatorState) => ({ + first: items?.[0], + // `items === undefined` means "never queried": the initial state, and where the paginator + // returns when its data is discarded (e.g. by `client.disconnectUser`). + isUnloaded: items === undefined, +}); + +/** + * Opens the first channel of `paginator` through workspace navigation once its first page loads, + * unless a channel is already open. Restores v14's `ChannelList` `setActiveChannelOnMount` default. + * + * Whether to open is decided by the navigation's `openChannels`, not by state of this hook, so a + * remount (e.g. a `ChatView` view switch that unmounts the list) does not re-open the first channel + * over the one the user picked. Each load counts once: a channel that later moves to the top of + * the list is not opened, but a list reset by `client.disconnectUser` opens its first channel + * again when it reloads. Call it below the navigation provider (`ChatView`, or your own + * `WorkspaceNavigationProvider`). + */ +export const useOpenFirstChannel = (paginator: ChannelPaginator) => { + const { openChannel, openChannels } = useWorkspaceNavigation(); + // Read at load time: the navigation changes with every open and close, and resubscribing on + // each change would count an already loaded page as a new load. + const navigationRef = useRef({ hasOpenChannel: openChannels.length > 0, openChannel }); + + useEffect(() => { + navigationRef.current = { hasOpenChannel: openChannels.length > 0, openChannel }; + }); + + useEffect(() => { + let awaitingLoad = true; + return paginator.state.subscribeWithSelector( + firstChannelSelector, + ({ first, isUnloaded }) => { + if (isUnloaded) { + awaitingLoad = true; + return; + } + if (!awaitingLoad) return; + awaitingLoad = false; + const { hasOpenChannel, openChannel } = navigationRef.current; + if (first && !hasOpenChannel) openChannel(first); + }, + ); + }, [paginator]); +}; diff --git a/src/context/__tests__/createWorkspaceNavigation.test.ts b/src/context/__tests__/createWorkspaceNavigation.test.ts new file mode 100644 index 0000000000..82b2265ca4 --- /dev/null +++ b/src/context/__tests__/createWorkspaceNavigation.test.ts @@ -0,0 +1,141 @@ +import type { Channel as StreamChannel, StreamChat } from 'stream-chat'; + +import { generateMessage, getTestClientWithUser } from '../../mock-builders'; +import { createWorkspaceNavigation } from '../createWorkspaceNavigation'; + +describe('createWorkspaceNavigation', () => { + let client: StreamChat; + let channelA: StreamChannel; + let channelB: StreamChannel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + channelA = client.channel('messaging', 'a'); + channelB = client.channel('messaging', 'b'); + }); + + const ensureThread = (channel: StreamChannel, id: string) => + client.threads.ensure({ channel, parentMessage: generateMessage({ id }) }); + + const setup = ( + state: Parameters[0] | object = {}, + ) => { + const setChannel = vi.fn(); + const setThread = vi.fn(); + const navigation = createWorkspaceNavigation({ setChannel, setThread, ...state }); + return { navigation, setChannel, setThread }; + }; + + it('reports nothing open without a channel or thread', () => { + const { navigation } = setup(); + + expect(navigation.openChannels).toEqual([]); + expect(navigation.openThreads).toEqual([]); + expect(navigation.isChannelActive('messaging:a')).toBe(false); + expect(navigation.isThreadActive('t1')).toBe(false); + expect(navigation.isThreadDismissable()).toBe(false); + expect(navigation.isThreadsView).toBe(false); + }); + + it('derives the open entities and the active predicates from the state', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation } = setup({ channel: channelA, thread }); + + expect(navigation.openChannels).toEqual([channelA]); + expect(navigation.openThreads).toEqual([thread]); + expect(navigation.isChannelActive(channelA.cid)).toBe(true); + expect(navigation.isChannelActive(channelB.cid)).toBe(false); + expect(navigation.isChannelActive(undefined)).toBe(false); + expect(navigation.isThreadActive(thread.id)).toBe(true); + expect(navigation.isThreadActive('other')).toBe(false); + expect(navigation.isThreadActive(undefined)).toBe(false); + }); + + it('makes the open thread dismissable and the channel panel not', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation } = setup({ channel: channelA, thread }); + + expect(navigation.isThreadDismissable(thread.id)).toBe(true); + expect(navigation.isThreadDismissable()).toBe(true); + expect(navigation.isThreadDismissable('other')).toBe(false); + expect(navigation.isChannelDismissable(channelA.cid)).toBe(false); + }); + + it('has nothing to go back to', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); + + expect(navigation.canGoBack(channelA.cid)).toBe(false); + expect(navigation.canGoBack(thread.id)).toBe(false); + navigation.goBack(thread.id); + expect(setChannel).not.toHaveBeenCalled(); + expect(setThread).not.toHaveBeenCalled(); + }); + + it('closes the thread when opening a different channel', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); + + navigation.openChannel(channelB); + + expect(setChannel).toHaveBeenCalledWith(channelB); + expect(setThread).toHaveBeenCalledWith(undefined); + }); + + it('keeps the thread when re-opening the open channel', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); + + navigation.openChannel(channelA); + + expect(setChannel).toHaveBeenCalledWith(channelA); + expect(setThread).not.toHaveBeenCalled(); + }); + + it('opens a thread instance as is', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setThread } = setup({ channel: channelA }); + + navigation.openThread(thread); + + expect(setThread).toHaveBeenCalledWith(thread); + }); + + it('resolves a { channel, message } target through client.threads.ensure', () => { + const message = generateMessage({ id: 'parent-2' }); + const ensure = vi.spyOn(client.threads, 'ensure'); + const { navigation, setThread } = setup({ channel: channelA }); + + navigation.openThread({ channel: channelA, message }); + + expect(ensure).toHaveBeenCalledWith({ channel: channelA, parentMessage: message }); + expect(setThread).toHaveBeenCalledWith(ensure.mock.results[0].value); + }); + + it('closes the open thread by ID, or without one', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setThread } = setup({ channel: channelA, thread }); + + navigation.closeThread('other'); + expect(setThread).not.toHaveBeenCalled(); + + navigation.closeThread(thread.id); + expect(setThread).toHaveBeenLastCalledWith(undefined); + + setThread.mockClear(); + navigation.closeThread(); + expect(setThread).toHaveBeenCalledWith(undefined); + }); + + it('closes the open channel with its thread, and ignores other channels', () => { + const thread = ensureThread(channelA, 'parent-1'); + const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); + + navigation.closeChannel(channelB.cid); + expect(setChannel).not.toHaveBeenCalled(); + + navigation.closeChannel(channelA.cid); + expect(setChannel).toHaveBeenCalledWith(undefined); + expect(setThread).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/context/createWorkspaceNavigation.ts b/src/context/createWorkspaceNavigation.ts new file mode 100644 index 0000000000..df35d8c1fc --- /dev/null +++ b/src/context/createWorkspaceNavigation.ts @@ -0,0 +1,73 @@ +import { Thread as StreamThreadClass } from 'stream-chat'; + +import { defaultWorkspaceNavigation } from './WorkspaceNavigationContext'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { WorkspaceNavigation } from './WorkspaceNavigationContext'; + +/** State and setters for {@link createWorkspaceNavigation}. */ +export type CreateWorkspaceNavigationParams = { + /** Shows a channel in the channel panel, or empties it with `undefined`. */ + setChannel: (channel: StreamChannel | undefined) => void; + /** Shows a thread in the thread panel, or empties it with `undefined`. */ + setThread: (thread: StreamThread | undefined) => void; + /** The channel shown in the channel panel, if any. */ + channel?: StreamChannel; + /** The thread shown in the thread panel, if any. */ + thread?: StreamThread; +}; + +/** + * Builds a complete {@link WorkspaceNavigation} for a layout without `ChatView`: one channel panel + * and one thread panel beside it, held in the integrator's own state. Provide the result with + * `WorkspaceNavigationProvider`, memoized on `channel` and `thread`. + * + * - Opening a different channel closes the thread; closing the channel closes both. + * - `{ channel, message }` thread targets resolve with `client.threads.ensure`. + * - The thread panel is dismissable, so `ThreadHeader` renders its close button; the channel panel + * is the primary one and is not. + * - Nothing is stacked, so `canGoBack` is always false. `isThreadsView` is false. + * + * Navigation options (`additive`, `layer`, `panel`) are ignored. Override members by spreading the + * result, e.g. to also close a side panel when a thread opens. + */ +export const createWorkspaceNavigation = ({ + channel, + setChannel, + setThread, + thread, +}: CreateWorkspaceNavigationParams): WorkspaceNavigation => { + const isOpenThread = (threadId?: string) => + !!thread && (threadId === undefined || threadId === thread.id); + + return { + ...defaultWorkspaceNavigation, + closeChannel: (cid) => { + if (!channel || (cid !== undefined && cid !== channel.cid)) return; + setChannel(undefined); + setThread(undefined); + }, + closeThread: (threadId) => { + if (isOpenThread(threadId)) setThread(undefined); + }, + isChannelActive: (cid) => cid !== undefined && cid === channel?.cid, + isThreadActive: (threadId) => threadId !== undefined && threadId === thread?.id, + isThreadDismissable: isOpenThread, + openChannel: (nextChannel) => { + if (nextChannel !== channel) setThread(undefined); + setChannel(nextChannel); + }, + openChannels: channel ? [channel] : [], + openThread: (target) => { + setThread( + target instanceof StreamThreadClass + ? target + : target.channel.getClient().threads.ensure({ + channel: target.channel, + parentMessage: target.message, + }), + ); + }, + openThreads: thread ? [thread] : [], + }; +}; diff --git a/src/context/index.ts b/src/context/index.ts index e246f71b41..53e4a5e477 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,6 +4,7 @@ export * from './ChannelListContext'; export * from './ChannelInstanceContext'; export * from './ChatContext'; export * from './ComponentContext'; +export * from './createWorkspaceNavigation'; export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; From 192baeb7519aeb853c7ac0e517b8d6252af0752d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:05:44 +0100 Subject: [PATCH 2/4] refactor: replace useOpenFirstChannel with a guide snippet Opening the first channel on load is the app's choice, not an SDK default, so it stays out of the public API. The guide's section 6 snippet is the corrected version instead: it checks openChannels rather than a useRef, so a ChatView view switch does not re-open the first channel over the one the user picked. --- ai-docs/ai-migration-v14-v15.md | 41 +++- .../__tests__/useOpenFirstChannel.test.tsx | 184 ------------------ src/components/ChannelList/hooks/index.ts | 1 - .../ChannelList/hooks/useOpenFirstChannel.ts | 50 ----- 4 files changed, 35 insertions(+), 241 deletions(-) delete mode 100644 src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx delete mode 100644 src/components/ChannelList/hooks/useOpenFirstChannel.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 7dacc2019f..33a5d3006a 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -933,19 +933,48 @@ export const createPaginator = (client: StreamChat, userId: string) => These hit apps that never passed the props, because they were v14 defaults: -- **No channel opens on load.** `setActiveChannelOnMount` defaulted to `true`, so every v14 app opened the first channel. v15 has no default selection: nothing is open until you call `useWorkspaceNavigation().openChannel(channel)`. To keep the v14 behavior, call `useOpenFirstChannel(paginator)` (root export) where the list renders: +- **No channel opens on load.** `setActiveChannelOnMount` defaulted to `true`, so every v14 app opened the first channel. v15 has no default selection: nothing is open until you call `useWorkspaceNavigation().openChannel(channel)`. To keep the v14 behavior, open the paginator's first item when its first page loads: ```tsx + import { useEffect, useRef } from 'react'; import type { ChannelPaginator } from 'stream-chat'; - import { ChannelList, useOpenFirstChannel } from 'stream-chat-react'; + import { useWorkspaceNavigation } from 'stream-chat-react'; - export const Sidebar = ({ paginator }: { paginator: ChannelPaginator }) => { - useOpenFirstChannel(paginator); - return ; + export const useOpenFirstChannel = (paginator: ChannelPaginator) => { + const { openChannel, openChannels } = useWorkspaceNavigation(); + // Read when a page loads. The navigation changes with every open and close, and resubscribing + // on it would replay a page that already loaded. + const navigation = useRef({ hasOpenChannel: openChannels.length > 0, openChannel }); + useEffect(() => { + navigation.current = { hasOpenChannel: openChannels.length > 0, openChannel }; + }); + + useEffect(() => { + // Each load counts once, so a channel that later moves to the top is not opened. + // `items === undefined` means "never queried", where `client.disconnectUser()` resets the + // list, so the next user's first load counts again. + let awaitingLoad = true; + return paginator.state.subscribeWithSelector( + ({ items }) => ({ first: items?.[0], isUnloaded: items === undefined }), + ({ first, isUnloaded }) => { + if (isUnloaded) { + awaitingLoad = true; + return; + } + if (!awaitingLoad) return; + awaitingLoad = false; + // Check what is open, not a flag of this hook: a remount (a `ChatView` view switch + // unmounts the list) must not replace the channel the user picked. + if (first && !navigation.current.hasOpenChannel) { + navigation.current.openChannel(first); + } + }, + ); + }, [paginator]); }; ``` - It opens the first channel when the list's first page loads, only if no channel is open, so a remount (a `ChatView` view switch unmounts the list) does not replace the channel the user picked. A guard of your own in a `useRef` resets on every remount; check `openChannels` instead. Call it below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. + Call it where the list renders and below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. Mounted where `ChatView`'s threads view renders too, it would open a channel there, since that view has no open channels. - **Unfiltered channels no longer join the list.** `allowNewMessagesFromUnfilteredChannels` defaulted to `true`: a new message in a channel that was not in the list added it, whatever the list's filters. In v15 the paginator adds a channel only if it matches the paginator's own filters, and there is no opt-out. If channels used to appear this way, widen the filters to cover them. - **Infinite scroll, not "Load more".** `Paginator` defaulted to `LoadMorePaginator` (a button). The v15 `ChannelList` always scrolls infinitely, loading the next page near the end of the list. To keep a button, render your own list: diff --git a/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx b/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx deleted file mode 100644 index db458933a4..0000000000 --- a/src/components/ChannelList/hooks/__tests__/useOpenFirstChannel.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { act, render } from '@testing-library/react'; -import type { Mock } from 'vitest'; -import { ChannelPaginator } from 'stream-chat'; -import type { - Channel as StreamChannel, - StreamChat, - Thread as StreamThread, -} from 'stream-chat'; - -import { getTestClientWithUser } from '../../../../mock-builders'; -import { createWorkspaceNavigation } from '../../../../context/createWorkspaceNavigation'; -import { - useWorkspaceNavigation, - WorkspaceNavigationProvider, -} from '../../../../context/WorkspaceNavigationContext'; -import { useOpenFirstChannel } from '../useOpenFirstChannel'; -import { Chat } from '../../../Chat'; -import { ChatView, useChatViewContext } from '../../../../plugins/SlotLayout'; - -import type { PropsWithChildren } from 'react'; -import type { WorkspaceNavigation } from '../../../../context/WorkspaceNavigationContext'; - -describe('useOpenFirstChannel', () => { - let client: StreamChat; - let paginator: ChannelPaginator; - let channelA: StreamChannel; - let channelB: StreamChannel; - let navigation: WorkspaceNavigation; - let openChannel: Mock<(channel: StreamChannel) => void>; - - beforeEach(async () => { - client = await getTestClientWithUser({ id: 'ann' }); - paginator = new ChannelPaginator({ client, id: 'list' }); - channelA = client.channel('messaging', 'a'); - channelB = client.channel('messaging', 'b'); - openChannel = vi.fn(); - }); - - // Navigation held in React state, as an app without ChatView holds it, so `openChannels` - // reflects what the hook opened. `openChannel` is spied to count the opens. - const NavigationRoot = ({ children }: PropsWithChildren) => { - const [channel, setChannel] = useState(); - const [thread, setThread] = useState(); - navigation = useMemo(() => { - const base = createWorkspaceNavigation({ channel, setChannel, setThread, thread }); - return { - ...base, - openChannel: (next, options) => { - openChannel(next); - base.openChannel(next, options); - }, - }; - }, [channel, thread]); - return ( - - {children} - - ); - }; - - const Sidebar = () => { - useOpenFirstChannel(paginator); - return null; - }; - - const renderSidebar = () => { - const Root = ({ showSidebar }: { showSidebar: boolean }) => ( - {showSidebar && } - ); - const result = render(); - return { - mountSidebar: () => result.rerender(), - unmountSidebar: () => result.rerender(), - }; - }; - - const loadPage = (items: StreamChannel[] | undefined) => - act(() => { - paginator.state.partialNext({ items }); - }); - - it('opens the first channel once the first page loads', () => { - renderSidebar(); - expect(openChannel).not.toHaveBeenCalled(); - - loadPage([channelA, channelB]); - - expect(openChannel).toHaveBeenCalledTimes(1); - expect(openChannel).toHaveBeenCalledWith(channelA); - expect(navigation.openChannels).toEqual([channelA]); - }); - - it('opens the first channel of a page loaded before it mounted', () => { - loadPage([channelA, channelB]); - - renderSidebar(); - - expect(openChannel).toHaveBeenCalledWith(channelA); - }); - - it('does not re-open the first channel over the picked one when remounted', () => { - const { mountSidebar, unmountSidebar } = renderSidebar(); - loadPage([channelA, channelB]); - act(() => navigation.openChannel(channelB)); - openChannel.mockClear(); - - unmountSidebar(); - mountSidebar(); - - expect(openChannel).not.toHaveBeenCalled(); - expect(navigation.openChannels).toEqual([channelB]); - }); - - it('does not open a channel that moves to the top of a loaded list', () => { - renderSidebar(); - loadPage([channelA]); - act(() => navigation.closeChannel(channelA.cid)); - openChannel.mockClear(); - - loadPage([channelB, channelA]); - - expect(openChannel).not.toHaveBeenCalled(); - expect(navigation.openChannels).toEqual([]); - }); - - it('opens nothing for an empty first page, nor for a channel that arrives later', () => { - renderSidebar(); - - loadPage([]); - loadPage([channelA]); - - expect(openChannel).not.toHaveBeenCalled(); - }); - - it('opens the first channel again when the list reloads after a reset', () => { - renderSidebar(); - loadPage([channelA]); - act(() => navigation.closeChannel(channelA.cid)); - openChannel.mockClear(); - - loadPage(undefined); - loadPage([channelB]); - - expect(openChannel).toHaveBeenCalledWith(channelB); - }); - - it('does not throw without a navigation provider', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - render(); - - expect(() => loadPage([channelA])).not.toThrow(); - warn.mockRestore(); - }); - - it('does not re-open the first channel when a ChatView view switch remounts it', () => { - let setActiveView: ReturnType['setActiveView'] = () => - undefined; - // Like a channel list, rendered only in the channels view. - const ChannelsViewSidebar = () => { - const chatView = useChatViewContext(); - setActiveView = chatView.setActiveView; - navigation = useWorkspaceNavigation(); - return chatView.activeView === 'channels' ? : null; - }; - render( - - - - - , - ); - - loadPage([channelA, channelB]); - expect(navigation.openChannels).toEqual([channelA]); - act(() => navigation.openChannel(channelB)); - expect(navigation.openChannels).toEqual([channelB]); - - act(() => setActiveView('threads')); - act(() => setActiveView('channels')); - - expect(navigation.openChannels).toEqual([channelB]); - }); -}); diff --git a/src/components/ChannelList/hooks/index.ts b/src/components/ChannelList/hooks/index.ts index 65d947b8d6..c125255483 100644 --- a/src/components/ChannelList/hooks/index.ts +++ b/src/components/ChannelList/hooks/index.ts @@ -2,4 +2,3 @@ export * from './useChannelListKeyboardNavigation'; export * from './useChannelMembershipState'; export * from './useChannelMembersState'; export * from './useChannelPaginatorState'; -export * from './useOpenFirstChannel'; diff --git a/src/components/ChannelList/hooks/useOpenFirstChannel.ts b/src/components/ChannelList/hooks/useOpenFirstChannel.ts deleted file mode 100644 index 0780d3c443..0000000000 --- a/src/components/ChannelList/hooks/useOpenFirstChannel.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { ChannelPaginator, ChannelPaginatorState } from 'stream-chat'; - -import { useWorkspaceNavigation } from '../../../context/WorkspaceNavigationContext'; - -const firstChannelSelector = ({ items }: ChannelPaginatorState) => ({ - first: items?.[0], - // `items === undefined` means "never queried": the initial state, and where the paginator - // returns when its data is discarded (e.g. by `client.disconnectUser`). - isUnloaded: items === undefined, -}); - -/** - * Opens the first channel of `paginator` through workspace navigation once its first page loads, - * unless a channel is already open. Restores v14's `ChannelList` `setActiveChannelOnMount` default. - * - * Whether to open is decided by the navigation's `openChannels`, not by state of this hook, so a - * remount (e.g. a `ChatView` view switch that unmounts the list) does not re-open the first channel - * over the one the user picked. Each load counts once: a channel that later moves to the top of - * the list is not opened, but a list reset by `client.disconnectUser` opens its first channel - * again when it reloads. Call it below the navigation provider (`ChatView`, or your own - * `WorkspaceNavigationProvider`). - */ -export const useOpenFirstChannel = (paginator: ChannelPaginator) => { - const { openChannel, openChannels } = useWorkspaceNavigation(); - // Read at load time: the navigation changes with every open and close, and resubscribing on - // each change would count an already loaded page as a new load. - const navigationRef = useRef({ hasOpenChannel: openChannels.length > 0, openChannel }); - - useEffect(() => { - navigationRef.current = { hasOpenChannel: openChannels.length > 0, openChannel }; - }); - - useEffect(() => { - let awaitingLoad = true; - return paginator.state.subscribeWithSelector( - firstChannelSelector, - ({ first, isUnloaded }) => { - if (isUnloaded) { - awaitingLoad = true; - return; - } - if (!awaitingLoad) return; - awaitingLoad = false; - const { hasOpenChannel, openChannel } = navigationRef.current; - if (first && !hasOpenChannel) openChannel(first); - }, - ); - }, [paginator]); -}; From a2e8b101f1a8198c532aa2725faaab1f9c990412 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:23:01 +0100 Subject: [PATCH 3/4] refactor: drop createWorkspaceNavigation, keep the guide fix only The guide's section 4 (from #3356) already lists every navigation member to override, so the builder would add public API for the minority path without ChatView. Section 4 is back to master's text; the section 6 open-first-channel snippet fix stays. --- ai-docs/ai-migration-v14-v15.md | 46 ++++-- .../createWorkspaceNavigation.test.ts | 141 ------------------ src/context/createWorkspaceNavigation.ts | 73 --------- src/context/index.ts | 1 - 4 files changed, 30 insertions(+), 231 deletions(-) delete mode 100644 src/context/__tests__/createWorkspaceNavigation.test.ts delete mode 100644 src/context/createWorkspaceNavigation.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 622bfa4aed..fa65d302a0 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -542,9 +542,7 @@ Nothing to wire. `ChatView` provides the navigation and binds what is opened int ### Without `ChatView` -Hold what is open in your own state and provide `WorkspaceNavigationProvider` with the navigation `createWorkspaceNavigation` (root export) builds from it. It models one channel panel with one thread panel beside it and derives every member the SDK reads, so list rows are marked active and the thread header's close button renders. Spread its result to override members. - -A layout with more panels needs a navigation of its own, built from `defaultWorkspaceNavigation`. Every member you do not override keeps its inert default (`false`, a no-op, an empty list). Override: +Hold what is open in your own state and provide `WorkspaceNavigationProvider` with an object built from `defaultWorkspaceNavigation`. Every member you do not override keeps its inert default (`false`, a no-op, an empty list). Override: - always: `openChannel`, `isChannelActive` and `openChannels`; - if you render threads: `openThread`, `closeThread`, `openThreads`, `isThreadActive`, and `isThreadDismissable` if you render the default `ThreadHeader`, whose close button shows only when it returns `true`; @@ -572,18 +570,19 @@ import { ChannelPaginator, type SortParamRequest, type StreamChat, - type Thread as StreamThread, + Thread as StreamThread, } from 'stream-chat'; import { Channel, ChannelList, ChannelPlaceholder, Chat, - createWorkspaceNavigation, + defaultWorkspaceNavigation, MessageComposer, MessageList, Thread, ThreadHeader, + type WorkspaceNavigation, WorkspaceNavigationProvider, } from 'stream-chat-react'; @@ -605,15 +604,31 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) }; }, [client, paginator]); - const navigation = useMemo( - () => - createWorkspaceNavigation({ - channel: activeChannel, - setChannel: setActiveChannel, - setThread: setActiveThread, - thread: activeThread, - }), - [activeChannel, activeThread], + const navigation = useMemo( + () => ({ + ...defaultWorkspaceNavigation, + closeThread: () => setActiveThread(undefined), + isChannelActive: (cid) => !!cid && cid === activeChannel?.cid, + isThreadActive: (threadId) => !!threadId && threadId === activeThread?.id, + // the default ThreadHeader renders its close button only when this returns true + isThreadDismissable: (threadId) => !!threadId && threadId === activeThread?.id, + openChannel: (channel) => { + setActiveChannel(channel); + setActiveThread(undefined); + }, + openChannels: activeChannel ? [activeChannel] : [], + openThread: (target) => + setActiveThread( + target instanceof StreamThread + ? target + : client.threads.ensure({ + channel: target.channel, + parentMessage: target.message, + }), + ), + openThreads: activeThread ? [activeThread] : [], + }), + [activeChannel, activeThread, client], ); return ( @@ -641,8 +656,7 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) }; ``` -- An `openThread` target is either a `Thread` or `{ channel, message }`. `createWorkspaceNavigation` turns the latter into a registered `Thread` with `client.threads.ensure({ channel, parentMessage })`; a navigation of your own has to do the same. -- Opening a different channel closes the thread, and closing the channel closes both. `canGoBack` is always `false`: nothing is stacked. +- An `openThread` target is either a `Thread` or `{ channel, message }`. `client.threads.ensure({ channel, parentMessage })` turns the latter into a registered `Thread`. - Instead of a navigation provider, `ChannelListItem` and the search result items take an `onSelect` prop that replaces their selection handler. That covers selection only: thread opening and the header buttons still read the navigation. - **Drop held channels on `disconnectUser()`.** It disposes the user's channels, and a disposed channel throws `Channel is pending disposal and cannot be used` from `getClient()`, so rendering it (as `activeChannel` above, after a logout or a user switch) throws. Clear the state that holds channels and threads when you disconnect, or re-`ensure` them on the new connection. This also shows up in development when Fast Refresh keeps a stale channel in state across a reconnect. diff --git a/src/context/__tests__/createWorkspaceNavigation.test.ts b/src/context/__tests__/createWorkspaceNavigation.test.ts deleted file mode 100644 index 82b2265ca4..0000000000 --- a/src/context/__tests__/createWorkspaceNavigation.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { Channel as StreamChannel, StreamChat } from 'stream-chat'; - -import { generateMessage, getTestClientWithUser } from '../../mock-builders'; -import { createWorkspaceNavigation } from '../createWorkspaceNavigation'; - -describe('createWorkspaceNavigation', () => { - let client: StreamChat; - let channelA: StreamChannel; - let channelB: StreamChannel; - - beforeEach(async () => { - client = await getTestClientWithUser({ id: 'ann' }); - channelA = client.channel('messaging', 'a'); - channelB = client.channel('messaging', 'b'); - }); - - const ensureThread = (channel: StreamChannel, id: string) => - client.threads.ensure({ channel, parentMessage: generateMessage({ id }) }); - - const setup = ( - state: Parameters[0] | object = {}, - ) => { - const setChannel = vi.fn(); - const setThread = vi.fn(); - const navigation = createWorkspaceNavigation({ setChannel, setThread, ...state }); - return { navigation, setChannel, setThread }; - }; - - it('reports nothing open without a channel or thread', () => { - const { navigation } = setup(); - - expect(navigation.openChannels).toEqual([]); - expect(navigation.openThreads).toEqual([]); - expect(navigation.isChannelActive('messaging:a')).toBe(false); - expect(navigation.isThreadActive('t1')).toBe(false); - expect(navigation.isThreadDismissable()).toBe(false); - expect(navigation.isThreadsView).toBe(false); - }); - - it('derives the open entities and the active predicates from the state', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation } = setup({ channel: channelA, thread }); - - expect(navigation.openChannels).toEqual([channelA]); - expect(navigation.openThreads).toEqual([thread]); - expect(navigation.isChannelActive(channelA.cid)).toBe(true); - expect(navigation.isChannelActive(channelB.cid)).toBe(false); - expect(navigation.isChannelActive(undefined)).toBe(false); - expect(navigation.isThreadActive(thread.id)).toBe(true); - expect(navigation.isThreadActive('other')).toBe(false); - expect(navigation.isThreadActive(undefined)).toBe(false); - }); - - it('makes the open thread dismissable and the channel panel not', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation } = setup({ channel: channelA, thread }); - - expect(navigation.isThreadDismissable(thread.id)).toBe(true); - expect(navigation.isThreadDismissable()).toBe(true); - expect(navigation.isThreadDismissable('other')).toBe(false); - expect(navigation.isChannelDismissable(channelA.cid)).toBe(false); - }); - - it('has nothing to go back to', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); - - expect(navigation.canGoBack(channelA.cid)).toBe(false); - expect(navigation.canGoBack(thread.id)).toBe(false); - navigation.goBack(thread.id); - expect(setChannel).not.toHaveBeenCalled(); - expect(setThread).not.toHaveBeenCalled(); - }); - - it('closes the thread when opening a different channel', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); - - navigation.openChannel(channelB); - - expect(setChannel).toHaveBeenCalledWith(channelB); - expect(setThread).toHaveBeenCalledWith(undefined); - }); - - it('keeps the thread when re-opening the open channel', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); - - navigation.openChannel(channelA); - - expect(setChannel).toHaveBeenCalledWith(channelA); - expect(setThread).not.toHaveBeenCalled(); - }); - - it('opens a thread instance as is', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setThread } = setup({ channel: channelA }); - - navigation.openThread(thread); - - expect(setThread).toHaveBeenCalledWith(thread); - }); - - it('resolves a { channel, message } target through client.threads.ensure', () => { - const message = generateMessage({ id: 'parent-2' }); - const ensure = vi.spyOn(client.threads, 'ensure'); - const { navigation, setThread } = setup({ channel: channelA }); - - navigation.openThread({ channel: channelA, message }); - - expect(ensure).toHaveBeenCalledWith({ channel: channelA, parentMessage: message }); - expect(setThread).toHaveBeenCalledWith(ensure.mock.results[0].value); - }); - - it('closes the open thread by ID, or without one', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setThread } = setup({ channel: channelA, thread }); - - navigation.closeThread('other'); - expect(setThread).not.toHaveBeenCalled(); - - navigation.closeThread(thread.id); - expect(setThread).toHaveBeenLastCalledWith(undefined); - - setThread.mockClear(); - navigation.closeThread(); - expect(setThread).toHaveBeenCalledWith(undefined); - }); - - it('closes the open channel with its thread, and ignores other channels', () => { - const thread = ensureThread(channelA, 'parent-1'); - const { navigation, setChannel, setThread } = setup({ channel: channelA, thread }); - - navigation.closeChannel(channelB.cid); - expect(setChannel).not.toHaveBeenCalled(); - - navigation.closeChannel(channelA.cid); - expect(setChannel).toHaveBeenCalledWith(undefined); - expect(setThread).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/src/context/createWorkspaceNavigation.ts b/src/context/createWorkspaceNavigation.ts deleted file mode 100644 index df35d8c1fc..0000000000 --- a/src/context/createWorkspaceNavigation.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Thread as StreamThreadClass } from 'stream-chat'; - -import { defaultWorkspaceNavigation } from './WorkspaceNavigationContext'; - -import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; -import type { WorkspaceNavigation } from './WorkspaceNavigationContext'; - -/** State and setters for {@link createWorkspaceNavigation}. */ -export type CreateWorkspaceNavigationParams = { - /** Shows a channel in the channel panel, or empties it with `undefined`. */ - setChannel: (channel: StreamChannel | undefined) => void; - /** Shows a thread in the thread panel, or empties it with `undefined`. */ - setThread: (thread: StreamThread | undefined) => void; - /** The channel shown in the channel panel, if any. */ - channel?: StreamChannel; - /** The thread shown in the thread panel, if any. */ - thread?: StreamThread; -}; - -/** - * Builds a complete {@link WorkspaceNavigation} for a layout without `ChatView`: one channel panel - * and one thread panel beside it, held in the integrator's own state. Provide the result with - * `WorkspaceNavigationProvider`, memoized on `channel` and `thread`. - * - * - Opening a different channel closes the thread; closing the channel closes both. - * - `{ channel, message }` thread targets resolve with `client.threads.ensure`. - * - The thread panel is dismissable, so `ThreadHeader` renders its close button; the channel panel - * is the primary one and is not. - * - Nothing is stacked, so `canGoBack` is always false. `isThreadsView` is false. - * - * Navigation options (`additive`, `layer`, `panel`) are ignored. Override members by spreading the - * result, e.g. to also close a side panel when a thread opens. - */ -export const createWorkspaceNavigation = ({ - channel, - setChannel, - setThread, - thread, -}: CreateWorkspaceNavigationParams): WorkspaceNavigation => { - const isOpenThread = (threadId?: string) => - !!thread && (threadId === undefined || threadId === thread.id); - - return { - ...defaultWorkspaceNavigation, - closeChannel: (cid) => { - if (!channel || (cid !== undefined && cid !== channel.cid)) return; - setChannel(undefined); - setThread(undefined); - }, - closeThread: (threadId) => { - if (isOpenThread(threadId)) setThread(undefined); - }, - isChannelActive: (cid) => cid !== undefined && cid === channel?.cid, - isThreadActive: (threadId) => threadId !== undefined && threadId === thread?.id, - isThreadDismissable: isOpenThread, - openChannel: (nextChannel) => { - if (nextChannel !== channel) setThread(undefined); - setChannel(nextChannel); - }, - openChannels: channel ? [channel] : [], - openThread: (target) => { - setThread( - target instanceof StreamThreadClass - ? target - : target.channel.getClient().threads.ensure({ - channel: target.channel, - parentMessage: target.message, - }), - ); - }, - openThreads: thread ? [thread] : [], - }; -}; diff --git a/src/context/index.ts b/src/context/index.ts index 53e4a5e477..e246f71b41 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,7 +4,6 @@ export * from './ChannelListContext'; export * from './ChannelInstanceContext'; export * from './ChatContext'; export * from './ComponentContext'; -export * from './createWorkspaceNavigation'; export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; From c6c84c91ca9982cc8549bb547a7cdc59b1169ce9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:51:33 +0100 Subject: [PATCH 4/4] docs(ai-migration): note the sign-out and several-lists caveats of useOpenFirstChannel --- ai-docs/ai-migration-v14-v15.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index fa65d302a0..3a206851da 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1037,7 +1037,8 @@ These hit apps that never passed the props, because they were v14 defaults: - Call it below the navigation provider (inside `ChatView`, or inside your `WorkspaceNavigationProvider`), where `openChannel` does something. - Inside `ChatView`, call it in the channels view, not in an always-rendered `ChatView` child. `openChannels` lists the active view's channels only, so it is empty in the threads view, and opening a channel from there switches the user back to the channels view. - - It opens once per load of the list: closing the last channel, or a channel moving to the top, opens nothing. After `client.disconnectUser()` resets the list, the next load opens its first channel again. + - It opens once per load of the list: closing the last channel, or a channel moving to the top, opens nothing. After `client.disconnectUser()` resets the list, the next load opens its first channel again, once the previous user's channel is closed: inside `ChatView`, mount `` (from `stream-chat-react/slot-layout`); without it, clear your own state as described in [Without `ChatView`](#without-chatview). + - With several lists, call it once, for the list whose first channel should open. Each call knows only its own paginator, so with one call per list the first page to arrive wins, and two pages arriving before a re-render can both open. - Create the paginator above `ChatView` too ([above](#channellist-is-driven-by-a-channelpaginator)); one created inside the view is replaced on every switch, and the hook then subscribes to a fresh, unloaded paginator. - **Unfiltered channels no longer join the list.** `allowNewMessagesFromUnfilteredChannels` defaulted to `true`: a new message in a channel that was not in the list added it, whatever the list's filters. In v15 the paginator adds a channel only if it matches the paginator's own filters, and there is no opt-out. If channels used to appear this way, widen the filters to cover them.