From 3619235cda99a1a66fc2aab1ffc45cc1010b902e Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 1 Oct 2026 16:15:21 +0200 Subject: [PATCH 01/19] feat!: adopt the thread paginator and ThreadManager API from stream-chat v10 stream-chat#1888 replaced the thread manager's list state with `client.threads.paginator` and a store of live threads. - The thread list, its loading indicator, the unseen-threads banner and thread highlighting read `client.threads.paginator` and `client.threads.get()`. - `Thread` loads a thread only while its state is stale; threads come from `client.threads.ensure()` / `get()`, which register them with the manager. - ChatView navigation and "also sent in channel" resolve threads through `ensure()` / `get()`. - The unseen-threads banner tracks its own reload, which a loaded list does not publish. - The edit payload expectation follows the narrowed update payload. Co-Authored-By: Claude Opus 5.5 --- .../Channel/__tests__/Channel.test.tsx | 27 +++- .../Message/__tests__/MessageUI.test.tsx | 4 +- ...essageAlsoSentInChannelNavigation.test.tsx | 76 ++++++++- .../useMessageAlsoSentInChannelNavigation.ts | 9 +- src/components/Thread/Thread.tsx | 96 ++---------- .../Thread/__tests__/Thread.test.tsx | 112 +++++++++++--- .../Threads/ThreadList/ThreadList.tsx | 29 ++-- .../ThreadList/ThreadListLoadingIndicator.tsx | 9 +- .../ThreadListUnseenThreadsBanner.tsx | 26 +++- .../ThreadList/__tests__/ThreadList.test.tsx | 64 +++++++- .../ThreadListUnseenThreadsBanner.test.tsx | 144 ++++++++++++++++++ .../__tests__/useThreadHighlighting.test.ts | 83 +++++----- .../ThreadList/useThreadHighlighting.ts | 9 +- .../SlotLayout/ChatViewNavigationContext.tsx | 17 +-- 14 files changed, 507 insertions(+), 198 deletions(-) create mode 100644 src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 7e2d99c250..f45ac0d4c1 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -11,7 +11,6 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; -import { localMessageToNewMessagePayload } from 'stream-chat'; import { act, render, screen, waitFor } from '@testing-library/react'; import type { RenderResult } from '@testing-library/react'; @@ -557,13 +556,33 @@ describe('Channel', () => { .catch(() => {}); }); await waitFor(() => - // v10: single request object - `client.updateMessage({ id, message })`, where `message` is - // the LocalMessage projected onto the API payload shape. + // A single request object, `client.updateMessage({ id, message })`, where `message` is the + // edited message projected onto the update payload: the edited content plus its pin state. expect(clientUpdateMessageSpy).toHaveBeenCalledWith({ id: updatedMessage.id, - message: localMessageToNewMessagePayload(fromPartial(updatedMessage)), + message: expect.objectContaining({ + attachments: updatedMessage.attachments, + cid: updatedMessage.cid, + id: updatedMessage.id, + mentioned_users: [], + pinned: false, + pinned_at: null, + text: newText, + }), }), ); + // Server-owned fields are left out; sending them makes the update fail. + const [{ message: payload }] = clientUpdateMessageSpy.mock.calls[0]; + for (const serverOwnedField of [ + '__html', + 'created_at', + 'html', + 'type', + 'updated_at', + 'user', + ]) { + expect(payload).not.toHaveProperty(serverOwnedField); + } }); it('uses a registered updateMessageRequest for the edit path', async () => { diff --git a/src/components/Message/__tests__/MessageUI.test.tsx b/src/components/Message/__tests__/MessageUI.test.tsx index 88972940f8..4659075773 100644 --- a/src/components/Message/__tests__/MessageUI.test.tsx +++ b/src/components/Message/__tests__/MessageUI.test.tsx @@ -721,8 +721,8 @@ describe('', () => { // MERGE-RECONCILE (test migration): the also-sent-in-channel "View" navigation moved from the // ChannelActionContext `openThread` handler to useMessageAlsoSentInChannelNavigation, which - // resolves the parent thread via `client.getThreadAndHydrate` (v10 rename of `getThread`) and - // then navigates through ChatView `open`. + // resolves the parent thread through `client.threads.get()`, fetching it with + // `client.getThreadAndHydrate` when it is not registered, and then navigates through ChatView `open`. it('should open thread when View button is clicked and parent thread is resolved', async () => { const parentMessage = generateMessage({ id: 'x' }); const message = generateAliceMessage({ diff --git a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx index 3856cb5264..e8927d1eb7 100644 --- a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx +++ b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx @@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Shared spies + mutable per-test state. `vi.hoisted` runs before the `vi.mock` factories below, so // they can close over these. const mocks = vi.hoisted(() => ({ + addError: vi.fn(), + getRegisteredThread: vi.fn(), + getThreadAndHydrate: vi.fn(), ingestChannel: vi.fn(), jumpToMessage: vi.fn(() => Promise.resolve(true)), openChannel: vi.fn(), @@ -29,9 +32,9 @@ vi.mock('../../../../context', () => ({ useChatContext: () => ({ channelManager: { ingestChannel: mocks.ingestChannel }, client: { - getThread: vi.fn(), - notifications: { addError: vi.fn() }, - threads: { threadsById: {} }, + getThreadAndHydrate: mocks.getThreadAndHydrate, + notifications: { addError: mocks.addError }, + threads: { get: mocks.getRegisteredThread }, }, }), useMessageContext: () => ({ message: mocks.state.message }), @@ -137,4 +140,71 @@ describe('useMessageAlsoSentInChannelNavigation', () => { expect(mocks.jumpToMessage).toHaveBeenCalledWith('reply-1'); }); }); + + describe('viewReplyInThread', () => { + const makeThread = (id = 'parent-1') => ({ + id, + messagePaginator: { jumpToMessage: vi.fn(() => Promise.resolve(true)) }, + }); + + it('opens the registered thread without fetching it', async () => { + const registered = makeThread(); + mocks.getRegisteredThread.mockReturnValue(registered); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.getRegisteredThread).toHaveBeenCalledWith('parent-1'); + expect(mocks.getThreadAndHydrate).not.toHaveBeenCalled(); + expect(mocks.openThread).toHaveBeenCalledWith(registered); + expect(registered.messagePaginator.jumpToMessage).toHaveBeenCalledWith('reply-1'); + }); + + it('fetches a thread that is not registered', async () => { + const fetched = makeThread(); + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockResolvedValue(fetched); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.getThreadAndHydrate).toHaveBeenCalledWith('parent-1', { watch: true }); + expect(mocks.openThread).toHaveBeenCalledWith(fetched); + expect(fetched.messagePaginator.jumpToMessage).toHaveBeenCalledWith('reply-1'); + }); + + it('reuses an instance registered while the fetch was in flight', async () => { + // E.g. a thread list query landing during the request: opening the fetched copy would leave + // two instances of one thread, and the second one gets no events. + const registeredMeanwhile = makeThread(); + const fetched = makeThread(); + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockImplementation(() => { + mocks.getRegisteredThread.mockReturnValue(registeredMeanwhile); + return Promise.resolve(fetched); + }); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.openThread).toHaveBeenCalledWith(registeredMeanwhile); + expect(registeredMeanwhile.messagePaginator.jumpToMessage).toHaveBeenCalledWith( + 'reply-1', + ); + expect(fetched.messagePaginator.jumpToMessage).not.toHaveBeenCalled(); + }); + + it('reports a thread that cannot be fetched', async () => { + mocks.getRegisteredThread.mockReturnValue(undefined); + mocks.getThreadAndHydrate.mockRejectedValue(new Error('not found')); + + const result = renderNavigation(); + await result.current.viewReplyInThread('reply-1', 'parent-1'); + + expect(mocks.addError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Thread has not been found' }), + ); + expect(mocks.openThread).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts index f6209df743..9435597631 100644 --- a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts +++ b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts @@ -71,11 +71,16 @@ export const useMessageAlsoSentInChannelNavigation = parentId = message?.parent_id, ) => { if (!replyId || !parentId) return; - let targetThread = client.threads.threadsById[parentId]; + let targetThread = client.threads.get(parentId); if (!targetThread) { try { - targetThread = await client.getThreadAndHydrate(parentId, { watch: true }); + const fetchedThread = await client.getThreadAndHydrate(parentId, { + watch: true, + }); + // An instance registered while the request was in flight (e.g. by a thread list query) + // wins, so there is only one live instance per thread. + targetThread = client.threads.get(parentId) ?? fetchedThread; } catch (error) { addThreadNotFoundNotification(error as Error); return; diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 88bbef56d4..6d7d1cb493 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import type { PropsWithChildren } from 'react'; import { WithAudioPlayback } from '../AudioPlayback'; @@ -7,12 +7,7 @@ import { useChatContext } from '../../context'; import { ThreadProvider } from '../Threads'; import { useStateStore } from '../../store'; -import type { - LocalMessage, - Thread as StreamThread, - ThreadManagerState, - ThreadState, -} from 'stream-chat'; +import type { Thread as StreamThread, ThreadState } from 'stream-chat'; import type { ChannelConfig } from 'stream-chat'; const repliesStateSelector = ({ replies }: ChannelConfig) => ({ @@ -21,8 +16,10 @@ const repliesStateSelector = ({ replies }: ChannelConfig) => ({ export type ThreadProps = PropsWithChildren<{ /** - * The thread to render. Initialize it before passing it in; `Thread` does not query for it -- - * it loads the replies of a thread the `ThreadManager` does not already hold. + * The thread to render. Get it from `client.threads.ensure()` (or `client.threads.get()`) rather + * than constructing it, so it is registered with the `ThreadManager` and receives events. `Thread` + * loads it while its state is stale, which is how a thread built by `ensure()` for a parent with + * replies starts. */ thread: StreamThread; }>; @@ -35,24 +32,11 @@ const selector = ({ isStateStale, parentMessage, replyCount }: ThreadState) => ( parentMessage, }); -// Same reasoning: the effects below only ask whether the replies have loaded, never what they are. -const messagePaginatorSelector = ({ - isLoading, - items, - lastQueryError, -}: { - isLoading: boolean; - items: LocalMessage[] | undefined; - lastQueryError?: Error; -}) => ({ - hasLoadedReplies: items !== undefined, - isLoading, - lastQueryError, -}); - /** - * The container for a thread panel: it provides the thread to its subtree, loads it, registers it - * with the `ThreadManager`, scopes audio playback to it, and renders whatever you compose inside. + * The container for a thread panel: it provides the thread to its subtree, loads it while its state + * is stale, scopes audio playback to it, and renders whatever you compose inside. It does not + * register the thread with the `ThreadManager`; `client.threads.ensure()` or `thread.activate()` + * does. * * It renders no UI of its own, the way `Channel` does not -- put the parts you want in as * children, and their own props say how they behave: @@ -74,7 +58,7 @@ const messagePaginatorSelector = ({ * the replies it shows. */ export const Thread = ({ children, thread }: ThreadProps) => { - const { client, customClasses } = useChatContext(); + const { customClasses } = useChatContext(); const { repliesEnabled } = useStateStore( thread.channel.configState, repliesStateSelector, @@ -89,66 +73,18 @@ export const Thread = ({ children, thread }: ThreadProps) => { thread.state, selector, ); - const { hasLoadedReplies, isLoading, lastQueryError } = useStateStore( - thread.messagePaginator.state, - messagePaginatorSelector, - ); - - const isThreadManagedSelector = useCallback( - ({ threads }: ThreadManagerState) => ({ - isThreadManaged: threads.some((managedThread) => managedThread.id === thread.id), - }), - [thread.id], - ); - const { isThreadManaged } = useStateStore( - client.threads.state, - isThreadManagedSelector, - ); - // Only an unmanaged thread is loaded here. The `ThreadManager` already loads and refreshes the - // ones it holds; an instance from `getThreadAndHydrate()` is registered nowhere, so it has no - // other owner. - useEffect(() => { - if (isThreadManaged) return; - if (!hasServerSideThread) return; - if (hasLoadedReplies || isLoading) return; - void thread.reload(); - }, [hasLoadedReplies, hasServerSideThread, isLoading, isThreadManaged, thread]); - - // Deliberately a separate effect rather than a branch of the one above: catching up a stale - // thread depends on `isStateStale` alone, so it fires once per staleness episode. Merged in, it - // would also re-run whenever the load branch's inputs change -- registering the thread flips - // `isThreadManaged`, which would request a second reload while the first is still in flight. + // The only load trigger. A thread `client.threads.ensure()` builds for a parent with replies starts + // stale, so it loads here once when first opened; one for a parent without replies has nothing on + // the server yet and starts up to date. Listed threads and `getThreadAndHydrate()` instances arrive + // with their replies and are not stale. It fires once per staleness episode, and `thread.reload()` ignores a + // call while one is in flight. useEffect(() => { if (isStateStale && hasServerSideThread) { void thread.reload(); } }, [hasServerSideThread, isStateStale, thread]); - useEffect(() => { - if (isThreadManaged) return; - if (isLoading) return; - if (lastQueryError) return; - if (!hasLoadedReplies) return; - - client.threads.state.next((current) => { - if (current.threads.some((managedThread) => managedThread.id === thread.id)) { - return current; - } - return { - ...current, - threads: [thread, ...current.threads], - }; - }); - }, [ - client.threads.state, - hasLoadedReplies, - isLoading, - isThreadManaged, - lastQueryError, - thread, - ]); - if (!parentMessage || repliesEnabled === false) return null; // The thread owns its audio-player pool (rather than inheriting one from an ambient ) diff --git a/src/components/Thread/__tests__/Thread.test.tsx b/src/components/Thread/__tests__/Thread.test.tsx index 9fbbaa6880..ee5ace16f7 100644 --- a/src/components/Thread/__tests__/Thread.test.tsx +++ b/src/components/Thread/__tests__/Thread.test.tsx @@ -5,6 +5,7 @@ import { StateStore } from '@stream-io/state-store'; import type { ChannelConfig, LocalMessage, + Channel as StreamChannel, StreamChat, Thread as StreamThread, ThreadState, @@ -26,12 +27,13 @@ import type { ComponentContextValue } from '../../../context'; // MERGE-RECONCILE (test migration): PR #2909 / v14 rewrote Thread to read from a Thread instance // (not the deleted ChannelStateContext/ChannelActionContext). The parent message, reply pagination -// and loading live on `thread.state` / `thread.messagePaginator`, and the thread-manager list on -// `client.threads.state`. Obsolete assertions that referenced the removed MessageList props +// and loading live on `thread.state` / `thread.messagePaginator`, and opened threads resolve through +// `client.threads.get()`. Obsolete assertions that referenced the removed MessageList props // (`hasMore`/`loadMore`/`messages`/`threadList`) and the ChannelActionContext // `loadMoreThread`/`closeThread` handlers are updated to the current contract. let chatClient: StreamChat; +let channel: StreamChannel; const alice = generateUser({ id: 'alice', name: 'alice' }); const bob = generateUser({ id: 'bob', name: 'bob' }); const parentMessage = generateMessage({ reply_count: 2, user: alice }); @@ -102,7 +104,10 @@ const renderComponent = ({ describe('Thread', () => { beforeAll(async () => { - ({ client: chatClient } = await initClientWithChannels()); + ({ + channels: [channel], + client: chatClient, + } = await initClientWithChannels()); }); afterEach(() => { @@ -140,22 +145,87 @@ describe('Thread', () => { expect(getByTestId('probe')).toHaveAttribute('data-thread-id', thread.id); }); - it('should reload the thread on mount when replies have not been fetched yet', () => { - // Use a unique parent id so the thread is not already tracked in the shared - // client.threads manager state (which would short-circuit the reload effect). - const { reload, thread } = makeThread({ - items: undefined, - parentMessage: generateMessage({ - id: 'reload-parent', + /** A thread opened the way the SDK opens one, with its fetch stubbed to succeed. */ + const ensureThread = (parent: LocalMessage) => { + const thread = chatClient.threads.ensure({ channel, parentMessage: parent }); + const reload = vi.spyOn(thread, 'reload').mockImplementation(() => { + thread.state.partialNext({ isStateStale: false }); + return Promise.resolve(); + }); + return { reload, thread }; + }; + + it('should load a thread built by `client.threads.ensure()` exactly once when opened', () => { + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'ensure-parent', reply_count: 2, user: alice, }), + ); + const { rerender } = renderComponent({ threadInstance: thread }); + rerender( + + + +
+ + + , + ); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('should not fetch when reopening a thread that is still registered', () => { + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'reopen-parent', + reply_count: 2, + user: alice, + }), + ); + const { unmount } = renderComponent({ threadInstance: thread }); + unmount(); + + const reopened = chatClient.threads.ensure({ + channel, + parentMessage: thread.state.getLatestValue().parentMessage, }); - renderComponent({ threadInstance: thread }); + renderComponent({ threadInstance: reopened }); + expect(reopened).toBe(thread); expect(reload).toHaveBeenCalledTimes(1); }); + it('should leave the opened thread out of the thread list', () => { + // An opened thread stays live through the manager's store; `Thread` does not add it to the list. + const { thread } = ensureThread( + generateMessage({ + cid: channel.cid, + id: 'unlisted-parent', + reply_count: 2, + user: alice, + }), + ); + renderComponent({ threadInstance: thread }); + + expect(chatClient.threads.get(thread.id)).toBe(thread); + expect(chatClient.threads.paginator.getItem(thread.id)).toBeUndefined(); + }); + + it('should not reload a non-stale thread', () => { + // Listed threads and `getThreadAndHydrate()` instances arrive loaded, so opening one is free. + const { reload, thread } = makeThread({ + parentMessage: generateMessage({ id: 'fresh-parent', reply_count: 2, user: alice }), + }); + renderComponent({ threadInstance: thread }); + + expect(reload).not.toHaveBeenCalled(); + }); + it('should not reload a thread whose parent message has no replies yet', () => { // The thread does not exist server-side until its first reply, so `GET /threads/:id` can only // 404 here — opening a reply-less message to write the first reply must not query. @@ -172,25 +242,26 @@ describe('Thread', () => { expect(reload).not.toHaveBeenCalled(); }); - it('should reload once the parent message reports its first reply', () => { - // The skip is self-healing: `replyCount` follows the parent message, so the thread loads as - // soon as it exists server-side — without remounting the component. - const { reload, thread } = makeThread({ - items: undefined, - parentMessage: generateMessage({ + it('should not load a thread built by `ensure()` for a parent without replies', () => { + // There is no server-side thread to load yet (`getThread` would answer 404), so `ensure()` builds + // it up to date. It is registered, so its first reply reaches it as an event, not through a load. + const { reload, thread } = ensureThread( + generateMessage({ + cid: channel.cid, id: 'first-reply-parent', reply_count: 0, user: alice, }), - }); + ); renderComponent({ threadInstance: thread }); + expect(thread.state.getLatestValue().isStateStale).toBe(false); expect(reload).not.toHaveBeenCalled(); act(() => { thread.state.partialNext({ replyCount: 1 }); }); - expect(reload).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); }); it('should defer a stale reload until the thread reports a reply', () => { @@ -200,8 +271,7 @@ describe('Thread', () => { // parent message reports a reply. const { reload, thread } = makeThread({ isStateStale: true, - // `[]`, not `undefined`: reopening runs on a disposed paginator, which is what makes the - // stale effect the only one that can still load this thread. + // `[]`: a reopened thread has replies loaded, so only its staleness can trigger the load. items: [], parentMessage: generateMessage({ id: 'stale-never-created-parent', diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index dabf3c5b03..ef11d7090a 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import type { ComputeItemKey, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; -import type { Thread, ThreadManagerState } from 'stream-chat'; +import type { PaginatorState, Thread } from 'stream-chat'; import { useVirtualizedListboxKeyboardNavigation } from '../../../a11y/hooks/useVirtualizedListboxKeyboardNavigation'; import { ThreadListItem as DefaultThreadListItem } from './ThreadListItem'; @@ -19,9 +19,11 @@ import { import { useStateStore } from '../../../store'; import { ThreadListHeader } from './ThreadListHeader'; -const selector = (nextValue: ThreadManagerState) => ({ - isLoading: nextValue.pagination.isLoading, - threads: nextValue.threads, +const noThreads: Thread[] = []; + +const selector = (nextValue: PaginatorState) => ({ + isLoading: nextValue.isLoading, + threads: nextValue.items ?? noThreads, }); const computeItemKey: ComputeItemKey = (_, item) => item.id; @@ -34,19 +36,7 @@ export const useThreadList = () => { const { client } = useChatContext(); useEffect(() => { - // Reset derived pagination inputs before initial reload so the first mount requests - // the default first page size, rather than a limit inferred from cached/unseen threads. - const { pagination } = client.threads.state.getLatestValue(); - client.threads.state.partialNext({ - isThreadOrderStale: false, - pagination: { - ...pagination, - nextCursor: null, - }, - ready: false, - threads: [], - unseenThreadIds: [], - }); + // Fresh list on every mount; the reload sizes its own page from the loaded and unseen threads. void client.threads.reload({ force: true }); const handleVisibilityChange = () => { @@ -78,7 +68,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator, ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner, } = useComponentContext(); - const { isLoading, threads } = useStateStore(client.threads.state, selector); + const { isLoading, threads } = useStateStore(client.threads.paginator.state, selector); const resetByThreadId = useThreadHighlighting(client.threads); @@ -103,6 +93,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { useThreadList(); + // Only the first load shows the placeholder; a reload keeps the loaded threads until the new ones land. if (isLoading && !threads.length) { return (
@@ -121,7 +112,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { atBottom && client.threads.loadNextPage()} + atBottomStateChange={(atBottom) => atBottom && client.threads.paginator.toTail()} className='str-chat__thread-list' components={{ EmptyPlaceholder: ThreadListEmptyPlaceholder, diff --git a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx index 9896550c21..f2e198b36b 100644 --- a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx +++ b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx @@ -1,19 +1,20 @@ import React from 'react'; -import type { ThreadManagerState } from 'stream-chat'; +import type { PaginatorState, Thread } from 'stream-chat'; import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; import { useChatContext, useComponentContext } from '../../../context'; import { useStateStore } from '../../../store'; -const selector = (nextValue: ThreadManagerState) => ({ - isLoadingNext: nextValue.pagination.isLoadingNext, +// `isLoading` with threads already loaded means the next page is loading. +const selector = (nextValue: PaginatorState) => ({ + isLoadingNext: nextValue.isLoading && !!nextValue.items?.length, }); export const ThreadListLoadingIndicator = () => { const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); const { client } = useChatContext(); - const { isLoadingNext } = useStateStore(client.threads.state, selector); + const { isLoadingNext } = useStateStore(client.threads.paginator.state, selector); if (!isLoadingNext) return null; diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index f03c5e3673..11609ff10e 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import type { ThreadManagerState } from 'stream-chat'; @@ -12,7 +12,6 @@ import { useStateStore } from '../../../store'; import { LoadingIndicator } from '../../Loading'; const selector = (nextValue: ThreadManagerState) => ({ - isLoading: nextValue.pagination.isLoading, unseenThreadIds: nextValue.unseenThreadIds, }); @@ -20,7 +19,26 @@ export const ThreadListUnseenThreadsBanner = () => { const { IconRefresh } = useComponentContextIcons(); const { client } = useChatContext(); const { t } = useTranslationContext(); - const { isLoading, unseenThreadIds } = useStateStore(client.threads.state, selector); + const { unseenThreadIds } = useStateStore(client.threads.state, selector); + // A reload of a loaded list publishes no loading state, so the pending reload is tracked here. + const [isLoading, setIsLoading] = useState(false); + const isMounted = useRef(true); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const reload = useCallback(async () => { + setIsLoading(true); + try { + await client.threads.reload(); + } finally { + if (isMounted.current) setIsLoading(false); + } + }, [client]); if (!unseenThreadIds.length) return null; @@ -30,7 +48,7 @@ export const ThreadListUnseenThreadsBanner = () => { 'str-chat__unseen-threads-banner--loading': isLoading, })} disabled={isLoading} - onClick={() => client.threads.reload()} + onClick={reload} > {!isLoading && ( <> diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx index 3c5ebe442f..0541a61ea6 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import type { StreamChat } from 'stream-chat'; +import type { PaginatorState, StreamChat, Thread } from 'stream-chat'; import { ThreadList } from '../ThreadList'; import { initClientWithChannels } from '../../../../mock-builders'; @@ -84,11 +84,9 @@ vi.mock('../../../Notifications', () => ({ })); describe('ThreadList', () => { - // MERGE-RECONCILE (test migration): the ThreadList effects now call real ThreadManager APIs - // (`client.threads.state.getLatestValue()`, `partialNext`, `reload`). Use a real StreamChat - // client (via initClientWithChannels) so `client.threads.state` is a genuine StateStore rather - // than hand-mocking `client.threads`. `useStateStore` stays mocked to drive isLoading/threads, - // and `client.threads.reload` is stubbed to avoid a network call in the mount effect. + // A real StreamChat client (via initClientWithChannels), so `client.threads` and its paginator + // are genuine. `useStateStore` stays mocked to drive isLoading/threads, and + // `client.threads.reload` is stubbed to avoid a network call in the mount effect. let client: StreamChat; beforeEach(async () => { @@ -135,6 +133,60 @@ describe('ThreadList', () => { }); }); + it('reads the threads and the loading state from the thread paginator', () => { + render(); + + const [store, selector] = mockUseStateStore.mock.calls[0]; + expect(store).toBe(client.threads.paginator.state); + expect( + selector({ isLoading: true, items: undefined } as PaginatorState), + ).toEqual({ isLoading: true, threads: [] }); + const threads = [{ id: 'thread-1' }] as Thread[]; + expect( + selector({ isLoading: false, items: threads } as PaginatorState), + ).toEqual({ isLoading: false, threads }); + }); + + it('keeps the loaded threads on screen while the paginator is loading', () => { + // `isLoading` with threads loaded is the next page, not the first load. + mockUseStateStore.mockReturnValue({ isLoading: true, threads: [{ id: 'thread-1' }] }); + + render(); + + expect(screen.getByTestId('virtuoso')).toBeInTheDocument(); + expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument(); + }); + + it('reloads the list on mount without resetting the manager state', () => { + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + + render(); + + expect(client.threads.reload).toHaveBeenCalledWith({ force: true }); + expect(client.threads.state.getLatestValue().unseenThreadIds).toEqual([ + 'unseen-thread', + ]); + }); + + it('loads the next page through the paginator when scrolled to the bottom', () => { + const toTail = vi + .spyOn(client.threads.paginator, 'toTail') + .mockResolvedValue(undefined); + mockUseStateStore.mockReturnValue({ + isLoading: false, + threads: [{ id: 'thread-1' }], + }); + + render(); + const { atBottomStateChange } = mockVirtuoso.mock.calls[0][0]; + + atBottomStateChange(false); + expect(toTail).not.toHaveBeenCalled(); + + atBottomStateChange(true); + expect(toTail).toHaveBeenCalledTimes(1); + }); + // 5 threads, but the mocked Virtuoso only renders the first MOCK_WINDOW_SIZE (3). const FIVE_THREADS = [ { id: 'thread-1' }, diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx new file mode 100644 index 0000000000..470bc416fc --- /dev/null +++ b/src/components/Threads/ThreadList/__tests__/ThreadListUnseenThreadsBanner.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { Channel, StreamChat, Thread } from 'stream-chat'; + +import { ChatProvider, WithComponents } from '../../../../context'; +import { TranslationProvider } from '../../../../context/TranslationContext'; +import { + generateMessage, + initClientWithChannels, + mockChatContext, + mockTranslationContextValue, +} from '../../../../mock-builders'; +import { mockT } from '../../../../mock-builders/translator'; +import { ThreadListUnseenThreadsBanner } from '../ThreadListUnseenThreadsBanner'; +import { ThreadListLoadingIndicator } from '../ThreadListLoadingIndicator'; + +const LoadingIndicator = () =>
; + +let channel: Channel; +let client: StreamChat; + +const listedThread = (): Thread => + client.threads.ensure({ + channel, + parentMessage: generateMessage({ cid: channel.cid, reply_count: 1 }), + }); + +const renderWithClient = (ui: React.ReactElement) => + render( + + + {ui} + + , + ); + +const deferred = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +beforeEach(async () => { + ({ + channels: [channel], + client, + } = await initClientWithChannels()); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ThreadListUnseenThreadsBanner', () => { + it('renders nothing without unseen threads', () => { + const { container } = renderWithClient(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the loading state while its reload is pending', async () => { + const pending = deferred(); + const reload = vi.spyOn(client.threads, 'reload').mockReturnValue(pending.promise); + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + renderWithClient(); + + const banner = screen.getByRole('button'); + expect(banner).not.toBeDisabled(); + + fireEvent.click(banner); + + expect(reload).toHaveBeenCalledTimes(1); + expect(banner).toBeDisabled(); + expect(banner).toHaveClass('str-chat__unseen-threads-banner--loading'); + expect(banner).toHaveTextContent('Loading...'); + + await act(async () => { + // A successful reload clears `unseenThreadIds`. + client.threads.state.partialNext({ unseenThreadIds: [] }); + pending.resolve(); + await pending.promise; + }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('keeps the banner after a failed reload', async () => { + // `reload()` logs a failure and leaves `unseenThreadIds` in place. + const pending = deferred(); + vi.spyOn(client.threads, 'reload').mockReturnValue(pending.promise); + client.threads.state.partialNext({ unseenThreadIds: ['unseen-thread'] }); + renderWithClient(); + + fireEvent.click(screen.getByRole('button')); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + + const banner = screen.getByRole('button'); + expect(banner).not.toBeDisabled(); + expect(banner).not.toHaveClass('str-chat__unseen-threads-banner--loading'); + expect(banner).not.toHaveTextContent('Loading...'); + }); +}); + +describe('ThreadListLoadingIndicator', () => { + it('does not show during the first load', () => { + client.threads.paginator.state.partialNext({ isLoading: true, items: undefined }); + + const { container } = renderWithClient(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows while the next page loads', () => { + client.threads.paginator.state.partialNext({ + isLoading: true, + items: [listedThread()], + }); + + renderWithClient(); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + }); + + it('hides once the page has loaded', () => { + client.threads.paginator.state.partialNext({ + isLoading: true, + items: [listedThread()], + }); + renderWithClient(); + + act(() => client.threads.paginator.state.partialNext({ isLoading: false })); + + expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts b/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts index 0417e9aae4..cc12525de6 100644 --- a/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts +++ b/src/components/Threads/ThreadList/__tests__/useThreadHighlighting.test.ts @@ -4,90 +4,95 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useThreadHighlighting } from '../useThreadHighlighting'; -import type { Thread, ThreadManager, ThreadManagerState } from 'stream-chat'; +import type { + PaginatorState, + Thread, + ThreadManager, + ThreadManagerState, +} from 'stream-chat'; /** - * The flash means "a thread arrived while you were looking at the list". - * - * It used to be derived by diffing the threads array by reference, which called anything that - * merely appeared for the first time an arrival -- so the whole first page lit up on load, and - * each page did while scrolling. + * The flash means "a thread arrived while you were looking at the list". Threads that merely + * appear for the first time (the first page on load, each page while scrolling) are not arrivals. */ const thread = (id: string) => fromPartial({ id }); -const setup = (initial: Partial = {}) => { - const state = new StateStore( - fromPartial({ threads: [], unseenThreadIds: [], ...initial }), - ); - const threadManager = fromPartial({ state }); +const setup = ({ items }: { items?: Thread[] } = {}) => { + const state = new StateStore(fromPartial({ unseenThreadIds: [] })); + const listState = new StateStore>(fromPartial({ items })); + const threadManager = fromPartial({ + paginator: { state: listState }, + state, + }); const { result } = renderHook(() => useThreadHighlighting(threadManager)); - return { result, state }; + return { listState, result, state }; }; describe('useThreadHighlighting', () => { it('does not flash the first page arriving on a cold load', () => { - const { result, state } = setup(); + const { listState, result } = setup(); - act(() => state.partialNext({ threads: [thread('a'), thread('b'), thread('c')] })); + act(() => listState.partialNext({ items: [thread('a'), thread('b'), thread('c')] })); expect(Object.keys(result.current)).toEqual([]); }); it('does not flash a page loaded by pagination', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result } = setup({ items: [thread('a')] }); - act(() => state.partialNext({ threads: [thread('a'), thread('b'), thread('c')] })); + act(() => listState.partialNext({ items: [thread('a'), thread('b'), thread('c')] })); expect(Object.keys(result.current)).toEqual([]); }); it('flashes a thread the manager reported unseen, once it lands in the list', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); // A message arrives for a thread the list does not hold. act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); expect(Object.keys(result.current)).toEqual([]); - // `reload()` brings it in and clears `unseenThreadIds` in the same update -- which is why the - // id has to have been remembered when it was reported. - act(() => - state.partialNext({ - threads: [thread('new-one'), thread('a')], - unseenThreadIds: [], - }), - ); + // `reload()` brings it into the list, then clears `unseenThreadIds` -- which is why the id has + // to have been remembered when it was reported. + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); + + expect(Object.keys(result.current)).toEqual(['new-one']); + }); + + it('flashes the arrival when `unseenThreadIds` is cleared before the list update lands', () => { + const { listState, result, state } = setup({ items: [thread('a')] }); + + act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); + act(() => state.partialNext({ unseenThreadIds: [] })); + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); expect(Object.keys(result.current)).toEqual(['new-one']); }); it('keeps an earlier flash alive when a second thread arrives', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); act(() => state.partialNext({ unseenThreadIds: ['first'] })); - act(() => - state.partialNext({ threads: [thread('first'), thread('a')], unseenThreadIds: [] }), - ); + act(() => listState.partialNext({ items: [thread('first'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); act(() => state.partialNext({ unseenThreadIds: ['second'] })); act(() => - state.partialNext({ - threads: [thread('second'), thread('first'), thread('a')], - unseenThreadIds: [], + listState.partialNext({ + items: [thread('second'), thread('first'), thread('a')], }), ); + act(() => state.partialNext({ unseenThreadIds: [] })); expect(Object.keys(result.current).sort()).toEqual(['first', 'second']); }); it('stops flashing a thread once its reset is called', () => { - const { result, state } = setup({ threads: [thread('a')] }); + const { listState, result, state } = setup({ items: [thread('a')] }); act(() => state.partialNext({ unseenThreadIds: ['new-one'] })); - act(() => - state.partialNext({ - threads: [thread('new-one'), thread('a')], - unseenThreadIds: [], - }), - ); + act(() => listState.partialNext({ items: [thread('new-one'), thread('a')] })); + act(() => state.partialNext({ unseenThreadIds: [] })); act(() => result.current['new-one']()); diff --git a/src/components/Threads/ThreadList/useThreadHighlighting.ts b/src/components/Threads/ThreadList/useThreadHighlighting.ts index 124f5d9f38..4f687f98cd 100644 --- a/src/components/Threads/ThreadList/useThreadHighlighting.ts +++ b/src/components/Threads/ThreadList/useThreadHighlighting.ts @@ -10,8 +10,9 @@ import type { ThreadManager } from 'stream-chat'; * each page while scrolling, neither of which is an arrival. * * The ids have to be remembered as they are reported, because `reload()` clears `unseenThreadIds` - * in the same update that puts those threads into the list: by the time they are on screen the - * manager no longer calls them unseen. + * once it has put those threads into the list: by the time they are on screen the manager no longer + * calls them unseen. The list (`paginator.state`) and `unseenThreadIds` (`state`) live in different + * stores, and remembering the ids makes the order their updates land in irrelevant. */ export const useThreadHighlighting = (threadManager: ThreadManager) => { const [threadsToHighlight, setThreadsToHighlight] = useState< @@ -27,8 +28,8 @@ export const useThreadHighlighting = (threadManager: ThreadManager) => { }, ); - const unsubscribeThreads = threadManager.state.subscribeWithSelector( - (state) => ({ threads: state.threads }), + const unsubscribeThreads = threadManager.paginator.state.subscribeWithSelector( + (state) => ({ threads: state.items ?? [] }), ({ threads }) => { if (!awaitingArrival.current.size) return; diff --git a/src/plugins/SlotLayout/ChatViewNavigationContext.tsx b/src/plugins/SlotLayout/ChatViewNavigationContext.tsx index a8f79f96f7..5e2c59dbc9 100644 --- a/src/plugins/SlotLayout/ChatViewNavigationContext.tsx +++ b/src/plugins/SlotLayout/ChatViewNavigationContext.tsx @@ -19,7 +19,6 @@ import type { StreamChat, Thread as StreamThread, } from 'stream-chat'; -import { Thread as StreamThreadClass } from 'stream-chat'; import type { ChatView, ChatViewEntityBinding, @@ -102,7 +101,7 @@ export type OpenThreadTarget = /** * Binding builder for the `thread` kind (D6). The `{ channel, message }` -> - * `Thread` construction (with dedupe against `client.threads`) lives here so + * `Thread` resolution (through `client.threads.ensure()`) lives here so * callers can drive the generic `open` with a ready binding instead of a * thread-specific navigation method. */ @@ -110,14 +109,12 @@ export const createThreadEntityBinding = ( client: StreamChat, target: { channel: StreamChannel; message: LocalMessage }, ): ChatViewEntityBinding => { - const existingThread = client.threads.threadsById[target.message.id]; - const thread = - existingThread ?? - new StreamThreadClass({ - channel: target.channel, - client, - parentMessage: target.message, - }); + // `ensure()` registers a thread it builds, so a thread opened beside another one stays live + // even though only the active slot's thread is activated. + const thread = client.threads.ensure({ + channel: target.channel, + parentMessage: target.message, + }); return { key: thread.id ?? undefined, kind: 'thread', source: thread }; }; From 5d495c9662cc21d1d46e894851a0541b3e1d4ad1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 1 Oct 2026 16:15:38 +0200 Subject: [PATCH 02/19] feat!: read channels from the channel store instead of client.activeChannels stream-chat v10 removes `client.activeChannels`; loaded channels live in the channel manager's store. - `Channel` keeps the release function returned by `channel.activate()` instead of calling `deactivate()`. - The chat context reads `client.channelManager.values()`. - The vite example's workspace URL restore resolves channels through `client.channelManager.get()` and threads through `client.threads`; it threw on `activeChannels` and left the channel panel empty on load. Its debug and settings panels read the channel store and the thread paginator. Co-Authored-By: Claude Opus 5.5 --- .../ServerSideClientPromptDialog.tsx | 7 ++-- .../tabs/Configuration/ConfigurationTab.tsx | 8 +++-- .../vite/src/ChatLayout/WorkspaceUrlSync.tsx | 32 +++++++++---------- .../vite/src/Debug/StreamDebugHandles.tsx | 9 ++++-- examples/vite/src/Debug/composerRegistry.ts | 2 +- src/components/Channel/Channel.tsx | 10 ++---- .../__tests__/channelInstanceAxis.test.tsx | 18 +++++------ .../__tests__/channelSwitchReset.test.tsx | 5 +-- .../Chat/hooks/useCreateChatContext.ts | 2 +- .../__tests__/MessageInput.test.tsx | 2 -- .../__tests__/ThreadMessageInput.test.tsx | 1 - .../__tests__/messageSourceKey.test.ts | 2 +- 12 files changed, 49 insertions(+), 49 deletions(-) diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx index 9d5205aa73..42fc58a004 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx @@ -100,12 +100,13 @@ export const ServerSideClientPromptDialog = ({ ); // Channels the client has loaded. Recomputed each time the dialog opens rather than subscribed - // to — `activeChannels` is a plain record with no change notification, and a debugging dialog + // to — the channel store sends no change notifications, and a debugging dialog // does not need it live. `allowCustomValue` covers anything not in the list. const channelOptions = useMemo[]>(() => { if (!dialogIsOpen) return []; - return Object.values(appClient.activeChannels) + return appClient.channelManager + .values() .map((activeChannel) => activeChannel.cid) .filter((activeChannelCid): activeChannelCid is string => !!activeChannelCid) .sort((left, right) => left.localeCompare(right)) @@ -118,7 +119,7 @@ export const ServerSideClientPromptDialog = ({ const localMembers = useMemo(() => { if (!dialogIsOpen || !cid) return []; - const members = appClient.activeChannels[cid]?.state?.members ?? {}; + const members = appClient.channelManager.get(cid)?.state?.members ?? {}; return Object.values(members) .map((member) => ({ diff --git a/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx b/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx index a8c64ccad1..833cdb0bf1 100644 --- a/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { ChannelManagerState, ThreadManagerState } from 'stream-chat'; +import type { ChannelManagerState, PaginatorState, Thread } from 'stream-chat'; import { Button, useChatContext, useStateStore } from 'stream-chat-react'; import { SettingsTabBody, @@ -50,7 +50,9 @@ const paginatorsSelector = (state: ChannelManagerState) => ({ paginators: state.paginators, }); -const threadsSelector = (state: ThreadManagerState) => ({ threads: state.threads }); +const threadsSelector = (state: PaginatorState) => ({ + threads: state.items ?? [], +}); /** * `client.config` keys its methods on the real key unions, so a segment parsed out of a dotted path @@ -66,7 +68,7 @@ const isSetupKey = (key: string): key is InstanceSetupKey => export const ConfigurationTab = ({ close }: ConfigurationTabProps) => { const { channelManager, client } = useChatContext(); const { paginators } = useStateStore(channelManager.state, paginatorsSelector); - const { threads } = useStateStore(client.threads.state, threadsSelector); + const { threads } = useStateStore(client.threads.paginator.state, threadsSelector); const [scope, setScope] = useState('all'); const [selectedType, setSelectedType] = useState(''); diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index 5a6cec00d9..8f2b50feb3 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -10,7 +10,7 @@ import { useChatViewContext, useChatViewNavigation, } from 'stream-chat-react/slot-layout'; -import { formatMessage, Thread as StreamThread } from 'stream-chat'; +import { formatMessage } from 'stream-chat'; import type { Channel, ChannelManager, @@ -183,13 +183,13 @@ const writeWorkspaceToUrl = (encoded: string, mode: 'push' | 'replace') => { // ---- resolve: token → live binding (async) ------------------------------------------------------ -// `client.channel(type, id)` returns the client's cached instance for that cid (the same one the -// channel-list query watches). When the caller has already waited for the list to settle (see +// `client.channelManager.get(cid)` returns the stored instance for that cid (the same one the +// channel-list query watches); `client.channel(type, id)` 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 page is returned unwatched and // `` watches it (the necessary, non-redundant fetch). const resolveChannel = (client: StreamChat, cid: string): Channel | undefined => { - const existing = Object.values(client.activeChannels).find((c) => c.cid === cid); + const existing = client.channelManager.get(cid); if (existing) return existing; const colon = cid.indexOf(':'); const type = cid.slice(0, colon); @@ -238,7 +238,7 @@ const resolveBinding = async ( } case 'thread': { // Paginator-first: a thread the thread-list already holds is reused as-is — no round-trip. - const listed = client.threads.threadsById[token.key]; + const listed = client.threads.get(token.key); if (listed) { return { binding: { key: listed.id ?? undefined, kind: 'thread', source: listed }, @@ -263,11 +263,13 @@ const resolveBinding = async ( // thread's channel config, members and read state are loaded when the panel renders. if (!channel.initialized) 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. return { binding: { key: token.key, kind: 'thread', - source: new StreamThread({ channel, client, parentMessage }), + source: client.threads.ensure({ channel, parentMessage }), }, channel, }; @@ -288,7 +290,7 @@ const resolveBinding = async ( // OWN `/query` watch and each thread need a `getThread` — duplicating what the list queries // (`/channels`, `/threads`) fetch a moment later. Waiting for the relevant list to settle first means // a listed channel is already `initialized` (so `` skips its watch) and a listed thread is -// already in `threadsById` (so no `getThread`). Entities genuinely absent from the loaded pages still +// already in `client.threads` (so no `getThread`). Entities genuinely absent from the loaded pages still // fall back to an explicit query. Warm Back/Forward keeps the paginators populated, so these waits // resolve on the first (immediate) subscribe callback — no added latency. @@ -335,9 +337,9 @@ const waitForChannelList = async (orchestrator: ChannelManager) => { await waitForState(paginator.state, (s) => s.items !== undefined); }; -/** Wait for the thread-list paginator to be ready (so listed threads are in `threadsById`). */ +/** Wait for the thread-list paginator to load its first page (so listed threads are in `client.threads`). */ const waitForThreadList = (client: StreamChat) => - waitForState(client.threads.state, (s) => s.ready); + waitForState(client.threads.paginator.state, (s) => s.items !== undefined); // ---- the sync component ------------------------------------------------------------------------- @@ -372,8 +374,8 @@ 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.activeChannels` - // and threads from `client.threads.threadsById`, both populated by the list paginators. Entities + // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channelManager.get()` + // 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. // @@ -384,17 +386,15 @@ export const WorkspaceUrlSync = () => { async (target: ParsedWorkspace) => { // Cold-load de-duplication: before binding, wait for the list paginators whose entities will // actually mount (only the *active* view's slots render). Then a listed channel is already - // watched and a listed thread already in `threadsById`, so resolution reuses them instead of + // watched and a listed thread already in `client.threads`, so resolution reuses them instead of // issuing a duplicate per-entity query. Warm Back/Forward resolves these waits immediately. // // The thread-list wait applies ONLY in the threads view: that is the only view whose // `ThreadList` mounts and activates `client.threads`, so it is the only case where waiting // de-duplicates against a list that is actually loading. A thread slot in the channels view is // a channel reply-thread that the thread list does NOT back — waiting there would just stall - // to the `waitForThreadList` timeout and then resolve anyway (and, if the list did load, reuse - // a manager instance whose replies aren't loaded, forcing a redundant `/replies`). So a - // channels-view thread skips the wait and resolves immediately via `getThreadAndHydrate` - // (fully hydrated, replies embedded → a single `/threads/` request). + // to the `waitForThreadList` timeout and then resolve anyway. So a channels-view thread skips + // the wait and resolves immediately from its parent message (see `resolveBinding`). const activeKinds = new Set( target.slots .filter((s) => s.view === target.activeView) diff --git a/examples/vite/src/Debug/StreamDebugHandles.tsx b/examples/vite/src/Debug/StreamDebugHandles.tsx index eedafa2aeb..d9bf21a0dc 100644 --- a/examples/vite/src/Debug/StreamDebugHandles.tsx +++ b/examples/vite/src/Debug/StreamDebugHandles.tsx @@ -12,7 +12,7 @@ import { listComposers } from './composerRegistry'; * a snapshot taken when the component last rendered. * * streamDebug.client // StreamChat - * streamDebug.channels // client.activeChannels, keyed by cid + * streamDebug.channels // loaded channels (client.channelManager.values()), keyed by cid * streamDebug.composers // [{ tag, label, composer }] incl. thread/edit composers * streamDebug.uploads // client.uploadManager.uploads, keyed by localMetadata.id * @@ -34,7 +34,12 @@ export const StreamDebugHandles = () => { const handles = { get channels() { - return client?.activeChannels; + return Object.fromEntries( + (client?.channelManager.values() ?? []).map((channel) => [ + channel.cid, + channel, + ]), + ); }, get client() { return client; diff --git a/examples/vite/src/Debug/composerRegistry.ts b/examples/vite/src/Debug/composerRegistry.ts index f6fda89c09..b6d1c7b0a2 100644 --- a/examples/vite/src/Debug/composerRegistry.ts +++ b/examples/vite/src/Debug/composerRegistry.ts @@ -46,7 +46,7 @@ export const listComposers = (client: StreamChat | undefined): ComposerEntry[] = const entries: ComposerEntry[] = []; - for (const channel of Object.values(client.activeChannels)) { + for (const channel of client.channelManager.values()) { if (!channel?.messageComposer) continue; entries.push({ composer: channel.messageComposer, diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index c6d74f422d..3644c78467 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -53,13 +53,9 @@ export const Channel = (props: PropsWithChildren) => { // Claim the channel while mounted (refcounted, so several consumers are fine). The client skips // re-seeding an active channel's message list on hydration, leaving the larger loaded window to - // `channel.reload()`. - useEffect(() => { - channel.activate(); - return () => { - channel.deactivate(); - }; - }, [channel]); + // `channel.reload()`. Activating also keeps the channel in the client's channel store for the + // session; the returned function only ends this mount's claim. + useEffect(() => channel.activate(), [channel]); // Keyed on the instance, not the cid: children subscribe to *this* channel's stores. const channelInstanceContextValue = useMemo(() => ({ channel }), [channel]); diff --git a/src/components/Channel/__tests__/channelInstanceAxis.test.tsx b/src/components/Channel/__tests__/channelInstanceAxis.test.tsx index 6767ed43d0..5674f25095 100644 --- a/src/components/Channel/__tests__/channelInstanceAxis.test.tsx +++ b/src/components/Channel/__tests__/channelInstanceAxis.test.tsx @@ -17,6 +17,7 @@ import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; import { initClientWithChannels } from '../../../mock-builders'; +import { Channel as StreamChannel } from 'stream-chat'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; const renderChannel = (client: StreamChat, channel: ChannelType) => ( @@ -39,10 +40,9 @@ describe('a replacement Channel instance for the same cid', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); - // Dropping the cache entry is how a genuinely new object for the same cid appears -- the same - // thing `disconnectUser` does to every channel. - delete client.activeChannels[first.cid]; - const second = client.channel('messaging', 'channel-a'); + // A second object for the same cid. The client creates one after it drops the first (a deletion, + // `disconnectUser`), which also tears the first down; built directly here, the first stays usable. + const second = new StreamChannel(client, 'messaging', 'channel-a', {}); return { client, first, second }; }; @@ -70,14 +70,13 @@ describe('a replacement Channel instance for the same cid', () => { it('is activated, and the previous instance released', async () => { const { client, first, second } = await setup(); - const activateSecond = vi.spyOn(second, 'activate'); - const deactivateFirst = vi.spyOn(first, 'deactivate'); const { rerender } = render(renderChannel(client, first)); + await waitFor(() => expect(first.active).toBe(true)); rerender(renderChannel(client, second)); - await waitFor(() => expect(activateSecond).toHaveBeenCalled()); - expect(deactivateFirst).toHaveBeenCalled(); + await waitFor(() => expect(second.active).toBe(true)); + expect(first.active).toBe(false); }); }); @@ -90,7 +89,6 @@ describe('the same Channel instance re-rendered', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); const activate = vi.spyOn(channel, 'activate'); - const deactivate = vi.spyOn(channel, 'deactivate'); const { rerender } = render(renderChannel(client, channel)); await waitFor(() => expect(activate).toHaveBeenCalledTimes(1)); @@ -98,7 +96,7 @@ describe('the same Channel instance re-rendered', () => { rerender(renderChannel(client, channel)); // A remount would release the channel and claim it again; the same instance keeps the same key. - expect(deactivate).not.toHaveBeenCalled(); expect(activate).toHaveBeenCalledTimes(1); + expect(channel.active).toBe(true); }); }); diff --git a/src/components/Channel/__tests__/channelSwitchReset.test.tsx b/src/components/Channel/__tests__/channelSwitchReset.test.tsx index 8846bcfa42..9a350fa282 100644 --- a/src/components/Channel/__tests__/channelSwitchReset.test.tsx +++ b/src/components/Channel/__tests__/channelSwitchReset.test.tsx @@ -17,6 +17,7 @@ import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; import { initClientWithChannels } from '../../../mock-builders'; +import { Channel as StreamChannel } from 'stream-chat'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; const renderChannel = (client: StreamChat, channel: ChannelType) => ( @@ -92,8 +93,8 @@ describe('switching channels', () => { channels: [first], client, } = await setupTwo(); - delete client.activeChannels[first.cid]; - const second = client.channel('messaging', 'channel-a'); + // a second object for the same cid, while the first stays usable + const second = new StreamChannel(client, 'messaging', 'channel-a', {}); const { rerender } = render(renderChannel(client, first)); const listBefore = messageListElement(); diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts index f0f2819274..964eac907d 100644 --- a/src/components/Chat/hooks/useCreateChatContext.ts +++ b/src/components/Chat/hooks/useCreateChatContext.ts @@ -15,7 +15,7 @@ export const useCreateChatContext = (value: ChatContextValue) => { useImageFlagEmojisOnWindows, } = value; - const clientValues = `${client.clientID}${Object.keys(client.activeChannels).length}${ + const clientValues = `${client.clientID}${client.channelManager.values().length}${ Object.keys(client.listeners).length }${client.mutedChannels.length} ${client.user?.id}`; diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 2780617a73..7bedfce449 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -379,7 +379,6 @@ const setup = async ({ channelData }: { channelData?: GenerateChannelOptions } = }), ); customChannel.initialized = true; - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, uploadFileSpy, uploadImageSpy }; }; @@ -397,7 +396,6 @@ const setupUploadRejected = async (error: unknown) => { const uploadFileSpy = vi .spyOn(customChannel, 'uploadFile') .mockRejectedValueOnce(error); - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, uploadFileSpy, uploadImageSpy }; }; diff --git a/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx b/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx index cab022748f..04c9ee5323 100644 --- a/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/ThreadMessageInput.test.tsx @@ -103,7 +103,6 @@ const setup = async ({ channelData }: any = {}) => { .mockResolvedValue(fromPartial({ draft: { message: { id: 'x', text: '' } } })); vi.spyOn(customChannel, 'deleteDraft').mockResolvedValue(fromPartial({})); customChannel.initialized = true; - customClient.activeChannels[customChannel.cid] = customChannel; return { customChannel, customClient, getDraftSpy, uploadFileSpy, uploadImageSpy }; }; diff --git a/src/components/MessageList/__tests__/messageSourceKey.test.ts b/src/components/MessageList/__tests__/messageSourceKey.test.ts index 613106379f..88e7868f9e 100644 --- a/src/components/MessageList/__tests__/messageSourceKey.test.ts +++ b/src/components/MessageList/__tests__/messageSourceKey.test.ts @@ -18,7 +18,7 @@ describe('getMessageSourceKey', () => { channelsData: [{ channel: { id: 'channel-a', type: 'messaging' } }], }); // A replacement instance for the same cid is a different object, and must key differently. - delete client.activeChannels[first.cid]; + client.channelManager.removeChannel(first.cid); const second = client.channel('messaging', 'channel-a'); expect(getMessageSourceKey({ channel: first })).toBe( From e4b9e183ea2d9d90dfe3c7a154c13c90a5761e24 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 2 Oct 2026 09:36:10 +0200 Subject: [PATCH 03/19] feat!: get channels through client.channelManager.ensure() stream-chat v10 replaces `client.channel()` with `client.channelManager.ensure({ type, id, data })`. The SDK, its tests and mock builders, the vite and tutorial examples, `AGENTS.md` and `ai-docs` move to it. The vite workspace URL restore resolves a channel with `ensure()` alone, since it already returns the stored instance. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- ai-docs/ai-migration-v14-v15.md | 8 +++-- ai-docs/instance-configuration.md | 4 +-- .../src/3-core-component-setup/App.tsx | 18 ++++++---- .../src/6-custom-ui-components/App.tsx | 16 +++++---- examples/tutorial/src/7-emoji-picker/App.tsx | 16 +++++---- .../optional-custom-attachment-type/App.tsx | 14 +++++--- .../tutorial/src/optional-livestream/App.tsx | 14 +++++--- examples/vite/src/App.tsx | 8 ++--- .../serverSideClient.ts | 2 +- .../serverSideMethods.ts | 2 +- .../tabs/Reactions/ReactionsTab.tsx | 2 +- .../vite/src/ChatLayout/WorkspaceUrlSync.tsx | 16 ++++----- .../src/SingleChannel/SingleChannelApp.tsx | 7 ++-- .../Attachment/__tests__/Card.test.tsx | 5 ++- .../Channel/__tests__/Channel.test.tsx | 10 ++++-- .../__tests__/ChannelHeader.test.tsx | 16 +++++++-- .../__tests__/ChannelLists.test.tsx | 4 ++- .../__tests__/ChannelListItemUI.test.tsx | 2 +- .../ChannelListItem/__tests__/utils.test.ts | 2 +- .../__tests__/useChannelPreviewInfo.test.tsx | 5 ++- .../__tests__/useIsChannelMuted.test.tsx | 20 ++++++++--- .../useMessageDeliveryStatus.test.tsx | 5 ++- .../Message/__tests__/Message.test.tsx | 2 +- .../hooks/__tests__/useDeleteHandler.test.tsx | 5 ++- .../__tests__/useReactionHandler.test.tsx | 5 ++- .../useMessageComposerController.test.tsx | 5 ++- .../__tests__/MessageList.test.tsx | 10 ++++-- .../__tests__/VirtualizedMessageList.test.tsx | 5 ++- .../__tests__/messageSourceKey.test.ts | 2 +- .../Search/SearchResults/SearchResultItem.tsx | 9 +++-- .../__tests__/SearchResultItem.test.tsx | 5 ++- .../__tests__/TypingIndicator.test.tsx | 2 +- src/mock-builders/utils.ts | 13 ++++--- .../ChannelMemberActions.defaults.tsx | 7 ++-- src/utils/__tests__/getChannel.test.ts | 35 +++++++++++++------ src/utils/getChannel.ts | 8 +++-- 37 files changed, 212 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30af291450..171d55930b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,7 +280,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.channel('messaging', channelId); +const channel = client.channelManager.ensure({ id: channelId, type: 'messaging' }); await channel.watch(); ``` diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index e05cb03254..441a518327 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -327,12 +327,16 @@ dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md). ```tsx import { getChannel } from 'stream-chat-react'; - const channel = client.channel('messaging', id, { members, custom }); + const channel = client.channelManager.ensure({ + data: { custom, members }, + id, + type: 'messaging', + }); if (!channel.initialized) await getChannel({ channel, client }); setChannel(channel); ``` - Two separate guards, both worth keeping. `client.channel()` returns the cached instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. + Two separate guards, both worth keeping. `client.channelManager.ensure()` (stream-chat v10's replacement for `client.channel()`) returns the stored instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. - **A direct message identified by members** → `getChannel({ client, type: 'messaging', members })` builds, watches and returns the instance. - **`channelQueryOptions`** → pass them to the watch you now own: `getChannel({ channel, client, options })`. diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index db7b881a9d..7e80615c82 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.channel()` / `client.queryChannels()` — which an app typically calls before +are constructed by `client.channelManager.ensure()` / `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. @@ -198,7 +198,7 @@ this cannot. If behaviour has to differ between channels, branch inside a single ```ts sendMessageRequest: async ({ localMessage, message, options }) => { - const channel = chatClient.channel(...); + const channel = chatClient.channelManager.ensure({ ... }); return isSupportChannel(localMessage.cid) ? { message: await sendViaProxy(message, options) } : { message: await sendNormally(message, options) }; diff --git a/examples/tutorial/src/3-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx index 1dea06b3a3..1f8bcb8471 100644 --- a/examples/tutorial/src/3-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -69,19 +69,23 @@ const App = () => { if (!client) return; const initChannel = async () => { - 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', + 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', + }, }, + 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.channel()` returns the cached instance for this cid, so a re-run of this effect + // `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, `getChannel` de-duplicates concurrent calls for the same channel, so two // overlapping runs still produce a single request. diff --git a/examples/tutorial/src/6-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx index 960109fdf1..c3b2dd7196 100644 --- a/examples/tutorial/src/6-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -215,13 +215,17 @@ const App = () => { if (!client) return; const initChannel = async () => { - 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', + 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', + }, }, + id: 'react-tutorial', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index 2326ad5816..8c94a83442 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -139,13 +139,17 @@ const App = () => { if (!client) return; const initChannel = async () => { - 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', + 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', + }, }, + id: 'react-tutorial', + type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/optional-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx index 248590fefc..0974494e02 100644 --- a/examples/tutorial/src/optional-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -182,12 +182,16 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channel('messaging', 'react-tutorial-products', { - members: [userId], - custom: { - image: 'https://getstream.io/random_png/?name=products', - name: 'Product recommendations', + const channel = client.channelManager.ensure({ + data: { + 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 diff --git a/examples/tutorial/src/optional-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx index bcbe396ff2..3c0886305f 100644 --- a/examples/tutorial/src/optional-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -42,12 +42,16 @@ const App = () => { if (!chatClient) return; const initChannel = async () => { - const spaceChannel = chatClient.channel('livestream', 'spacex', { - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://goo.gl/Zefkbx', - name: 'SpaceX launch discussion', + const spaceChannel = chatClient.channelManager.ensure({ + data: { + // 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 diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index bd1a1d9f20..a243828d78 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -395,10 +395,10 @@ const App = () => { targets.forEach(({ cid, messageId }) => { const separatorIndex = cid.indexOf(':'); - const channel = chatClient.channel( - cid.slice(0, separatorIndex), - cid.slice(separatorIndex + 1), - ); + const channel = chatClient.channelManager.ensure({ + id: cid.slice(separatorIndex + 1), + type: cid.slice(0, separatorIndex), + }); void (async () => { if (!channel.initialized) await getChannel({ channel, client: chatClient }); diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts index fe3960e733..60a0fa2e70 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.channel(...)` throws without a connected user. So callers must not build `Channel` + * `client.channelManager.ensure()` 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 e1d37d7bdf..5c6a83bac4 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.channel(...)` throws without a connected user, and the generated + * reasons: `client.channelManager.ensure()` 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 dede5bda9e..da9ff76d6b 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.channel('messaging', 'reactions-preview'), + () => client.channelManager.ensure({ id: 'reactions-preview', type: 'messaging' }), [client], ); diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index 8f2b50feb3..2a45588033 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -183,18 +183,16 @@ const writeWorkspaceToUrl = (encoded: string, mode: 'push' | 'replace') => { // ---- resolve: token → live binding (async) ------------------------------------------------------ -// `client.channelManager.get(cid)` returns the stored instance for that cid (the same one the -// channel-list query watches); `client.channel(type, id)` 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 page is returned unwatched and -// `` watches it (the necessary, non-redundant fetch). +// `client.channelManager.ensure()` 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 +// page is returned unwatched and `` watches it (the necessary, non-redundant fetch). const resolveChannel = (client: StreamChat, cid: string): Channel | undefined => { - const existing = client.channelManager.get(cid); - if (existing) return existing; const colon = cid.indexOf(':'); const type = cid.slice(0, colon); const id = cid.slice(colon + 1); - return type && id ? client.channel(type, id) : undefined; + return type && id ? client.channelManager.ensure({ id, type }) : undefined; }; /** @@ -374,7 +372,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.get()` + // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channelManager.ensure()` // 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/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index 86d947b6b1..1c576fa1bb 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.channel(type, id); + return client.channelManager.ensure({ id, type }); } const loadedChannel = channelManager?.paginators.flatMap( @@ -56,7 +56,10 @@ export const resolveSingleChannel = ({ )[0]; if (loadedChannel) return loadedChannel; - return client.channel('messaging', SINGLE_CHANNEL_DEFAULT_ID); + return client.channelManager.ensure({ + id: SINGLE_CHANNEL_DEFAULT_ID, + type: 'messaging', + }); }; const channelDisplayName = (channel: StreamChannel) => diff --git a/src/components/Attachment/__tests__/Card.test.tsx b/src/components/Attachment/__tests__/Card.test.tsx index a766261f68..18fb99d892 100644 --- a/src/components/Attachment/__tests__/Card.test.tsx +++ b/src/components/Attachment/__tests__/Card.test.tsx @@ -62,7 +62,10 @@ describe('Card', () => { beforeAll(async () => { chatClient = await getTestClientWithUser({ id: user.id }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channel('messaging', mockedChannel['id']); + channel = chatClient.channelManager.ensure({ + id: mockedChannel['id'], + type: 'messaging', + }); channel.query(); }); diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index f45ac0d4c1..b275c535cc 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -125,7 +125,10 @@ const initClient = async ({ const chatClient = await getTestClientWithUser(user); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); + const channel = chatClient.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); // `Channel` does not query any more -- whoever supplies the channel initializes it. await channel.watch(); @@ -215,7 +218,10 @@ describe('Channel', () => { // to it, but it does not fetch. A channel that arrives unqueried stays that way, and its // children render whatever an empty channel renders. const { chatClient } = await setup(); - const unqueried = chatClient.channel('messaging', 'never-queried'); + const unqueried = chatClient.channelManager.ensure({ + id: 'never-queried', + type: 'messaging', + }); 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 a89d334c6f..c75dd059ea 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx @@ -88,7 +88,11 @@ async function renderComponent({ testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel(channelType, testChannel1.channel.id, channelData); + const channel = client.channelManager.ensure({ + data: channelData, + id: testChannel1.channel.id, + type: channelType, + }); await channel.query(); return renderComponentBase({ channel, client, props }); @@ -198,7 +202,10 @@ describe('ChannelHeader', () => { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState }); useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel('messaging', testChannel1.channel.id); + const channel = client.channelManager.ensure({ + id: testChannel1.channel.id, + type: 'messaging', + }); await channel.query(); renderComponentBase({ @@ -222,7 +229,10 @@ describe('ChannelHeader', () => { testChannel1 = generateChannel({ ...defaultChannelState }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channel('messaging', testChannel1.channel.id); + const channel = client.channelManager.ensure({ + id: testChannel1.channel.id, + type: 'messaging', + }); await channel.query(); render( diff --git a/src/components/ChannelList/__tests__/ChannelLists.test.tsx b/src/components/ChannelList/__tests__/ChannelLists.test.tsx index 1f8f20dc25..bc3201cafa 100644 --- a/src/components/ChannelList/__tests__/ChannelLists.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelLists.test.tsx @@ -38,7 +38,9 @@ const seededPaginator = (client: StreamChat, id: string) => { const paginator = new ChannelPaginator({ client, id }); paginator.setItems({ isLastPage: true, - valueOrFactory: [client.channel('messaging', id.replace(':', '-'))], + valueOrFactory: [ + client.channelManager.ensure({ id: id.replace(':', '-'), type: 'messaging' }), + ], }); return paginator; }; diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx index ca18968fbe..2ee3590ec6 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx @@ -88,7 +88,7 @@ describe('ChannelPreviewMessenger', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - channel = chatClient.channel('messaging'); + channel = chatClient.channelManager.ensure({ type: 'messaging' }); await channel.watch(); }; diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 5c8e52f4b4..354e824199 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.channel('messaging'); + const channel = chatClient.channelManager.ensure({ type: '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 fee3264699..837e6044d8 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx @@ -32,7 +32,10 @@ const getClientAndChannel = async (channelOverrides = {}) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); 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 4f27b9f91a..d1673b9a53 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -33,7 +33,10 @@ describe('useIsChannelMuted', () => { // channel.muteStatus() this hook used to call throws `_checkInitialized` on such a // channel and crashed the app when it was rendered in the ChannelList (issue #2474). // Reading the reactive `muteStatus` slice is safe instead. - const channel = client.channel('messaging', 'never-watched-channel'); + const channel = client.channelManager.ensure({ + id: 'never-watched-channel', + type: 'messaging', + }); expect(channel.initialized).toBe(false); @@ -48,7 +51,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { @@ -62,7 +68,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); expect(channel.initialized).toBe(true); @@ -83,7 +92,10 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); 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 1928f9eb9b..c06615eb87 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -41,7 +41,10 @@ const getClientAndChannel = async (channelData = {}, user = ownUser) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); return { diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 495d0f1013..b61a3db2a0 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -117,7 +117,7 @@ async function renderComponent({ }); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel(type, channelData.channel.id); + channel = client.channelManager.ensure({ id: channelData.channel.id, type }); 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 f4c15a4d85..4129e5087d 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx @@ -53,7 +53,10 @@ describe('useDeleteHandler custom hook', () => { client = await getTestClientWithUser(generateUser()); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel('messaging', channelData.channel.id); + channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); }); afterEach(() => { diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index bd8507544a..1a1628bed7 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -63,7 +63,10 @@ describe('useReactionHandler custom hook', () => { client = await getTestClientWithUser(alice); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channel('messaging', channelData.channel.id); + channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); }); afterEach(() => { diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx index ef894b95c4..a691849e8b 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -65,7 +65,10 @@ describe('useMessageComposerController', () => { client = await getTestClientWithUser({ id: 'test-user' }); const mockedChannelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - channel = client.channel('messaging', mockedChannelData.channel.id); + channel = client.channelManager.ensure({ + id: mockedChannelData.channel.id, + type: 'messaging', + }); await channel.watch(); }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 009ca7ee7b..e80de0e448 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -224,7 +224,10 @@ describe('MessageList', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); - channel = chatClient.channel('messaging', mockedChannelData['id']); + channel = chatClient.channelManager.ensure({ + id: mockedChannelData['id'], + type: 'messaging', + }); await channel.watch(); markReadMock = vi @@ -1973,7 +1976,10 @@ 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.channel('messaging', 'never-watched'); + const bootstrapping = client.channelManager.ensure({ + id: 'never-watched', + type: 'messaging', + }); const { rerender } = render(renderChannel(client, channelA)); raise(client); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 0b39020b58..c24dff05a6 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -86,7 +86,10 @@ 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.channel('messaging', mockedChannel.channel.id); + const channel = client.channelManager.ensure({ + id: mockedChannel.channel.id, + type: 'messaging', + }); await channel.watch(); return { channel, client }; diff --git a/src/components/MessageList/__tests__/messageSourceKey.test.ts b/src/components/MessageList/__tests__/messageSourceKey.test.ts index 88e7868f9e..fc295d0d57 100644 --- a/src/components/MessageList/__tests__/messageSourceKey.test.ts +++ b/src/components/MessageList/__tests__/messageSourceKey.test.ts @@ -19,7 +19,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.channel('messaging', 'channel-a'); + const second = client.channelManager.ensure({ id: 'channel-a', type: 'messaging' }); 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 985e40f780..0a57619d1a 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -85,7 +85,7 @@ export const MessageSearchResultItem = ({ const { channel: channelData } = item; const type = channelData?.type ?? 'unknown'; const id = channelData?.id ?? 'unknown'; - return client.channel(type, id); + return client.channelManager.ensure({ id, type }); }, [client, item]); const channelOpenInSlot = isChannelActive(channel?.cid ?? undefined); @@ -145,8 +145,11 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro onSelect(event); return; } - const newChannel = client.channel(directMessagingChannelType, { - members: [{ user_id: client.userId as string }, { user_id: item.id }], + const newChannel = client.channelManager.ensure({ + data: { + members: [{ user_id: client.userId as string }, { user_id: item.id }], + }, + type: directMessagingChannelType, }); newChannel.watch(); // Default: open the DM channel in the workspace, forwarding the event so a consumer overriding diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 6feefc1a2b..1fa1a0bd68 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -174,7 +174,10 @@ describe('SearchResultItem Components', () => { }); const { id, type } = messageResponseData.channel; const jumpToMessage = vi - .spyOn(client.channel(type, id).messagePaginator, 'jumpToMessage') + .spyOn( + client.channelManager.ensure({ id, type }).messagePaginator, + 'jumpToMessage', + ) .mockResolvedValue(true); await act(() => { diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx index d550c37416..9c695248ff 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx @@ -237,7 +237,7 @@ describe('TypingIndicator', () => { channel: { config: fromPartial({ typing_events: true }) }, }); useMockedApis(client, [getOrCreateChannelApi(ch)]); - channel = client.channel('messaging', ch.id); + channel = client.channelManager.ensure({ id: ch.id, type: 'messaging' }); await channel.watch(); }); diff --git a/src/mock-builders/utils.ts b/src/mock-builders/utils.ts index 03b97113e2..93a22e672b 100644 --- a/src/mock-builders/utils.ts +++ b/src/mock-builders/utils.ts @@ -48,7 +48,10 @@ 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.channel('messaging', mockedChannel['id']); + const channel = client.channelManager.ensure({ + id: mockedChannel['id'], + type: 'messaging', + }); await channel.watch(); return { channel, client, users }; @@ -70,10 +73,10 @@ export const initChannelFromData = async ({ // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - const channel = client.channel( - mockedChannelData.channel.type, - mockedChannelData.channel.id, - ); + const channel = client.channelManager.ensure({ + id: mockedChannelData.channel.id, + type: mockedChannelData.channel.type, + }); await channel.watch(); // Written into the client's store rather than stubbed onto the channel: `getConfig()` is gone, and // its replacement `serverConfig` is a getter reading this store. Going through the store also drives diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index 7c8eeeff3d..a40d8cd63c 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -232,8 +232,11 @@ const SendDirectMessageAction = () => { setIsSending(true); try { - const directMessageChannel = client.channel(channel.type, { - members: [client.userID, targetUserId].map((user_id) => ({ user_id })), + const directMessageChannel = client.channelManager.ensure({ + data: { + members: [client.userID, targetUserId].map((user_id) => ({ user_id })), + }, + type: channel.type, }); await directMessageChannel.watch(); // Selection is one navigation model: open the DM into a layout slot, then route it into diff --git a/src/utils/__tests__/getChannel.test.ts b/src/utils/__tests__/getChannel.test.ts index 896dd79185..d35c3f5f07 100644 --- a/src/utils/__tests__/getChannel.test.ts +++ b/src/utils/__tests__/getChannel.test.ts @@ -40,28 +40,37 @@ describe('getChannel', () => { }); it('throws an error if channel without with id and no channel members array are provided', async () => { - const channel = client.channel('type', undefined); + const channel = client.channelManager.ensure({ type: 'type' }); await expect(getChannel({ channel, client })).rejects.toThrow( 'Channel ID or channel members array have to be provided to query a channel.', ); }); it('throws an error if channel without with id but with channel members array are provided', async () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); await expect(getChannel({ channel, client })).rejects.toThrow( 'Channel ID or channel members array have to be provided to query a channel.', ); }); it('calls channel.watch for a given channel type and id if channel query not already in progress', async () => { - const channel = client.channel('messaging', channelData.channel.id); + const channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ client, id: channel.id, type: channel.type }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel type and id if channel query already in progress', () => { - const channel = client.channel('messaging', channelData.channel.id); + const channel = client.channelManager.ensure({ + id: channelData.channel.id, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ client, id: channel.id, type: channel.type }); getChannel({ client, id: channel.id, type: channel.type }); @@ -69,14 +78,20 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel type and members array if channel query not already in progress', async () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ client, members: memberIds, type: channelData.channel.type }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel type and members array if channel query already in progress', () => { - const channel = client.channel('messaging', { members: memberIds }); + const channel = client.channelManager.ensure({ + data: { members: memberIds }, + type: 'messaging', + }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ client, members: memberIds, type: channelData.channel.type }); getChannel({ client, members: memberIds, type: channelData.channel.type }); @@ -84,14 +99,14 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel object with id and type if channel query not already in progress', async () => { - const channel = client.channel('messaging', 'id'); + const channel = client.channelManager.ensure({ id: 'id', type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ channel, client }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel object with id and type if channel query already in progress', () => { - const channel = client.channel('messaging', 'id'); + const channel = client.channelManager.ensure({ id: 'id', type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ channel, client }); getChannel({ channel, client }); @@ -99,14 +114,14 @@ describe('getChannel', () => { }); it('calls channel.watch for a given channel object with type and members array if channel query not already in progress', async () => { - const channel = client.channel('messaging', undefined); + const channel = client.channelManager.ensure({ type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValueOnce(undefined!); await getChannel({ channel, client, members: memberIds }); expect(channel.watch).toHaveBeenCalledTimes(1); }); it('does not call channel.watch for a given channel object with type and members array if channel query already in progress', () => { - const channel = client.channel('messaging', undefined); + const channel = client.channelManager.ensure({ type: 'messaging' }); vi.spyOn(channel, 'watch').mockResolvedValue(undefined!); getChannel({ channel, client, members: memberIds }); getChannel({ channel, client, members: memberIds }); diff --git a/src/utils/getChannel.ts b/src/utils/getChannel.ts index 61c312421d..d708b5c96d 100644 --- a/src/utils/getChannel.ts +++ b/src/utils/getChannel.ts @@ -48,8 +48,12 @@ export const getChannel = async ({ if (!type) { throw new Error('Channel or channel type have to be provided to query a channel.'); } - theChannel = client.channel(type, id, { - members: members?.map((user_id) => ({ user_id })), + theChannel = client.channelManager.ensure({ + data: { + members: members?.map((user_id) => ({ user_id })), + }, + id, + type, }); } From 64d9c80bc0d61f8f2241b0a219d04a40528b2f63 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 2 Oct 2026 09:52:13 +0200 Subject: [PATCH 04/19] chore(examples): get channels through channelManager.ensure() in docs-playwright scripts The screenshot scripts call the app's client inside the page, so they move from the removed `client.channel()` to `client.channelManager.ensure({ type, id, data })`. The seed script found the client in the React tree by its `channel` method; it now checks for `channelManager`. Co-Authored-By: Claude Opus 5.5 --- .../screenshot-attachment-actions.ts | 4 +-- .../vite/docs-playwright/screenshot-misc.ts | 4 +-- .../docs-playwright/screenshot-reactions.ts | 4 +-- .../screenshot-system-message.ts | 4 +-- .../docs-playwright/screenshot-variants.ts | 4 +-- .../screenshot-voice-recording.ts | 4 +-- .../seed-channel-and-screenshot.mjs | 25 +++++++++++++------ 7 files changed, 30 insertions(+), 19 deletions(-) diff --git a/examples/vite/docs-playwright/screenshot-attachment-actions.ts b/examples/vite/docs-playwright/screenshot-attachment-actions.ts index 223210918e..0a7821a646 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.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { 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 d64eb75f01..9167e969b7 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.channel('messaging', ${JSON.stringify(channelId)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(channelId)}, data: { 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 de3119ac29..e77359a4f8 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.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { 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 bf62dae5e0..acfb52c67b 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.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { 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 cfd6098fa7..56c1867145 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.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { 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 be35fa5366..1b7b88b98d 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.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { + var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { 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 ac771ff56b..98a21581f0 100644 --- a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs +++ b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs @@ -627,7 +627,8 @@ async function run() { const key = Object.keys(el).find((k) => k.startsWith('__reactFiber')); let fiber = el[key]; while (fiber) { - if (fiber.memoizedProps?.client?.channel) return fiber.memoizedProps.client; + if (fiber.memoizedProps?.client?.channelManager) + return fiber.memoizedProps.client; fiber = fiber.return; } return null; @@ -635,9 +636,13 @@ async function run() { const client = findClient(); if (!client) return { ok: false, reason: 'no client' }; try { - const ch = client.channel('messaging', channelId, { - members: [userA, userB], - name: 'Design redesign — v14', + const ch = client.channelManager.ensure({ + type: 'messaging', + id: channelId, + data: { + members: [userA, userB], + name: 'Design redesign — v14', + }, }); await ch.watch(); const existingCount = (ch.state.messages || []).length; @@ -713,7 +718,7 @@ async function run() { let fiber = el[key]; let client = null; while (fiber) { - if (fiber.memoizedProps?.client?.channel) { + if (fiber.memoizedProps?.client?.channelManager) { client = fiber.memoizedProps.client; break; } @@ -756,13 +761,19 @@ async function run() { const results = []; for (const [channelId, data] of Object.entries(channelImages)) { try { - const ch = client.channel('public', channelId); + const ch = client.channelManager.ensure({ + type: 'public', + id: channelId, + }); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok'); } catch (e) { // Try messaging type too try { - const ch = client.channel('messaging', channelId); + const ch = client.channelManager.ensure({ + type: 'messaging', + id: channelId, + }); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok (messaging)'); } catch (e2) { From 9202bd5f37f171b90a3ee76a392437d59062cdad Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 5 Oct 2026 13:24:37 +0200 Subject: [PATCH 05/19] feat: watch opened channel search results and tie search subscriptions to Chat stream-chat's channel search now queries with watch: false, so a result is a preview. Opening a channel search result watches it unless it is already watched. Chat calls searchController.registerSubscriptions() on mount and dispose() on cleanup for the controller it creates, so an active channel search claims its results in the channel store only while mounted (StrictMode-safe). A controller supplied by the app is left to the app. ai-docs: ends its activation with the release function returned by channel.activate(); channel.deactivate() no longer exists. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 --- ai-docs/ai-migration-v14-v15.md | 9 +-- src/components/Chat/Chat.tsx | 12 +++- src/components/Chat/__tests__/Chat.test.tsx | 60 ++++++++++++++++++- .../Search/SearchResults/SearchResultItem.tsx | 12 +++- .../__tests__/SearchResultItem.test.tsx | 33 ++++++++++ 5 files changed, 117 insertions(+), 9 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 441a518327..dda0238ddc 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -913,8 +913,9 @@ reads `muteStatus` instead of subscribing to `notification.channel_mutes_updated ### `` declares the channel active, and owns its message window -`` now calls `channel.activate()` on mount and `channel.deactivate()` on unmount (refcounted, -so several consumers can hold one instance), and calls `channel.reload()` on `connection.recovered`. +`` now calls `channel.activate()` on mount and the release function it returns on unmount +(refcounted, so several consumers can hold one instance), and calls `channel.reload()` on +`connection.recovered`. These two go together and **a custom channel surface must do both**. While a channel is active, the client deliberately skips re-seeding its message list on channel-list hydration and on reconnect — its @@ -923,8 +924,8 @@ Nothing in the client calls `reload()` for you: ```ts useEffect(() => { - channel.activate(); - return () => channel.deactivate(); + const release = channel.activate(); + return release; }, [channel]); client.on('connection.recovered', () => { diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index c31919daff..7ef870e022 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import type { StreamChat } from 'stream-chat'; import { ChannelSearchSource, @@ -143,6 +143,16 @@ export const Chat = (props: PropsWithChildren) => { [client, customChannelSearchController], ); + // The controller created here is this component's to release: its configuration subscription, and + // a channel search's hold on its results in the channel store. One passed in belongs to the app. + // Disposing in the cleanup is safe under StrictMode, whose second mount of the same instance + // takes everything back through `registerSubscriptions()`. + useEffect(() => { + if (customChannelSearchController) return; + searchController.registerSubscriptions(); + return () => searchController.dispose(); + }, [customChannelSearchController, searchController]); + const chatContextValue = useCreateChatContext({ channelManager: client.channelManager, client, diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 8f6a6836a0..f4108a827b 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { OwnUserResponse, StreamChat } from 'stream-chat'; -import { ChannelPaginator } from 'stream-chat'; +import { ChannelPaginator, SearchController } from 'stream-chat'; import { Chat } from '..'; @@ -223,6 +223,64 @@ describe('Chat', () => { }); }); + describe('search controller', () => { + it('disposes the controller it created when it unmounts', async () => { + let controller: ChatContextValue['searchController'] | undefined; + const { unmount } = render( + + { + controller = ctx.searchController; + }} + /> + , + ); + await waitFor(() => expect(controller).toBeDefined()); + const dispose = vi.spyOn(controller as SearchController, 'dispose'); + + unmount(); + + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('leaves a controller passed in to the app', () => { + const controller = new SearchController({ client: chatClient }); + const dispose = vi.spyOn(controller, 'dispose'); + const registerSubscriptions = vi.spyOn(controller, 'registerSubscriptions'); + const { unmount } = render( + +
+ , + ); + + unmount(); + + expect(registerSubscriptions).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('takes back what it released when StrictMode mounts it a second time', async () => { + let controller: ChatContextValue['searchController'] | undefined; + render( + + + { + controller = ctx.searchController; + }} + /> + + , + ); + await waitFor(() => expect(controller).toBeDefined()); + + // a disposed controller would stop hearing client.config + chatClient.config.set({ searchController: { keepSingleActiveSource: false } }); + expect(controller?.config.keepSingleActiveSource).toBe(false); + chatClient.config.reset('searchController'); + }); + }); + describe('channel manager', () => { it('exposes the client channel manager on the context', async () => { const client = getTestClient(); diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 0a57619d1a..8fdf99d243 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import type { ComponentType } from 'react'; -import { convertTimestampToDate, formatMessage } from 'stream-chat'; +import { ChannelWatchStatus, convertTimestampToDate, formatMessage } from 'stream-chat'; import type { Channel, ChannelResponse, @@ -21,6 +21,7 @@ import { } from '../../../context'; import { Timestamp } from '../../../components/Message/Timestamp'; import { useStateStore } from '../../../store'; +import { getChannel } from '../../../utils/getChannel'; type SearchResultMessage = MessageResponse & { channel?: ChannelResponse }; @@ -40,7 +41,7 @@ export const ChannelSearchResultItem = ({ onSelect, }: ChannelSearchResultItemProps) => { const { openChannel } = useWorkspaceNavigation(); - const { channelManager } = useChatContext(); + const { channelManager, client } = useChatContext(); const handleSelect = useCallback( (event: React.MouseEvent) => { @@ -51,11 +52,16 @@ export const ChannelSearchResultItem = ({ // Default: open the channel in the workspace, forwarding the event so a consumer overriding // `openChannel` (e.g. via ChatView's `deriveWorkspaceNavigation`) can honor ⌘/ctrl-click. openChannel(item, { event }); + // Channel search doesn't watch its results, and `Channel` doesn't watch either, so the opened + // channel is watched here to receive its events. + if (item.watchStatus !== ChannelWatchStatus.Watching) { + void getChannel({ channel: item, client }).catch(() => undefined); + } // Route the channel into the list(s) that should own it (the channel manager dedupes by cid, // inserts in sort order, and honors ownership/filters) so it appears without a re-query. channelManager.ingestChannel(item); }, - [item, openChannel, channelManager, onSelect], + [item, openChannel, channelManager, client, onSelect], ); return ( diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 1fa1a0bd68..7ac556e0d1 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { fromPartial } from '@total-typescript/shoehorn'; +import { ChannelWatchStatus } from 'stream-chat'; import { ChannelSearchResultItem, @@ -131,6 +132,38 @@ describe('SearchResultItem Components', () => { expect(mockIngestChannel).toHaveBeenCalledTimes(1); }); + it('watches the opened channel when the search did not', async () => { + const channelSearchData = generateChannel(); + const { client } = await renderComponent({ + channelSearchData, + SearchResultItemComponent, + }); + const channel = client.channelManager.get(channelSearchData.channel.cid); + if (!channel) throw new Error('the result channel is not stored'); + channel.watchStatus = ChannelWatchStatus.NotWatching; + const watch = vi.spyOn(channel, 'watch').mockResolvedValue(undefined as never); + + fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); + + expect(watch).toHaveBeenCalledTimes(1); + }); + + it('does not watch an opened channel that is already watched', async () => { + const channelSearchData = generateChannel(); + const { client } = await renderComponent({ + channelSearchData, + SearchResultItemComponent, + }); + const channel = client.channelManager.get(channelSearchData.channel.cid); + if (!channel) throw new Error('the result channel is not stored'); + channel.watchStatus = ChannelWatchStatus.Watching; + const watch = vi.spyOn(channel, 'watch'); + + fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); + + expect(watch).not.toHaveBeenCalled(); + }); + it('runs a custom onSelect instead of the default open', async () => { const channelSearchData = generateChannel(); const onSelect = vi.fn(); From 757897c89442f468da3969d7d994977be2812178 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 5 Oct 2026 13:24:42 +0200 Subject: [PATCH 06/19] chore(examples): add a channel store debug dialog to the vite app The Channel Store dialog (Actions menu) lists every stored channel with what keeps it, from channelManager.getChannelUsage(), and offers Release now and per-channel Stop watching / Watch. The connection dev panel's "socket: take down" now marks watches interrupted, as a real drop does, so recovery and the channel store see the dropped watches. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 --- .../AppSettings/ActionsMenu/ActionsMenu.tsx | 24 ++- .../ConnectionDevPanel/ConnectionDevPanel.tsx | 4 + .../vite/src/Debug/ChannelStoreDialog.tsx | 182 ++++++++++++++++++ .../vite/src/Debug/ComposerInspector.scss | 34 ++++ examples/vite/src/Debug/index.ts | 5 + 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 examples/vite/src/Debug/ChannelStoreDialog.tsx diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx index af274d9403..41f55bb166 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -72,13 +72,19 @@ const ActionsMenuButton = ({
); -import { ComposerStateDialog, useComposerStateDialog } from '../../Debug'; +import { + ChannelStoreDialog, + ComposerStateDialog, + useChannelStoreDialog, + useComposerStateDialog, +} from '../../Debug'; import { usePersistentDialog } from './usePersistentDialog'; export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { // Shared hook so the dialog is registered with closeOnClickOutside disabled regardless of // which of the two call sites reaches getOrCreate first. const { dialog: composerStateDialog } = useComposerStateDialog(); + const { dialog: channelStoreDialog } = useChannelStoreDialog(); const [menuButtonElement, setMenuButtonElement] = useState( null, ); @@ -118,6 +124,7 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { + {serverSideClientEnabled && ( )} @@ -126,6 +133,7 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { + {serverSideClientEnabled && ( )} @@ -202,3 +210,17 @@ function TriggerComposerStateInspectorAction({ onTrigger }: { onTrigger: () => v /> ); } + +function TriggerChannelStoreAction({ onTrigger }: { onTrigger: () => void }) { + const { closeMenu } = useContextMenuContext(); + + return ( + { + closeMenu(); + onTrigger(); + }} + /> + ); +} diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx index 06a69e4d71..50657ac4f7 100644 --- a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx +++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx @@ -90,6 +90,10 @@ export const ConnectionDevPanel = () => { if (!nextHealthy) { parkedConnectionId.current = client.connectionIdManager.connectionId; client.connectionIdManager.invalidate(); + // The server drops watches with the socket. A real drop marks them interrupted in + // `_setHealth(false)`, which this panel bypasses, so recovery would see nothing to + // restore and the channel store would treat dropped watches as live. + client.channelManager.markChannelsWatchInterrupted(); } else if (parkedConnectionId.current) { client.connectionIdManager.resolveConnectionId(parkedConnectionId.current); parkedConnectionId.current = undefined; diff --git a/examples/vite/src/Debug/ChannelStoreDialog.tsx b/examples/vite/src/Debug/ChannelStoreDialog.tsx new file mode 100644 index 0000000000..4b8edc1d6c --- /dev/null +++ b/examples/vite/src/Debug/ChannelStoreDialog.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { ChannelUsage, StreamChat } from 'stream-chat'; +import { ChannelWatchStatus } from 'stream-chat'; +import { Button, Prompt, useChatContext, useDialogIsOpen } from 'stream-chat-react'; + +import { DraggableDialog } from '../AppSettings/ActionsMenu/DraggableDialog'; +import { usePersistentDialog } from '../AppSettings/ActionsMenu/usePersistentDialog'; + +export const channelStoreDialogId = 'app-channel-store-dialog'; + +/** Same registration rule as the composer inspector: see `usePersistentDialog`. */ +export const useChannelStoreDialog = () => usePersistentDialog(channelStoreDialogId); + +// The channel store sends no change notifications, so the table re-reads it on this interval. +const REFRESH_INTERVAL_MS = 1000; + +const useChannelUsage = (client: StreamChat, enabled: boolean) => { + const [usage, setUsage] = useState([]); + const refresh = useCallback( + () => setUsage(client.channelManager.getChannelUsage()), + [client], + ); + + useEffect(() => { + if (!enabled) return; + refresh(); + const interval = setInterval(refresh, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [enabled, refresh]); + + return { refresh, usage }; +}; + +const ChannelUsageRow = ({ + onChange, + usage: { channel, key, keptBy }, +}: { + onChange: () => void; + usage: ChannelUsage; +}) => { + const watched = channel.watchStatus !== ChannelWatchStatus.NotWatching; + + return ( + + +
{channel.id ?? '(no id yet)'}
+
{key}
+ + {channel.watchStatus} + + {keptBy.length ? ( +
+ {keptBy.map((reason) => ( + + {reason} + + ))} +
+ ) : ( + + released at next release + + )} + + + {watched ? ( + + ) : ( + + )} + + + ); +}; + +/** + * Lists every channel in `client.channelManager`'s store with what keeps it (its own state and the + * names of its holders), as reported by `channelManager.getChannelUsage()`. "Release now" calls + * `releaseUnusedChannels()`, which otherwise runs when the lists reload or the connection recovers; + * "Stop watching" makes a channel unwatched, which list channels never are on their own. + */ +export const ChannelStoreDialog = ({ + referenceElement, +}: { + referenceElement: HTMLElement | null; +}) => { + const { client } = useChatContext(); + const { dialog, dialogManager } = useChannelStoreDialog(); + const dialogIsOpen = useDialogIsOpen(channelStoreDialogId, dialogManager?.id); + const { refresh, usage } = useChannelUsage(client, dialogIsOpen); + + const closeDialog = useCallback(() => { + dialog.close(); + }, [dialog]); + + const released = usage.filter(({ keptBy }) => !keptBy.length).length; + + return ( + + +
+ + {usage.length} stored, {released} released at next release + + +
+ {usage.length ? ( + + + + + + + + + + {usage.map((entry) => ( + + ))} + +
ChannelWatch statusKept because +
+ ) : ( +
No stored channels.
+ )} +
+
+ ); +}; diff --git a/examples/vite/src/Debug/ComposerInspector.scss b/examples/vite/src/Debug/ComposerInspector.scss index ec903282b2..841f17dc0c 100644 --- a/examples/vite/src/Debug/ComposerInspector.scss +++ b/examples/vite/src/Debug/ComposerInspector.scss @@ -120,3 +120,37 @@ .app__composer-inspector__copy { align-self: flex-start; } + +/* Channel store dialog: wider than the composer inspector, for the table. */ +.app__channel-store-dialog__shell { + display: flex; + flex-direction: column; + width: min(720px, calc(100vw - 32px)); + max-height: min(70vh, 720px); +} + +.app__channel-store__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.app__channel-store__table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 4px 6px; + border-bottom: 1px solid var(--str-chat__border-core-default, rgb(0 0 0 / 15%)); + text-align: left; + vertical-align: top; + } +} + +.app__channel-store__key { + opacity: 0.6; + font-family: ui-monospace, monospace; + word-break: break-all; +} diff --git a/examples/vite/src/Debug/index.ts b/examples/vite/src/Debug/index.ts index 4629bdfb90..7a9ef56812 100644 --- a/examples/vite/src/Debug/index.ts +++ b/examples/vite/src/Debug/index.ts @@ -1,3 +1,8 @@ +export { + ChannelStoreDialog, + channelStoreDialogId, + useChannelStoreDialog, +} from './ChannelStoreDialog'; export { ComposerStateDialog, composerStateDialogId, From 736e8339a37f1d0100a42773fe86a8d4116de8a3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 5 Oct 2026 15:57:30 +0200 Subject: [PATCH 07/19] chore(examples): add a user profile dialog to the vite app A profile button below the sidebar's settings group shows the connected user's avatar (the SDK's Avatar, filling the button) and opens a dialog to edit the user: - clicking the avatar opens the file picker; the chosen image is uploaded with client.uploadImage() and its URL saved as the user's image, and "Remove photo" clears it - the name and each custom property are edited in place, and a custom property can be removed or added (built-in field names are refused; values that are valid JSON are stored as such) Every change is saved with client.updateUsersPartial() under the user's own token, so no app secret is needed; role is shown read-only. The dialog follows user.updated events for the connected user, since client.user isn't reactive. Co-Authored-By: Claude Opus 5.5 --- .../src/Sidebar/ChatViewSelectorItemSet.tsx | 2 + .../vite/src/UserProfile/UserDetailDialog.tsx | 434 ++++++++++++++++++ .../vite/src/UserProfile/UserProfile.scss | 173 +++++++ .../src/UserProfile/UserProfileButton.tsx | 34 ++ examples/vite/src/UserProfile/index.ts | 3 + examples/vite/src/UserProfile/useOwnUser.ts | 50 ++ examples/vite/src/index.scss | 1 + 7 files changed, 697 insertions(+) create mode 100644 examples/vite/src/UserProfile/UserDetailDialog.tsx create mode 100644 examples/vite/src/UserProfile/UserProfile.scss create mode 100644 examples/vite/src/UserProfile/UserProfileButton.tsx create mode 100644 examples/vite/src/UserProfile/index.ts create mode 100644 examples/vite/src/UserProfile/useOwnUser.ts diff --git a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx index bc4d60b48c..67ea05ba89 100644 --- a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx +++ b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx @@ -3,8 +3,10 @@ import { defaultChatViewSelectorItemSet, } from 'stream-chat-react/slot-layout'; import { AppSettings } from '../AppSettings'; +import { UserProfileButton } from '../UserProfile'; export const chatViewSelectorItemSet: ChatViewSelectorEntry[] = [ ...defaultChatViewSelectorItemSet, { Component: AppSettings, type: 'settings' }, + { Component: UserProfileButton, type: 'profile' }, ]; diff --git a/examples/vite/src/UserProfile/UserDetailDialog.tsx b/examples/vite/src/UserProfile/UserDetailDialog.tsx new file mode 100644 index 0000000000..fef6c813d4 --- /dev/null +++ b/examples/vite/src/UserProfile/UserDetailDialog.tsx @@ -0,0 +1,434 @@ +import { + type ChangeEvent, + type FormEvent, + type KeyboardEvent, + useRef, + useState, +} from 'react'; +import { + Avatar, + Button, + IconCamera, + IconCheckmark, + IconDelete, + IconEdit, + IconLoading, + IconPlus, + IconXmark, + Prompt, + TextInput, + useChatContext, +} from 'stream-chat-react'; + +import { type ProfileUser, toProfileUser } from './useOwnUser'; + +/** + * Fields the API keeps outside `custom`. A custom property can't take one of these names: the + * server would write the built-in field (or reject the update) instead. + */ +const RESERVED_KEYS = new Set([ + 'banned', + 'created_at', + 'custom', + 'deactivated_at', + 'deleted_at', + 'id', + 'image', + 'invisible', + 'language', + 'last_active', + 'name', + 'online', + 'privacy_settings', + 'revoke_tokens_issued_before', + 'role', + 'teams', + 'teams_role', + 'updated_at', +]); + +/** Shown as typed for text; other JSON values (numbers, booleans, objects) as JSON. */ +const formatValue = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value); + +/** Text that is valid JSON for a non-string value is stored as that value; anything else as text. */ +const parseValue = (text: string): unknown => { + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'string' ? text : parsed; + } catch { + return text; + } +}; + +type FieldRowProps = { + label: string; + onRemove?: () => Promise; + onSave?: (text: string) => Promise; + placeholder?: string; + value: string; +}; + +const FieldRow = ({ label, onRemove, onSave, placeholder, value }: FieldRowProps) => { + const [draft, setDraft] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const editing = draft !== undefined; + + const run = async (action: () => Promise) => { + setBusy(true); + setError(undefined); + try { + await action(); + setDraft(undefined); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const save = () => { + if (!onSave || draft === undefined) return; + if (draft === value) return setDraft(undefined); + void run(() => onSave(draft)); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') save(); + if (event.key === 'Escape') { + event.stopPropagation(); + setDraft(undefined); + setError(undefined); + } + }; + + return ( +
+
{label}
+ {editing ? ( + setDraft(event.target.value)} + // the whole value is selected, so typing replaces it + onFocus={(event) => event.currentTarget.select()} + onKeyDown={onKeyDown} + placeholder={placeholder} + trailing={ + <> + + + + } + value={draft} + variant='outline' + /> + ) : ( +
+
+ {value || Not set} +
+ {onSave && ( + + )} + {onRemove && ( + + )} +
+ )} + {!editing && error &&
{error}
} +
+ ); +}; + +const AddCustomProperty = ({ + existingKeys, + onAdd, +}: { + existingKeys: string[]; + onAdd: (key: string, value: unknown) => Promise; +}) => { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const trimmedKey = key.trim(); + const keyError = !trimmedKey + ? undefined + : RESERVED_KEYS.has(trimmedKey) + ? `"${trimmedKey}" is a built-in field` + : existingKeys.includes(trimmedKey) + ? `"${trimmedKey}" already exists; edit it above` + : undefined; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!trimmedKey || keyError) return; + setBusy(true); + setError(undefined); + try { + await onAdd(trimmedKey, parseValue(value)); + setKey(''); + setValue(''); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
Add custom property
+
+ setKey(event.target.value)} + placeholder='Name, e.g. nickname' + value={key} + variant='outline' + /> + setValue(event.target.value)} + placeholder='Value' + value={value} + variant='outline' + /> + +
+ {(keyError || error) && ( +
{keyError ?? error}
+ )} +
+ Values that are valid JSON (numbers, booleans, objects) are stored as such; + anything else as text. +
+
+ ); +}; + +/** + * The user's avatar as a button: clicking it opens the OS file picker, and the chosen image is + * uploaded to the Stream CDN (`client.uploadImage()`) and its URL saved as the user's `image`. + */ +const AvatarPicker = ({ + onImageChange, + user, +}: { + onImageChange: (image: string | undefined) => Promise; + user: ProfileUser; +}) => { + const { client } = useChatContext(); + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const run = async (action: () => Promise) => { + setBusy(true); + setError(undefined); + try { + await action(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const onFileSelected = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // cleared, so choosing the same file again still fires `change` + event.target.value = ''; + if (!file) return; + void run(async () => { + const { file: url } = await client.uploadImage({ file }); + if (!url) throw new Error('The upload returned no image URL.'); + await onImageChange(url); + }); + }; + + return ( +
+ + + {user.image && ( + + )} + {error &&
{error}
} +
+ ); +}; + +/** + * The connected user's profile: the avatar (click to upload a new image), the name, the custom + * properties, and a form to add one. Every change is saved right away with `client.updateUsersPartial()` under the user's own + * token, which lets a user edit their own name, image and custom data; built-in fields such as + * `role` need a server-side client, so they are shown read-only. + */ +export const UserDetailDialog = ({ + onClose, + onUserChange, + user, +}: { + onClose: () => void; + onUserChange: (user: ProfileUser) => void; + user: ProfileUser; +}) => { + const { client } = useChatContext(); + + const update = async ({ + set, + unset, + }: { + set?: Record; + unset?: string[]; + }) => { + const { users } = await client.updateUsersPartial({ + users: [{ id: user.id, set, unset }], + }); + const updated = users[user.id]; + if (updated) onUserChange(toProfileUser(updated)); + }; + + // by name: the server returns custom data in no stable order, so rows would move on every save + const customEntries = Object.entries(user.custom).sort(([a], [b]) => + a.localeCompare(b), + ); + + return ( + + + +
+ + image ? update({ set: { image } }) : update({ unset: ['image'] }) + } + user={user} + /> +
{user.id}
+
+ +
+ update({ set: { name: text } })} + placeholder='Display name' + value={user.name ?? ''} + /> + +
+ +
+
Custom properties
+ {customEntries.length ? ( + customEntries.map(([key, value]) => ( + update({ unset: [key] })} + onSave={(text) => update({ set: { [key]: parseValue(text) } })} + value={formatValue(value)} + /> + )) + ) : ( +
+ No custom properties yet. +
+ )} +
+ + key)} + onAdd={(key, value) => update({ set: { [key]: value } })} + /> +
+
+ ); +}; diff --git a/examples/vite/src/UserProfile/UserProfile.scss b/examples/vite/src/UserProfile/UserProfile.scss new file mode 100644 index 0000000000..5c43a8b4a3 --- /dev/null +++ b/examples/vite/src/UserProfile/UserProfile.scss @@ -0,0 +1,173 @@ +@layer stream-app-overrides { + .app__user-profile { + width: min(440px, 92vw); + max-height: min(80dvh, 720px); + display: flex; + flex-direction: column; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + } + + .app__user-profile__body { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-md); + overflow-y: auto; + padding-bottom: var(--str-chat__spacing-xl); + } + + .app__user-profile__header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + } + + .app__user-profile__avatar-picker { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xs); + } + + .app__user-profile__avatar-button { + position: relative; + padding: 0; + border: none; + border-radius: 50%; + background: none; + cursor: pointer; + + &:disabled { + cursor: progress; + } + + &:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: 2px; + } + } + + // the camera icon over the avatar: shown on hover and focus, and while an upload is running + .app__user-profile__avatar-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + opacity: 0; + transition: opacity 120ms ease; + + svg { + width: 24px; + height: 24px; + } + } + + .app__user-profile__avatar-button:hover .app__user-profile__avatar-overlay, + .app__user-profile__avatar-button:focus-visible .app__user-profile__avatar-overlay, + .app__user-profile__avatar-button:disabled .app__user-profile__avatar-overlay { + opacity: 1; + } + + .app__user-profile__id { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__user-profile__section { + display: flex; + flex-direction: column; + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-sm); + } + + .app__user-profile__section-title { + font: var(--str-chat__font-caption-default); + font-weight: 600; + color: var(--str-chat__text-secondary); + padding-bottom: var(--str-chat__spacing-xs); + } + + // label above the value, both tight, so a row costs little vertical space + .app__user-profile__field { + display: flex; + flex-direction: column; + padding: var(--str-chat__spacing-xs) 0; + } + + .app__user-profile__field-label { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__user-profile__field-value-row { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + min-height: 32px; + } + + .app__user-profile__field-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app__user-profile__field-empty { + color: var(--str-chat__text-tertiary); + } + + .app__user-profile__add { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-sm); + } + + .app__user-profile__add-inputs { + display: grid; + // minmax(0, …) and min-width: 0 let the inputs shrink below their intrinsic width, so the + // button stays inside the dialog + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: var(--str-chat__spacing-xs); + align-items: center; + + > * { + min-width: 0; + } + } + + .app__user-profile__error { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-utility-error); + } + + .app__user-profile__hint { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-tertiary); + } + + // Below the settings group, with no padding: the avatar fills the whole button, the size the + // other selector buttons reach with their icon plus padding. + .str-chat__chat-view__selector .str-chat__button.app__user-profile-button { + --app-user-profile-button-size: calc( + var(--str-chat__icon-size-md) + 2 * var(--str-chat__spacing-xs) + ); + + padding: 0; + border-radius: 50%; + + .str-chat__avatar { + flex-shrink: 0; + width: var(--app-user-profile-button-size); + height: var(--app-user-profile-button-size); + } + } +} diff --git a/examples/vite/src/UserProfile/UserProfileButton.tsx b/examples/vite/src/UserProfile/UserProfileButton.tsx new file mode 100644 index 0000000000..151e60fab3 --- /dev/null +++ b/examples/vite/src/UserProfile/UserProfileButton.tsx @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; +import { Avatar, GlobalModal, useChatContext } from 'stream-chat-react'; +import { ChatViewSelectorButton } from 'stream-chat-react/slot-layout'; + +import { UserDetailDialog } from './UserDetailDialog'; +import { useOwnUser } from './useOwnUser'; + +/** Sidebar entry showing the connected user's avatar; opens their profile. */ +export const UserProfileButton = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const { client } = useChatContext(); + const [user, setUser] = useOwnUser(client); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + if (!user) return null; + const displayName = user.name || user.id; + + return ( + <> + setOpen(true)} + text={displayName} + > + + + + + + + ); +}; diff --git a/examples/vite/src/UserProfile/index.ts b/examples/vite/src/UserProfile/index.ts new file mode 100644 index 0000000000..b93336c206 --- /dev/null +++ b/examples/vite/src/UserProfile/index.ts @@ -0,0 +1,3 @@ +export * from './UserProfileButton'; +export * from './UserDetailDialog'; +export * from './useOwnUser'; diff --git a/examples/vite/src/UserProfile/useOwnUser.ts b/examples/vite/src/UserProfile/useOwnUser.ts new file mode 100644 index 0000000000..8441d05d78 --- /dev/null +++ b/examples/vite/src/UserProfile/useOwnUser.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import type { CustomUserData, StreamChat } from 'stream-chat'; + +/** The connected user's fields the profile shows and edits. */ +export type ProfileUser = { + custom: Record; + id: string; + image?: string; + name?: string; + role?: string; +}; + +/** Both `client.user` and the users an update returns carry these fields. */ +type UserFields = { + custom?: CustomUserData; + id: string; + image?: string; + name?: string; + role?: string; +}; + +export const toProfileUser = (user: UserFields): ProfileUser => ({ + custom: Object.fromEntries(Object.entries(user.custom ?? {})), + id: user.id, + image: user.image, + name: user.name, + role: user.role, +}); + +/** + * The connected user, kept current with `user.updated` events for that user. `client.user` is not + * reactive, so the event is what tells the profile that it changed, e.g. from another device. The + * setter lets a save show the server's answer right away, before or without that event. + */ +export const useOwnUser = (client: StreamChat) => { + const [user, setUser] = useState(() => + client.user ? toProfileUser(client.user) : undefined, + ); + + useEffect(() => { + setUser(client.user ? toProfileUser(client.user) : undefined); + const { unsubscribe } = client.on('user.updated', (event) => { + if (event.user?.id !== client.userID || !client.user) return; + setUser(toProfileUser(client.user)); + }); + return unsubscribe; + }, [client]); + + return [user, setUser] as const; +}; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index b9efbab3fc..7328cafaff 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -16,6 +16,7 @@ layer(stream-app-overrides); @import url('./SingleChannel/SingleChannel.scss') layer(stream-app-overrides); @import url('./AppSettings/ActionsMenu/DraggableDialog.scss') layer(stream-app-overrides); @import url('./Debug/ComposerInspector.scss') layer(stream-app-overrides); +@import url('./UserProfile/UserProfile.scss') layer(stream-app-overrides); @import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins); @import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins); From 75b0a817f45ae25c1d2a668b605ee8143fbfb7e6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 5 Oct 2026 16:26:39 +0200 Subject: [PATCH 08/19] fix(ChannelListItem): re-derive preview info from channel state instead of every user.updated useChannelPreviewInfo and useChannelDisplayName each listened to client.on('user.updated') in every channel list item. For a group channel, getGroupChannelDisplayInfo() built a new object on every call, so every group preview re-rendered on any user.updated, whichever user changed. Both hooks now read data and members from channel.state through useStateStore and derive the title, image and group members with useMemo; the user.updated and channel.updated listeners are gone. The client replaces the member of each channel that contains an updated user, in one state update, so only those previews recompute. The derivation lives in an internal channelDisplayState module; the exported getChannelDisplayImage and getGroupChannelDisplayInfo keep their signatures. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 --- .../ChannelListItem/channelDisplayState.ts | 90 ++++++++++++ .../__tests__/useChannelPreviewInfo.test.tsx | 134 ++++++++++++------ .../hooks/useChannelDisplayName.ts | 77 +++------- .../hooks/useChannelPreviewInfo.ts | 66 ++++----- src/components/ChannelListItem/utils.tsx | 40 ++---- 5 files changed, 237 insertions(+), 170 deletions(-) create mode 100644 src/components/ChannelListItem/channelDisplayState.ts diff --git a/src/components/ChannelListItem/channelDisplayState.ts b/src/components/ChannelListItem/channelDisplayState.ts new file mode 100644 index 0000000000..ffd492e51e --- /dev/null +++ b/src/components/ChannelListItem/channelDisplayState.ts @@ -0,0 +1,90 @@ +import type { + Channel, + ChannelDataState, + ChannelMemberResponse, + MembersState, +} from 'stream-chat'; + +import { extractDisplayInfo } from '../Avatar/utils'; +import type { GroupChannelDisplayInfo, GroupChannelDisplayInfoMember } from './utils'; + +/** The channel state a channel's display name, image and group info are derived from. */ +export type ChannelDisplayState = { + data: Channel['data']; + members: Record; +}; + +/** + * Selects {@link ChannelDisplayState} from `channel.state`. The client replaces a member when that + * user is updated, and `channel.data` changes on `channel.updated`, so a subscription with this + * selector re-derives the display info only for the channels that changed. + */ +export const channelDisplayStateSelector = ( + state: ChannelDataState & MembersState, +): ChannelDisplayState => ({ + data: state.data, + members: state.members, +}); + +/** + * 1. data.custom.name + * 2. DM (exactly 2 members): other member's name, then directMessageLabel + * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) + * 4. undefined otherwise + */ +export const deriveChannelDisplayName = ( + { data, members }: ChannelDisplayState, + directMessageLabel: string, + currentUserId: string | undefined, +): string | undefined => { + const name = data?.custom?.name; + if (name && typeof name === 'string') return name; + + const memberList = Object.values(members); + const otherMembers = memberList.filter((m) => m.user?.id !== currentUserId); + + if (memberList.length === 2 && otherMembers.length === 1) { + return otherMembers[0].user?.name || directMessageLabel; + } + if (otherMembers.length >= 2) { + const names = otherMembers + .map((m) => m.user?.name) + .filter(Boolean) + .slice(0, 2) as string[]; + if (names.length > 0) return names.join(', '); + } + return undefined; +}; + +/** data.custom.image, or for a DM (2 members) the other member's user.image. */ +export const deriveChannelDisplayImage = ( + { data, members }: ChannelDisplayState, + currentUserId?: string, +): string | undefined => { + const image = data?.custom?.image; + if (image && typeof image === 'string') return image; + + const memberList = Object.values(members); + if (memberList.length === 2) { + const other = memberList.find((m) => m.user?.id !== currentUserId); + const otherImage = other?.user?.image; + if (otherImage && typeof otherImage === 'string') return otherImage; + } + return undefined; +}; + +/** The members shown for a group channel (3+ members); undefined for a DM or an empty channel. */ +export const deriveGroupChannelDisplayInfo = ({ + members, +}: ChannelDisplayState): GroupChannelDisplayInfo | undefined => { + const memberList = Object.values(members); + if (memberList.length <= 2) return; + + const displayMembers: GroupChannelDisplayInfoMember[] = []; + for (const member of memberList) { + const { user } = member; + if (!user?.name && !user?.image) continue; + displayMembers.push(extractDisplayInfo(member)); + } + return { members: displayMembers }; +}; diff --git a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx index 837e6044d8..bf4e2a1008 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx @@ -138,6 +138,38 @@ describe('useChannelPreviewInfo', () => { ).toBe(true); }); + it('does not re-render a group channel when a user outside it is updated', async () => { + const { channel, client } = await getClientAndChannel({ + members: [ + generateMember({ user: generateUser({ image: 'a.jpg', name: 'A' }) }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }); + let renders = 0; + const { result } = renderHook( + () => { + renders += 1; + return useChannelPreviewInfo({ channel }); + }, + { wrapper: createWrapper(client) }, + ); + const before = result.current; + const rendersBefore = renders; + + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: generateUser({ id: 'outsider', name: 'Outsider' }), + }), + ); + }); + + expect(renders).toBe(rendersBefore); + expect(result.current).toBe(before); + }); + it('uses overrideTitle over channel display title', async () => { const { channel, client } = await getClientAndChannel({ channel: { custom: { name: 'Channel Name' } }, @@ -164,74 +196,92 @@ describe('useChannelPreviewInfo', () => { expect(result.current.displayImage).toBe('https://override.jpg'); }); - it('subscribes to user.updated and updates displayImage and groupChannelDisplayInfo', async () => { - const imageUrl = 'https://initial.jpg'; + it('shows an updated member in a DM title and image', async () => { + const other = generateUser({ id: 'other', image: 'before.jpg', name: 'Before' }); const { channel, client } = await getClientAndChannel({ - channel: { custom: { image: imageUrl } }, + members: [generateMember({ user: clientUser }), generateMember({ user: other })], }); - - const onSpy = vi.spyOn(client, 'on'); - const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { wrapper: createWrapper(client), }); - - expect(result.current.displayImage).toBe(imageUrl); - expect(onSpy).toHaveBeenCalledWith('user.updated', expect.any(Function)); - - const updateInfo = onSpy.mock.calls.find((c) => c[0] === 'user.updated')?.[1]; - expect(updateInfo).toBeDefined(); + expect(result.current.displayTitle).toBe('Before'); + expect(result.current.displayImage).toBe('before.jpg'); act(() => { - updateInfo(fromPartial({})); + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...other, image: 'after.jpg', name: 'After' }, + }), + ); }); - expect(result.current.displayImage).toBe(imageUrl); - - onSpy.mockRestore(); + expect(result.current.displayTitle).toBe('After'); + expect(result.current.displayImage).toBe('after.jpg'); }); - it('does not subscribe to user.updated for image when overrideImage is set', async () => { + it('shows an updated member in the group members', async () => { + const a = generateUser({ id: 'a', image: 'a.jpg', name: 'A' }); const { channel, client } = await getClientAndChannel({ - channel: { custom: { image: 'https://channel.jpg' } }, + members: [ + generateMember({ user: a }), + generateMember({ user: generateUser({ image: 'b.jpg', name: 'B' }) }), + generateMember({ user: clientUser }), + ], + }); + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { + wrapper: createWrapper(client), }); - const onSpy = vi.spyOn(client, 'on'); + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...a, image: 'a2.jpg', name: 'A2' }, + }), + ); + }); - renderHook( + expect(result.current.groupChannelDisplayInfo.members).toContainEqual( + expect.objectContaining({ imageUrl: 'a2.jpg', userName: 'A2' }), + ); + }); + + it('keeps overrideImage when a member is updated', async () => { + const other = generateUser({ id: 'other', image: 'before.jpg' }); + const { channel, client } = await getClientAndChannel({ + members: [generateMember({ user: clientUser }), generateMember({ user: other })], + }); + const { result } = renderHook( () => useChannelPreviewInfo({ channel, overrideImage: 'https://override.jpg' }), { wrapper: createWrapper(client) }, ); - // useChannelDisplayName always subscribes to user.updated (updateDisplayName), - // but the image-related subscription (updateInfo) from useChannelPreviewInfo should not be present. - const userUpdatedCalls = onSpy.mock.calls.filter((c) => c[0] === 'user.updated'); - // Only the useChannelDisplayName subscription should be present - expect(userUpdatedCalls).toHaveLength(1); - expect(userUpdatedCalls[0][1].name).toBe('updateDisplayName'); + act(() => { + client.dispatchEvent( + fromPartial({ + type: 'user.updated', + user: { ...other, image: 'after.jpg' }, + }), + ); + }); - onSpy.mockRestore(); + expect(result.current.displayImage).toBe('https://override.jpg'); }); - it('unsubscribes from user.updated on unmount', async () => { - const { channel, client } = await getClientAndChannel(); - - const onSpy = vi.spyOn(client, 'on'); - const offSpy = vi.spyOn(client, 'off'); - - const { unmount } = renderHook(() => useChannelPreviewInfo({ channel }), { + it('follows channel.data, e.g. after channel.updated', async () => { + const { channel, client } = await getClientAndChannel({ + channel: { custom: { image: 'https://before.jpg' } }, + }); + const { result } = renderHook(() => useChannelPreviewInfo({ channel }), { wrapper: createWrapper(client), }); - expect(onSpy).toHaveBeenCalledWith('user.updated', expect.any(Function)); - const updateInfo = onSpy.mock.calls.find((c) => c[0] === 'user.updated')?.[1]; - - unmount(); - - expect(offSpy).toHaveBeenCalledWith('user.updated', updateInfo); + act(() => { + channel.data = { ...channel.data, custom: { image: 'https://after.jpg' } }; + }); - onSpy.mockRestore(); - offSpy.mockRestore(); + expect(result.current.displayImage).toBe('https://after.jpg'); }); }); }); diff --git a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts index c08470a59e..9f32fa4855 100644 --- a/src/components/ChannelListItem/hooks/useChannelDisplayName.ts +++ b/src/components/ChannelListItem/hooks/useChannelDisplayName.ts @@ -1,39 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; import { useChatContext } from '../../../context'; import { useTranslationContext } from '../../../context/TranslationContext'; - -/** - * 1. channel.data.custom.name - * 2. DM (exactly 2 members): other member's name, then directMessageLabel - * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) - * 4. undefined otherwise - */ -function computeChannelDisplayName( - channel: Channel, - directMessageLabel: string, - currentUserId: string | undefined, -): string | undefined { - const name = channel.data?.custom?.name; - if (name && typeof name === 'string') return name; - - const memberList = Object.values(channel.state.members); - const otherMembers = memberList.filter((m) => m.user?.id !== currentUserId); - - if (memberList.length === 2 && otherMembers.length === 1) { - const name = otherMembers[0].user?.name; - return name || directMessageLabel; - } - if (otherMembers.length >= 2) { - const names = otherMembers - .map((m) => m.user?.name) - .filter(Boolean) - .slice(0, 2) as string[]; - if (names.length > 0) return names.join(', '); - } - return undefined; -} +import { useStateStore } from '../../../store'; +import { + channelDisplayStateSelector, + deriveChannelDisplayName, +} from '../channelDisplayState'; /** * Channel display name with translation context. @@ -41,6 +15,9 @@ function computeChannelDisplayName( * 2. DM (exactly 2 members): other member's name, then translated "Direct message" * 3. Group (3+ members): comma-separated list of 2 other members' names (no ellipsis) * 4. undefined otherwise + * + * Re-derived from the channel's `data` and `members`, so it changes only when this channel does: + * an updated user reaches it through the member the client replaces. */ export const useChannelDisplayName = ( channel: Channel | undefined, @@ -51,32 +28,14 @@ export const useChannelDisplayName = ( 'channelListItem.channelDisplayName.directMessage.label', 'Direct message', ); - - const [displayName, setDisplayName] = useState(() => - channel - ? computeChannelDisplayName(channel, directMessageLabel, client.userID ?? undefined) - : undefined, + const displayState = useStateStore(channel?.state, channelDisplayStateSelector); + const currentUserId = client.userID ?? undefined; + + return useMemo( + () => + displayState + ? deriveChannelDisplayName(displayState, directMessageLabel, currentUserId) + : undefined, + [currentUserId, directMessageLabel, displayState], ); - - useEffect(() => { - if (!channel) { - setDisplayName(undefined); - return; - } - const updateDisplayName = () => - setDisplayName( - computeChannelDisplayName( - channel, - directMessageLabel, - client.userID ?? undefined, - ), - ); - updateDisplayName(); - client.on('user.updated', updateDisplayName); - return () => { - client.off('user.updated', updateDisplayName); - }; - }, [channel, channel?.data, client, directMessageLabel]); - - return displayName; }; diff --git a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts index 33e3234a5d..b7f2770521 100644 --- a/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts +++ b/src/components/ChannelListItem/hooks/useChannelPreviewInfo.ts @@ -1,12 +1,14 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; import { - getChannelDisplayImage, - getGroupChannelDisplayInfo, - type GroupChannelDisplayInfo, -} from '../utils'; + channelDisplayStateSelector, + deriveChannelDisplayImage, + deriveGroupChannelDisplayInfo, +} from '../channelDisplayState'; +import type { GroupChannelDisplayInfo } from '../utils'; import { useChannelDisplayName } from './useChannelDisplayName'; const emptyGroupInfo: GroupChannelDisplayInfo = { @@ -23,6 +25,11 @@ export type ChannelPreviewInfoParams = { overrideTitle?: string; }; +/** + * The title, image and group members a channel preview shows. Derived from the channel's `data` + * and `members`, so a preview re-renders only when its own channel changes: an updated user reaches + * the channels that contain them through the member the client replaces. + */ export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { const { channel, overrideImage, overrideTitle } = props; const { client } = useChatContext(); @@ -30,43 +37,22 @@ export const useChannelPreviewInfo = (props: ChannelPreviewInfoParams) => { const channelDisplayName = useChannelDisplayName(channel); const displayTitle = overrideTitle ?? channelDisplayName; - const [displayImage, setDisplayImage] = useState(() => - channel - ? (overrideImage ?? getChannelDisplayImage(channel, client.userID ?? undefined)) - : undefined, - ); - const [groupChannelDisplayInfo, setGroupChannelDisplayInfo] = - useState(() => - channel ? (getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo) : emptyGroupInfo, - ); - - useEffect(() => { - if (!channel) return; - if (overrideImage) return; - - const updateInfo = () => { - setDisplayImage(getChannelDisplayImage(channel, client.userID ?? undefined)); - setGroupChannelDisplayInfo(getGroupChannelDisplayInfo(channel) ?? emptyGroupInfo); - }; + const displayState = useStateStore(channel?.state, channelDisplayStateSelector); + const currentUserId = client.userID ?? undefined; - updateInfo(); - const { unsubscribe: unsubscribeChannelUpdated } = channel.on( - 'channel.updated', - updateInfo, - ); - const { unsubscribe: unsubscribeUserUpdated } = client.on('user.updated', updateInfo); - return () => { - unsubscribeChannelUpdated(); - unsubscribeUserUpdated(); - }; - }, [channel, channel?.data, client, overrideImage]); + const displayImage = useMemo( + () => + overrideImage ?? + (displayState ? deriveChannelDisplayImage(displayState, currentUserId) : undefined), + [currentUserId, displayState, overrideImage], + ); + const groupChannelDisplayInfo = useMemo( + () => (displayState && deriveGroupChannelDisplayInfo(displayState)) ?? emptyGroupInfo, + [displayState], + ); return useMemo( - () => ({ - displayImage: overrideImage ?? displayImage, - displayTitle, - groupChannelDisplayInfo, - }), - [displayImage, displayTitle, groupChannelDisplayInfo, overrideImage], + () => ({ displayImage, displayTitle, groupChannelDisplayInfo }), + [displayImage, displayTitle, groupChannelDisplayInfo], ); }; diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index 82b060ab3d..7204dd8027 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -13,7 +13,10 @@ import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import { isMessageDeleted } from '../Message/utils'; import remarkGfm from 'remark-gfm'; -import { extractDisplayInfo } from '../Avatar/utils'; +import { + deriveChannelDisplayImage, + deriveGroupChannelDisplayInfo, +} from './channelDisplayState'; const remarkPlugins: PluggableList = [ htmlToTextPlugin, @@ -358,34 +361,13 @@ export type GroupChannelDisplayInfo = { export const getChannelDisplayImage = ( channel: Channel, currentUserId?: string, -): string | undefined => { - const image = channel.data?.custom?.image; - if (image && typeof image === 'string') return image; - - const memberList = Object.values(channel.state.members); - if (memberList.length === 2) { - const other = memberList.find((m) => m.user?.id !== currentUserId); - const image = other?.user?.image; - if (image && typeof image === 'string') return image; - } - return undefined; -}; +): string | undefined => + deriveChannelDisplayImage( + { data: channel.data, members: channel.state.members }, + currentUserId, + ); export const getGroupChannelDisplayInfo = ( channel: Channel, -): GroupChannelDisplayInfo | undefined => { - const members = Object.values(channel.state.members); - if (members.length <= 2) return; - - const memberList: GroupChannelDisplayInfoMember[] = []; - for (const member of members) { - const { user } = member; - - if (!user?.name && !user?.image) continue; - - memberList.push(extractDisplayInfo(member)); - } - return { - members: memberList, - }; -}; +): GroupChannelDisplayInfo | undefined => + deriveGroupChannelDisplayInfo({ data: channel.data, members: channel.state.members }); From 477557309dce222c8e96346e0f0def4dde23d598 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 6 Oct 2026 10:08:30 +0200 Subject: [PATCH 09/19] docs: client.channel() is a shorthand for channelManager.ensure() in stream-chat v10 stream-chat v10 keeps client.channel() as a shorthand for client.channelManager.ensure(), so ensure() is no longer described as its replacement. Refs: REACT-1062 Co-Authored-By: Claude Opus 5.5 --- ai-docs/ai-migration-v14-v15.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index dda0238ddc..395019e7fb 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -336,7 +336,7 @@ dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md). setChannel(channel); ``` - Two separate guards, both worth keeping. `client.channelManager.ensure()` (stream-chat v10's replacement for `client.channel()`) returns the stored instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. + Two separate guards, both worth keeping. `client.channelManager.ensure()` (which `client.channel()` calls in stream-chat v10) returns the stored instance, which may already be loaded, so `initialized` skips a query that is not needed. And prefer `getChannel` over a bare `channel.watch()` when one _is_ needed: it de-duplicates concurrent calls for the same channel (keyed on the sorted member list while a channel has no id yet), so an effect that runs twice, or two components opening the same channel, still produce one query. That de-duplication used to live inside `Channel`. - **A direct message identified by members** → `getChannel({ client, type: 'messaging', members })` builds, watches and returns the instance. - **`channelQueryOptions`** → pass them to the watch you now own: `getChannel({ channel, client, options })`. From 803b317aed39ebab312fefccbce7f17d56be93d1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 10:00:01 +0200 Subject: [PATCH 10/19] fix: pass path parameters separately to stream-chat v10 API calls stream-chat v10's generated methods take path parameters as their own first argument. sendReaction, queryReactions, upsertReminder and createPollOption still passed one merged object, which failed the type check and the build. Tests now assert the new argument shape, including deleteMessage and updateMessage, which stream-chat already calls this way. Co-Authored-By: Claude Opus 5.5 --- .../Channel/__tests__/Channel.test.tsx | 42 ++++++++++--------- .../Message/__tests__/Message.test.tsx | 14 ++++--- .../__tests__/useReactionHandler.test.tsx | 40 ++++++++++-------- .../__tests__/useReactionsFetcher.test.tsx | 16 +++---- .../Message/hooks/useReactionHandler.ts | 16 +++---- .../Message/hooks/useReactionsFetcher.ts | 16 +++---- .../MessageActions/RemindMeSubmenu.tsx | 8 ++-- .../PollActions/SuggestPollOptionPrompt.tsx | 5 +-- .../__tests__/SuggestPollOptionForm.test.tsx | 8 ++-- 9 files changed, 89 insertions(+), 76 deletions(-) diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index b275c535cc..1e6ab3934b 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -506,11 +506,11 @@ describe('Channel', () => { .catch(() => {}); }); await waitFor(() => - // v10: single request object - `client.deleteMessage({ id, ...options })`. - expect(clientDeleteMessageSpy).toHaveBeenCalledWith({ - id: message.id, - ...deleteMessageOptions, - }), + // v10: the message id is a path parameter - `client.deleteMessage({ id }, options)`. + expect(clientDeleteMessageSpy).toHaveBeenCalledWith( + { id: message.id }, + deleteMessageOptions, + ), ); }); @@ -562,23 +562,25 @@ describe('Channel', () => { .catch(() => {}); }); await waitFor(() => - // A single request object, `client.updateMessage({ id, message })`, where `message` is the - // edited message projected onto the update payload: the edited content plus its pin state. - expect(clientUpdateMessageSpy).toHaveBeenCalledWith({ - id: updatedMessage.id, - message: expect.objectContaining({ - attachments: updatedMessage.attachments, - cid: updatedMessage.cid, - id: updatedMessage.id, - mentioned_users: [], - pinned: false, - pinned_at: null, - text: newText, - }), - }), + // `client.updateMessage({ id }, { message })`: the id is a path parameter, and `message` is + // the edited message projected onto the update payload: the edited content plus its pin state. + expect(clientUpdateMessageSpy).toHaveBeenCalledWith( + { id: updatedMessage.id }, + { + message: expect.objectContaining({ + attachments: updatedMessage.attachments, + cid: updatedMessage.cid, + id: updatedMessage.id, + mentioned_users: [], + pinned: false, + pinned_at: null, + text: newText, + }), + }, + ), ); // Server-owned fields are left out; sending them makes the update fail. - const [{ message: payload }] = clientUpdateMessageSpy.mock.calls[0]; + const [, { message: payload }] = clientUpdateMessageSpy.mock.calls[0]; for (const serverOwnedField of [ '__html', 'created_at', diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index b61a3db2a0..16c8db6272 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -261,13 +261,15 @@ describe(' component', () => { }); await context.handleReaction(reaction.type); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '❤️', - type: reaction.type, + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '❤️', + type: reaction.type, + }, }, - }); + ); }); // MERGE-RECONCILE (test migration): the reaction handler no longer gates on the diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index 1a1628bed7..11d1f94f7a 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -117,13 +117,15 @@ describe('useReactionHandler custom hook', () => { const message = generateMessage({ own_reactions: [] }); const handleReaction = await renderUseReactionHandlerHook({ message }); await handleReaction('love'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '❤️', - type: 'love', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '❤️', + type: 'love', + }, }, - }); + ); }); it('should send reaction without emoji_code when the type has no unicode', async () => { @@ -133,12 +135,14 @@ describe('useReactionHandler custom hook', () => { const message = generateMessage({ own_reactions: [] }); const handleReaction = await renderUseReactionHandlerHook({ message }); await handleReaction('unsupported-reaction-type'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - type: 'unsupported-reaction-type', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + type: 'unsupported-reaction-type', + }, }, - }); + ); }); it('should derive emoji_code from custom reaction options provided via context', async () => { @@ -161,13 +165,15 @@ describe('useReactionHandler custom hook', () => { message, }); await handleReaction('rocket'); - expect(sendReaction).toHaveBeenCalledWith({ - id: message.id, - reaction: { - emoji_code: '🚀', - type: 'rocket', + expect(sendReaction).toHaveBeenCalledWith( + { id: message.id }, + { + reaction: { + emoji_code: '🚀', + type: 'rocket', + }, }, - }); + ); }); it('should stamp emoji_code on the optimistic reaction preview ingested into the paginator', async () => { diff --git a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx index 4e82cee80b..1e23c671e2 100644 --- a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx @@ -52,13 +52,15 @@ describe('useReactionsFetcher', () => { }); expect(queryReactions).toHaveBeenCalledTimes(2); - // v10: `client.queryReactions` takes a single request object. - expect(queryReactions.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: message.id, limit: 25, next: undefined }), - ); - expect(queryReactions.mock.calls[1]?.[0]).toEqual( - expect.objectContaining({ id: message.id, limit: 25, next: 'page-2' }), - ); + // v10: the message id is a path parameter, separate from the request. + expect(queryReactions.mock.calls[0]?.slice(0, 2)).toEqual([ + { id: message.id }, + expect.objectContaining({ limit: 25, next: undefined }), + ]); + expect(queryReactions.mock.calls[1]?.slice(0, 2)).toEqual([ + { id: message.id }, + expect.objectContaining({ limit: 25, next: 'page-2' }), + ]); expect(reactions!).toHaveLength(2); }); }); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 5794ca5f7b..aad97586e0 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -114,13 +114,15 @@ export const useReactionHandler = (message?: LocalMessage) => { try { updateMessage(tempMessage); const messageResponse = add - ? await channel.sendReaction({ - id, - reaction: { - type, - ...(emojiCode && { emoji_code: emojiCode }), - } as ReactionRequest, - }) + ? await channel.sendReaction( + { id }, + { + reaction: { + type, + ...(emojiCode && { emoji_code: emojiCode }), + } as ReactionRequest, + }, + ) : await channel.deleteReaction({ id, type }); // seems useless as we're expecting WS event to come in and replace this anyway diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts index 03c0c064b6..437ead4247 100644 --- a/src/components/Message/hooks/useReactionsFetcher.ts +++ b/src/components/Message/hooks/useReactionsFetcher.ts @@ -30,13 +30,15 @@ async function fetchMessageReactions( let hasNext = true; while (hasNext && reactions.length < MAX_MESSAGE_REACTIONS_TO_FETCH) { - const response = await client.queryReactions({ - filter: reactionType ? { type: reactionType } : {}, - id: messageId, - limit, - next, - sort, - }); + const response = await client.queryReactions( + { id: messageId }, + { + filter: reactionType ? { type: reactionType } : {}, + limit, + next, + sort, + }, + ); reactions.push(...response.reactions); next = response.next; diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index f396008d67..2660b7a2e2 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -54,10 +54,10 @@ export const RemindMeSubmenu = () => { key={`reminder-offset-option--${offsetMs}`} onClick={async () => { try { - await client.reminders.upsertReminder({ - message_id: message.id, - remind_at: new Date(new Date().getTime() + offsetMs), - }); + await client.reminders.upsertReminder( + { message_id: message.id }, + { remind_at: new Date(new Date().getTime() + offsetMs) }, + ); addNotification({ context: { message, diff --git a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx index 3a6cc1453c..8581512d91 100644 --- a/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx +++ b/src/components/Poll/PollActions/SuggestPollOptionPrompt.tsx @@ -56,10 +56,7 @@ export const SuggestPollOptionPrompt = () => { const onSubmit = useCallback( async (formValue: { optionText: string }) => { - await client.createPollOption({ - poll_id: poll.id, - text: formValue.optionText, - }); + await client.createPollOption({ poll_id: poll.id }, { text: formValue.optionText }); close(); }, [client, poll, close], diff --git a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx index 40fbd6b335..ebee06966e 100644 --- a/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx +++ b/src/components/Poll/__tests__/SuggestPollOptionForm.test.tsx @@ -64,10 +64,10 @@ describe('SuggestPollOptionPrompt', () => { await waitFor(() => { // v10 takes a single request object: `createPollOption({ poll_id, text })`. - expect(createPollOptionSpy).toHaveBeenCalledWith({ - poll_id: poll.id, - text: newlyTypedValue, - }); + expect(createPollOptionSpy).toHaveBeenCalledWith( + { poll_id: poll.id }, + { text: newlyTypedValue }, + ); }); }); }); From 09bf3fb655a8bba545b8786769b1a2ef090aab8e Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 10:00:14 +0200 Subject: [PATCH 11/19] chore(examples): add a new conversation dialog to the vite app A "+" button in the sidebar opens a dialog to search people, pick members and optionally name the conversation and give it an image. Nothing is created on submit. One other member opens the 1:1 conversation: the stored channel, or one read-only query for an existing one on the server; a new one is created when its first message is sent. More members start a new group with a fresh id, also created on the first message. Co-Authored-By: Claude Opus 5.5 --- .../src/NewConversation/NewConversation.scss | 199 ++++++++++++ .../NewConversation/NewConversationButton.tsx | 28 ++ .../NewConversation/NewConversationDialog.tsx | 282 ++++++++++++++++++ .../src/NewConversation/createConversation.ts | 97 ++++++ examples/vite/src/NewConversation/index.ts | 1 + .../src/Sidebar/ChatViewSelectorItemSet.tsx | 2 + examples/vite/src/index.scss | 1 + 7 files changed, 610 insertions(+) create mode 100644 examples/vite/src/NewConversation/NewConversation.scss create mode 100644 examples/vite/src/NewConversation/NewConversationButton.tsx create mode 100644 examples/vite/src/NewConversation/NewConversationDialog.tsx create mode 100644 examples/vite/src/NewConversation/createConversation.ts create mode 100644 examples/vite/src/NewConversation/index.ts diff --git a/examples/vite/src/NewConversation/NewConversation.scss b/examples/vite/src/NewConversation/NewConversation.scss new file mode 100644 index 0000000000..418fbda9be --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversation.scss @@ -0,0 +1,199 @@ +@layer stream-app-overrides { + .app__new-conversation { + width: min(440px, 92vw); + max-height: min(80dvh, 720px); + display: flex; + flex-direction: column; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + } + + // the form wraps body and footer, so it takes over the prompt's column layout + .app__new-conversation__form { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + } + + .app__new-conversation__body { + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-sm); + overflow-y: auto; + } + + .app__new-conversation__chips, + .app__new-conversation__results { + list-style: none; + margin: 0; + padding: 0; + } + + .app__new-conversation__chips { + display: flex; + flex-wrap: wrap; + gap: var(--str-chat__spacing-xs); + } + + .app__new-conversation__chip { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-xxs, 4px); + padding: 2px 4px 2px 2px; + border-radius: 999px; + background: var(--str-chat__background-core-surface); + font: var(--str-chat__font-caption-default); + } + + .app__new-conversation__chip-remove { + display: flex; + padding: 2px; + border: none; + border-radius: 50%; + background: none; + color: var(--str-chat__text-secondary); + cursor: pointer; + + svg { + width: 14px; + height: 14px; + } + } + + .app__new-conversation__results { + display: flex; + flex-direction: column; + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-xs); + } + + .app__new-conversation__result { + display: flex; + align-items: center; + gap: var(--str-chat__spacing-sm); + width: 100%; + padding: var(--str-chat__spacing-xs); + border: none; + border-radius: var(--str-chat__radius-md, 8px); + background: none; + color: inherit; + text-align: start; + cursor: pointer; + + &:hover { + background: var(--str-chat__background-core-surface); + } + + &[aria-pressed='true'] { + background: var( + --str-chat__background-core-surface-strong, + var(--str-chat__background-core-surface) + ); + } + + &:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: -2px; + } + } + + .app__new-conversation__result-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app__new-conversation__result-id { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-tertiary); + } + + .app__new-conversation__empty { + padding: var(--str-chat__spacing-xs); + color: var(--str-chat__text-tertiary); + } + + .app__new-conversation__details { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--str-chat__spacing-xs); + border-top: 1px solid var(--str-chat__border-core-default); + padding-top: var(--str-chat__spacing-sm); + } + + .app__new-conversation__image { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--str-chat__spacing-xxs, 4px); + } + + .app__new-conversation__image-button { + position: relative; + padding: 0; + border: none; + border-radius: 50%; + background: none; + cursor: pointer; + + &:disabled { + cursor: progress; + } + + &:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: 2px; + } + } + + // the camera icon over the image: shown on hover and focus, and while an upload is running + .app__new-conversation__image-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + opacity: 0; + transition: opacity 120ms ease; + + svg { + width: 20px; + height: 20px; + } + } + + .app__new-conversation__image-button:hover .app__new-conversation__image-overlay, + .app__new-conversation__image-button:focus-visible + .app__new-conversation__image-overlay, + .app__new-conversation__image-button:disabled .app__new-conversation__image-overlay { + opacity: 1; + } + + .app__new-conversation__link-button { + padding: 0; + border: none; + background: none; + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + text-decoration: underline; + cursor: pointer; + } + + .app__new-conversation__hint { + margin: 0; + font: var(--str-chat__font-caption-default); + color: var(--str-chat__text-secondary); + } + + .app__new-conversation__error { + font: var(--str-chat__font-caption-default); + color: var(--str-chat__accent-error); + } +} diff --git a/examples/vite/src/NewConversation/NewConversationButton.tsx b/examples/vite/src/NewConversation/NewConversationButton.tsx new file mode 100644 index 0000000000..83bd6945c3 --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversationButton.tsx @@ -0,0 +1,28 @@ +import { useCallback, useState } from 'react'; +import { GlobalModal, IconPlus } from 'stream-chat-react'; +import { ChatViewSelectorButton } from 'stream-chat-react/slot-layout'; + +import { NewConversationDialog } from './NewConversationDialog'; + +/** Sidebar entry that opens the new conversation dialog. */ +export const NewConversationButton = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + return ( + <> + setOpen(true)} + text='New conversation' + > + + + + {open && } + + + ); +}; diff --git a/examples/vite/src/NewConversation/NewConversationDialog.tsx b/examples/vite/src/NewConversation/NewConversationDialog.tsx new file mode 100644 index 0000000000..f56c33b3ae --- /dev/null +++ b/examples/vite/src/NewConversation/NewConversationDialog.tsx @@ -0,0 +1,282 @@ +import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react'; +import type { UserResponse } from 'stream-chat'; +import { + Avatar, + IconCamera, + IconLoading, + IconXmark, + Prompt, + TextInput, + useChatContext, + useWorkspaceNavigation, +} from 'stream-chat-react'; + +import { createGroup, openOneToOne } from './createConversation'; + +const SEARCH_DEBOUNCE_MS = 250; + +/** Users matching `query` by id or name, without the connected user. */ +const useUserSearch = (query: string) => { + const { client } = useChatContext(); + const [users, setUsers] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + const text = query.trim(); + if (!text) { + setUsers([]); + return; + } + let cancelled = false; + const timeout = setTimeout(async () => { + setSearching(true); + try { + const response = await client.queryUsers({ + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }], + id: { $ne: client.userID as string }, + }, + limit: 10, + sort: [{ direction: 1, field: 'id' }], + }, + }); + if (!cancelled) setUsers(response.users); + } finally { + if (!cancelled) setSearching(false); + } + }, SEARCH_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [client, query]); + + return { searching, users }; +}; + +const displayName = (user: UserResponse) => user.name || user.id; + +const ImagePicker = ({ + image, + name, + onChange, +}: { + image?: string; + name: string; + onChange: (image?: string) => void; +}) => { + const { client } = useChatContext(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(); + + const onFileSelected = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // cleared, so choosing the same file again still fires `change` + event.target.value = ''; + if (!file) return; + setUploading(true); + setError(undefined); + try { + const { file: url } = await client.uploadImage({ file }); + if (!url) throw new Error('The upload returned no image URL.'); + onChange(url); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setUploading(false); + } + }; + + return ( +
+ + + {image && ( + + )} + {error &&
{error}
} +
+ ); +}; + +/** + * Starts a conversation: pick the members, optionally name it and give it an image. Nothing is + * created on the server here. One other member opens the 1:1 conversation with them, the existing + * one if there is one; more members start a new group. A new conversation is created on the + * server when its first message is sent. + */ +export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => { + const { client } = useChatContext(); + const { openChannel } = useWorkspaceNavigation(); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState([]); + const [name, setName] = useState(''); + const [image, setImage] = useState(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(); + const { searching, users } = useUserSearch(query); + + const isOneToOne = selected.length === 1; + const selectedIds = new Set(selected.map(({ id }) => id)); + + const toggle = (user: UserResponse) => + setSelected((current) => + current.some(({ id }) => id === user.id) + ? current.filter(({ id }) => id !== user.id) + : [...current, user], + ); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!selected.length) return; + setSubmitting(true); + setError(undefined); + try { + const details = { image, name }; + const { channel, existed } = isOneToOne + ? await openOneToOne(client, selected[0].id, details) + : createGroup( + client, + selected.map(({ id }) => id), + details, + ); + if (existed && (name.trim() || image)) { + client.notifications.addInfo({ + message: + 'This conversation already exists, so its name and image were left unchanged.', + origin: { emitter: 'NewConversationDialog' }, + }); + } + openChannel(channel); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + }; + + return ( + + +
+ + setQuery(event.target.value)} + placeholder='Search people by name or id' + trailing={searching ? : undefined} + value={query} + /> + + {selected.length > 0 && ( +
    + {selected.map((user) => ( +
  • + + {displayName(user)} + +
  • + ))} +
+ )} + + {query.trim() && ( +
    + {users.length === 0 && !searching ? ( +
  • + No one matches “{query.trim()}”. +
  • + ) : ( + users.map((user) => ( +
  • + +
  • + )) + )} +
+ )} + + {selected.length > 0 && ( +
+ + setName(event.target.value)} + placeholder={isOneToOne ? 'Name (optional)' : 'Group name (optional)'} + value={name} + /> +

+ {isOneToOne + ? 'Opens your conversation with this person. If it is new, it is created when you send the first message, with this name and image.' + : 'Starts a new group. It is created when you send the first message.'} +

+
+ )} + + {error &&
{error}
} +
+ + + + Cancel + + + {isOneToOne ? 'Open conversation' : 'Start group'} + + + +
+
+ ); +}; diff --git a/examples/vite/src/NewConversation/createConversation.ts b/examples/vite/src/NewConversation/createConversation.ts new file mode 100644 index 0000000000..aa78c6c853 --- /dev/null +++ b/examples/vite/src/NewConversation/createConversation.ts @@ -0,0 +1,97 @@ +import { + type Channel, + localMessageToNewMessagePayload, + type StreamChat, +} from 'stream-chat'; + +export type ConversationDetails = { image?: string; name?: string }; + +export type OpenedConversation = { + channel: Channel; + /** The conversation already existed on the server; the details typed in the dialog were not applied. */ + existed: boolean; +}; + +const CHANNEL_TYPE = 'messaging'; + +const withoutEmpty = ({ image, name }: ConversationDetails) => ({ + ...(image ? { image } : {}), + ...(name?.trim() ? { name: name.trim() } : {}), +}); + +/** + * Keeps a channel local until its first message: nothing is loaded for it (there is nothing on the + * server yet), and sending the first message creates it on the server (`watch()`) before sending. + */ +const createOnFirstSend = (channel: Channel) => { + channel.messagePaginator.seedFirstPageSync([], channel.messagePaginator.pageSize); + const { requestHandlers } = channel.configState.getLatestValue(); + channel.configState.partialNext({ + requestHandlers: { + ...requestHandlers, + sendMessageRequest: async ({ localMessage, message, options }) => { + if (!channel.initialized) await channel.watch(); + const response = await channel.sendMessage({ + message: message ?? localMessageToNewMessagePayload(localMessage), + ...options, + }); + return { message: response.message }; + }, + }, + }); +}; + +/** + * A 1:1 conversation is a distinct channel: one per pair of users, with the id the server derives + * from the members, so it is known before the channel exists. The channel this session already has + * is reused, loaded or not; otherwise one read-only query tells whether the server has it (its + * history then shows right away). Only a conversation that doesn't exist yet stays local until the + * first message, and only then are the name and image applied. + */ +export const openOneToOne = async ( + client: StreamChat, + otherUserId: string, + details: ConversationDetails, +): Promise => { + const ownUserId = client.userID as string; + const channel = client.channelManager.ensure({ + type: CHANNEL_TYPE, + data: { + custom: withoutEmpty(details), + members: [{ user_id: ownUserId }, { user_id: otherUserId }], + }, + }); + if (channel.initialized) return { channel, existed: true }; + + const [found] = await client.queryChannelsAndHydrate( + { filter_conditions: { cid: channel.cid }, limit: 1 }, + {}, + ); + if (found) return { channel: found, existed: true }; + + createOnFirstSend(channel); + return { channel, existed: false }; +}; + +/** + * A group gets an id of its own: the same people can share several groups ("Project X", "Lunch"), + * so the members can't identify it. The id is fresh, so there is nothing to look up; the group stays + * local until the first message creates it with its members, name and image. + */ +export const createGroup = ( + client: StreamChat, + memberIds: string[], + details: ConversationDetails, +): OpenedConversation => { + const ownUserId = client.userID as string; + const channel = client.channelManager.ensure({ + data: { + custom: withoutEmpty(details), + members: [ownUserId, ...memberIds].map((user_id) => ({ user_id })), + }, + id: crypto.randomUUID(), + type: CHANNEL_TYPE, + }); + createOnFirstSend(channel); + return { channel, existed: false }; +}; diff --git a/examples/vite/src/NewConversation/index.ts b/examples/vite/src/NewConversation/index.ts new file mode 100644 index 0000000000..bf08ef0f92 --- /dev/null +++ b/examples/vite/src/NewConversation/index.ts @@ -0,0 +1 @@ +export * from './NewConversationButton'; diff --git a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx index 67ea05ba89..d8abb34530 100644 --- a/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx +++ b/examples/vite/src/Sidebar/ChatViewSelectorItemSet.tsx @@ -3,10 +3,12 @@ import { defaultChatViewSelectorItemSet, } from 'stream-chat-react/slot-layout'; import { AppSettings } from '../AppSettings'; +import { NewConversationButton } from '../NewConversation'; import { UserProfileButton } from '../UserProfile'; export const chatViewSelectorItemSet: ChatViewSelectorEntry[] = [ ...defaultChatViewSelectorItemSet, + { Component: NewConversationButton, type: 'new-conversation' }, { Component: AppSettings, type: 'settings' }, { Component: UserProfileButton, type: 'profile' }, ]; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 7328cafaff..185fa61778 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -17,6 +17,7 @@ layer(stream-app-overrides); @import url('./AppSettings/ActionsMenu/DraggableDialog.scss') layer(stream-app-overrides); @import url('./Debug/ComposerInspector.scss') layer(stream-app-overrides); @import url('./UserProfile/UserProfile.scss') layer(stream-app-overrides); +@import url('./NewConversation/NewConversation.scss') layer(stream-app-overrides); @import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins); @import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins); From 35894d4e2a3304d2dbfa1a27f17e92dd80efe259 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:06:14 +0200 Subject: [PATCH 12/19] fix(Modal): keep scrolling over the overlay from reaching the page The overlay doesn't scroll, so a wheel or touch scroll over it went on to the page behind (or made it bounce). `overscroll-behavior` only acts on an element that scrolls, so the overlay now scrolls by one hidden pixel and stops scroll chaining there. Co-Authored-By: Claude Opus 5.5 --- src/components/Modal/styling/Modal.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/Modal/styling/Modal.scss b/src/components/Modal/styling/Modal.scss index f34f0c0998..c7db2ac719 100644 --- a/src/components/Modal/styling/Modal.scss +++ b/src/components/Modal/styling/Modal.scss @@ -45,6 +45,25 @@ pointer-events: auto; background-color: var(--str-chat__modal-overlay-color); backdrop-filter: var(--str-chat__modal-overlay-backdrop-filter); + // Scrolling over the modal stays in it rather than reaching the page behind. `overscroll-behavior` + // acts only on an element that scrolls, so the overlay scrolls by one pixel (`::before`), with no + // scrollbar shown. + overflow-y: auto; + overscroll-behavior: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + &::before { + content: ''; + position: absolute; + top: 0; + width: 1px; + height: calc(100% + 1px); + pointer-events: none; + } .str-chat__modal__floating-dialog-overlay { z-index: 1; From d6f6a8888cb84339bbcb53b344049eb8495e4833 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:06:17 +0200 Subject: [PATCH 13/19] feat(ChannelDetail): disable the details button until the channel is loaded Channel details need a channel the server has answered for. The button is disabled until the channel is initialized (or restored from the offline database), so a channel created locally from members doesn't open an empty details view. Co-Authored-By: Claude Opus 5.5 --- .../ChannelDetail/AvatarWithChannelDetail.tsx | 13 ++++ .../AvatarWithChannelDetail.test.tsx | 65 +++++++++++++++++++ .../styling/AvatarWithChannelDetail.scss | 5 ++ 3 files changed, 83 insertions(+) create mode 100644 src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx diff --git a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx index c8f4e6962c..21deb451f7 100644 --- a/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx +++ b/src/plugins/ChannelDetail/AvatarWithChannelDetail.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { useCallback, useState } from 'react'; +import type { ChannelStateData } from 'stream-chat'; import { useChannel, useComponentContext, useTranslationContext } from '../../context'; import { @@ -11,6 +12,7 @@ import { ChannelDetail as DefaultChannelDetail, } from './ChannelDetail'; import { GlobalModal } from '../../components/Modal'; +import { useStateStore } from '../../store'; export type AvatarWithChannelDetailProps = ChannelAvatarProps & { Avatar?: React.ComponentType; @@ -21,6 +23,15 @@ const avatarWithChannelDetailDialogRootProps = { className: 'str-chat__channel-detail-modal', }; +/** + * Whether the channel exists on the server: a query's response was applied to it, or it was restored + * from the offline database. The details are loaded from the server, so a channel created only + * locally (sent to the server with its first message) has none to show yet. + */ +const existsOnServerSelector = ({ initialized, offlineMode }: ChannelStateData) => ({ + existsOnServer: initialized || offlineMode, +}); + export const AvatarWithChannelDetail = ({ Avatar, ChannelDetail = DefaultChannelDetail, @@ -31,6 +42,7 @@ export const AvatarWithChannelDetail = ({ const channel = useChannel(); const { Avatar: ContextAvatar, Modal = GlobalModal } = useComponentContext(); const [isModalOpen, setIsModalOpen] = useState(false); + const { existsOnServer } = useStateStore(channel.state, existsOnServerSelector); const openModal = useCallback(() => setIsModalOpen(true), []); const closeModal = useCallback(() => setIsModalOpen(false), []); @@ -48,6 +60,7 @@ export const AvatarWithChannelDetail = ({ 'Open channel details', )} className='str-chat__avatar-with-channel-detail-button' + disabled={!existsOnServer} onClick={openModal} type='button' > diff --git a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx new file mode 100644 index 0000000000..8b6de7bc03 --- /dev/null +++ b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { AvatarWithChannelDetail } from '../AvatarWithChannelDetail'; +import { + ChannelInstanceProvider, + ChatProvider, + ModalDialogManagerProvider, +} from '../../../context'; +import { getTestClientWithUser, mockChatContext } from '../../../mock-builders'; + +const ChannelDetailStub = () =>
; + +describe('AvatarWithChannelDetail', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'alice' }); + // created locally, e.g. by a "new conversation" flow: the server doesn't have it yet + channel = client.channelManager.ensure({ id: 'local', type: 'messaging' }); + }); + + const renderAvatar = () => + render( + + + + + + + , + ); + + const openButton = () => screen.getByRole('button', { name: 'Open channel details' }); + + it('is disabled for a channel the server does not have yet', () => { + renderAvatar(); + + expect(openButton()).toBeDisabled(); + fireEvent.click(openButton()); + expect(screen.queryByTestId('channel-detail')).not.toBeInTheDocument(); + }); + + it('enables once the server has created the channel', () => { + renderAvatar(); + + act(() => { + channel.initialized = true; + }); + fireEvent.click(openButton()); + + expect(openButton()).toBeEnabled(); + expect(screen.getByTestId('channel-detail')).toBeInTheDocument(); + }); + + it('is enabled for a channel restored from the offline database', () => { + channel.offlineMode = true; + + renderAvatar(); + + expect(openButton()).toBeEnabled(); + }); +}); diff --git a/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss index f6441d360a..36b3663d3f 100644 --- a/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss +++ b/src/plugins/ChannelDetail/styling/AvatarWithChannelDetail.scss @@ -7,4 +7,9 @@ cursor: pointer; display: flex; padding: 0; + + // the channel doesn't exist on the server yet, so it has no details to show + &:disabled { + cursor: default; + } } From e5330e08d70477b6bc14d337ba650cc549fdfdf2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:06:36 +0200 Subject: [PATCH 14/19] feat(SlotLayout): move a slot to the instance that superseded its channel When stream-chat supersedes a channel created from members by an instance already stored under the server's cid (`channel.supersededBy`), ChatView rebinds each slot showing the old instance to the new one. It waits only while the new instance is open in another slot and the old one's composer still holds something (stream-chat doesn't move a composer into one in use), until that composer is empty. Co-Authored-By: Claude Opus 5.5 --- src/plugins/SlotLayout/ChatView.tsx | 15 ++++ .../SlotLayout/SupersededChannelSwap.tsx | 59 +++++++++++++++ .../__tests__/SupersededChannelSwap.test.tsx | 75 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/plugins/SlotLayout/SupersededChannelSwap.tsx create mode 100644 src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 8a63ce5a70..94c0290bf2 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -42,6 +42,7 @@ import { LayoutController as LayoutControllerClass, } from './layoutController/LayoutController'; import { createChatViewSlotBinding, getChatViewEntityBinding } from './slotBinding'; +import { SupersededChannelSwap } from './SupersededChannelSwap'; import { renderSlotFromRegistry, resolveSlotKindRegistry, @@ -438,6 +439,12 @@ export const ChatView = ({ children ); + // every slot showing a channel, so one that gets superseded moves to the instance replacing it + const channelSlots = viewState.availableSlots.flatMap((slot) => { + const entity = getChatViewEntityBinding(viewState.slotBindings[slot]); + return entity?.kind === 'channel' ? [{ channel: entity.source, slot }] : []; + }); + return ( @@ -456,6 +463,14 @@ export const ChatView = ({ {content} + {channelSlots.map(({ channel, slot }) => ( + + ))}
diff --git a/src/plugins/SlotLayout/SupersededChannelSwap.tsx b/src/plugins/SlotLayout/SupersededChannelSwap.tsx new file mode 100644 index 0000000000..7ad8598cb4 --- /dev/null +++ b/src/plugins/SlotLayout/SupersededChannelSwap.tsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import type { + ChannelStateData, + EditingAuditState, + Channel as StreamChannel, +} from 'stream-chat'; + +import { useStateStore } from '../../store'; +import { createChatViewSlotBinding } from './slotBinding'; +import type { + LayoutController, + SlotName, +} from './layoutController/layoutControllerTypes'; + +const supersededBySelector = ({ supersededBy }: ChannelStateData) => ({ supersededBy }); +const activeSelector = ({ active }: ChannelStateData) => ({ active }); +const editingAuditStateSelector = (state: EditingAuditState) => state; + +/** + * Moves a slot from a superseded channel to the instance that replaced it (`channel.supersededBy`): + * a channel created without an id whose cid, once the server answered, another instance already + * held. stream-chat has moved the conversation to that instance; this moves the screen. + * + * The swap happens right away, except when the replacing instance is open in another slot and this + * one's composer still holds something: stream-chat moves a composer only into one nobody is using, + * so the slot stays until its composer is empty, typically once its message is sent. + */ +export const SupersededChannelSwap = ({ + channel, + layoutController, + slot, +}: { + channel: StreamChannel; + layoutController: LayoutController; + slot: SlotName; +}) => { + // a slot source without channel state (a stand-in object) never swaps + const { supersededBy } = useStateStore(channel.state, supersededBySelector) ?? {}; + const successorActive = + useStateStore(supersededBy?.state, activeSelector)?.active ?? false; + // re-rendered on every composer change, so the emptiness read below stays current + useStateStore(channel.messageComposer?.editingAuditState, editingAuditStateSelector); + const composerIsEmpty = channel.messageComposer?.compositionIsEmpty ?? true; + + useEffect(() => { + if (!supersededBy) return; + if (successorActive && !composerIsEmpty) return; + layoutController.bind( + slot, + createChatViewSlotBinding({ + key: supersededBy.cid, + kind: 'channel', + source: supersededBy, + }), + ); + }, [composerIsEmpty, layoutController, slot, successorActive, supersededBy]); + + return null; +}; diff --git a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx new file mode 100644 index 0000000000..ca18f9efa0 --- /dev/null +++ b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { SupersededChannelSwap } from '../SupersededChannelSwap'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding, getChatViewEntityBinding } from '../slotBinding'; +import { getTestClientWithUser } from '../../../mock-builders'; + +describe('SupersededChannelSwap', () => { + let client: StreamChat; + let previous: Channel; + let successor: Channel; + let layoutController: LayoutController; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + previous = client.channelManager.ensure({ id: 'previous', type: 'messaging' }); + successor = client.channelManager.ensure({ id: 'successor', type: 'messaging' }); + layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: previous.cid, kind: 'channel', source: previous }), + ); + }); + + const boundChannel = () => { + const state = layoutController.state.getLatestValue(); + const viewState = state.layouts?.[state.activeView]; + return getChatViewEntityBinding(viewState?.slotBindings.slot1)?.source; + }; + + const renderSwap = () => + render( + , + ); + + it('leaves a channel that is not superseded in its slot', () => { + renderSwap(); + + expect(boundChannel()).toBe(previous); + }); + + it('moves the slot to the instance that superseded its channel', () => { + renderSwap(); + + act(() => { + previous.state.partialNext({ supersededBy: successor }); + }); + + expect(boundChannel()).toBe(successor); + }); + + it('waits while the successor is open elsewhere and this composer still holds something', () => { + successor.activate(); + previous.messageComposer.textComposer.setText('unsent'); + renderSwap(); + + act(() => { + previous.state.partialNext({ supersededBy: successor }); + }); + expect(boundChannel()).toBe(previous); + + act(() => { + previous.messageComposer.clear(); + }); + expect(boundChannel()).toBe(successor); + }); +}); From 87ac163121c1387259592a0d25a05555276b457c Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:06:40 +0200 Subject: [PATCH 15/19] feat(SlotLayout): add DisposedChannelRelease to empty a slot whose channel was disposed A channel that was deleted, or that the current user was removed from, is disposed of by stream-chat (`pendingDisposal`), but a slot kept showing it. ``, mounted inside ``, releases every slot whose channel is disposed of. A hidden channel is not disposed of, so it stays. Co-Authored-By: Claude Opus 5.5 --- .../SlotLayout/DisposedChannelRelease.tsx | 52 +++++++++ .../__tests__/DisposedChannelRelease.test.tsx | 109 ++++++++++++++++++ src/plugins/SlotLayout/index.tsx | 1 + 3 files changed, 162 insertions(+) create mode 100644 src/plugins/SlotLayout/DisposedChannelRelease.tsx create mode 100644 src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx diff --git a/src/plugins/SlotLayout/DisposedChannelRelease.tsx b/src/plugins/SlotLayout/DisposedChannelRelease.tsx new file mode 100644 index 0000000000..1a8193f62b --- /dev/null +++ b/src/plugins/SlotLayout/DisposedChannelRelease.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import type { Channel, ChannelStateData } from 'stream-chat'; + +import { useStateStore } from '../../store'; +import { useChatViewContext } from './ChatView'; +import { useSlotChannels } from './hooks'; +import type { SlotName } from './layoutController/layoutControllerTypes'; +import { getChatViewEntityBinding } from './slotBinding'; + +const pendingDisposalSelector = ({ pendingDisposal }: ChannelStateData) => ({ + pendingDisposal, +}); + +/** + * Empties `slot` once stream-chat disposes of its channel (`channel.pendingDisposal`): the channel + * was deleted (`channel.deleted`, `notification.channel_deleted`) or the current user was removed + * from it (`notification.removed_from_channel`). It no longer receives events, so the slot shows its + * empty state rather than a channel that is gone. A hidden channel is not disposed of, so it stays. + */ +const ReleaseWhenDisposed = ({ channel, slot }: { channel: Channel; slot: SlotName }) => { + const { layoutController } = useChatViewContext(); + const { pendingDisposal } = useStateStore(channel.state, pendingDisposalSelector); + + useEffect(() => { + if (!pendingDisposal) return; + const { activeView, layouts } = layoutController.state.getLatestValue(); + const bound = getChatViewEntityBinding(layouts?.[activeView]?.slotBindings[slot]); + // the slot may already show something else + if (bound?.source !== channel) return; + layoutController.release(slot); + }, [channel, layoutController, pendingDisposal, slot]); + + return null; +}; + +/** + * Empties every slot whose channel stream-chat disposed of: deleted, or the current user removed + * from it. Opt-in: mount it once inside ``. + * + * ```tsx + * + * + * + * ``` + */ +export const DisposedChannelRelease = () => ( + <> + {useSlotChannels().map(({ channel, slot }) => ( + + ))} + +); diff --git a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx new file mode 100644 index 0000000000..57803f192f --- /dev/null +++ b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { ChatView } from '../ChatView'; +import { DisposedChannelRelease } from '../DisposedChannelRelease'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding, getChatViewEntityBinding } from '../slotBinding'; +import { ChatProvider } from '../../../context/ChatContext'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import type { TranslationContextValue } from '../../../context/TranslationContext'; +import { getTestClientWithUser } from '../../../mock-builders'; +import { mockT } from '../../../mock-builders/translator'; + +vi.mock('../../../components/Channel/Channel', () => ({ + Channel: () => null, +})); + +describe('DisposedChannelRelease', () => { + let client: StreamChat; + let channel: Channel; + let layoutController: LayoutController; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'ann' }); + channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), + ); + }); + + const boundSource = () => { + const state = layoutController.state.getLatestValue(); + const viewState = state.layouts?.[state.activeView]; + return getChatViewEntityBinding(viewState?.slotBindings.slot1)?.source; + }; + + const renderInChatView = () => + render( + + + + + + + , + ); + + it('leaves a live channel in its slot', () => { + renderInChatView(); + + expect(boundSource()).toBe(channel); + }); + + it('empties the slot when its channel is deleted', () => { + renderInChatView(); + + act(() => { + client.dispatchEvent({ + channel: { cid: channel.cid, id: 'general', type: 'messaging' }, + channel_id: 'general', + channel_type: 'messaging', + cid: channel.cid, + type: 'channel.deleted', + } as never); + }); + + expect(channel.pendingDisposal).toBe(true); + expect(boundSource()).toBeUndefined(); + }); + + it('empties the slot when the current user is removed from its channel', () => { + renderInChatView(); + + act(() => { + client.dispatchEvent({ + cid: channel.cid, + type: 'notification.removed_from_channel', + } as never); + }); + + expect(boundSource()).toBeUndefined(); + }); + + it('leaves a slot that shows another channel', () => { + const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + renderInChatView(); + + act(() => { + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: other.cid, kind: 'channel', source: other }), + ); + client.dispatchEvent({ + cid: channel.cid, + type: 'channel.deleted', + } as never); + }); + + expect(boundSource()).toBe(other); + }); +}); diff --git a/src/plugins/SlotLayout/index.tsx b/src/plugins/SlotLayout/index.tsx index 1d3a17087f..2cf3bd48c6 100644 --- a/src/plugins/SlotLayout/index.tsx +++ b/src/plugins/SlotLayout/index.tsx @@ -5,6 +5,7 @@ export * from './layoutController/layoutControllerTypes'; export * from './layoutController/serialization'; export * from './hooks'; export * from './ChannelSlot'; +export * from './DisposedChannelRelease'; export * from './ThreadSlot'; export * from './ThreadListSlot'; export * from './layout/Slot'; From 07bb6cf12ca5d3746168acdd960b39e9632a4324 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:06:54 +0200 Subject: [PATCH 16/19] feat(SlotLayout): show an empty-state placeholder when no slot has content `ChatViewEmptyPlaceholder` shows a message-bubble icon and "No chat selected" (or "No thread selected" in the threads view), styled with the existing tokens. `WorkspaceLayout` renders it in place of the slots while none of them has content, so the built-in layout shows one placeholder instead of a row of empty slots. An app rendering its own views can place the exported component itself. ChatView no longer renders an untranslated "Select a channel to start messaging" in every empty slot: an empty slot shows the app's `SlotFallback` / `slotFallbackComponents` if given, and is otherwise blank. A fallback counts as content, so an app supplying one keeps its own empty state. Co-Authored-By: Claude Opus 5.5 --- src/i18n/__tests__/catalog.fixture.json | 2 + src/i18n/keys.ts | 2 + src/plugins/SlotLayout/ChatView.tsx | 21 ++-- .../SlotLayout/ChatViewEmptyPlaceholder.tsx | 27 +++++ .../SlotLayout/__tests__/ChatView.test.tsx | 108 ++++++++++++++++++ src/plugins/SlotLayout/index.tsx | 1 + .../SlotLayout/layout/WorkspaceLayout.tsx | 55 +++++---- src/plugins/SlotLayout/styling/ChatView.scss | 29 +++++ 8 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index e59b721427..8546b3187e 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -534,6 +534,8 @@ "search.sourceResults.searching.text": "Searching for {{ searchSourceType }}...", "slotLayout.chatView.channels.text": "Channels", "slotLayout.chatView.chatViewControls.ariaLabel": "Chat view controls", + "slotLayout.chatView.empty.channels.text": "No chat selected", + "slotLayout.chatView.empty.threads.text": "No thread selected", "slotLayout.chatView.openChannelsView.ariaLabel": "Open channels view", "slotLayout.chatView.openThreadsView.ariaLabel": "Open threads view", "slotLayout.chatView.openThreadsViewUnread.ariaLabel_one": "Open threads view, {{ count }} unread thread", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 76cd2f34bc..fc70b118c5 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -545,6 +545,8 @@ export type TranslationCatalog = { 'search.sourceResults.searching.text': 'Searching for {{ searchSourceType }}...'; 'slotLayout.chatView.channels.text': 'Channels'; 'slotLayout.chatView.chatViewControls.ariaLabel': 'Chat view controls'; + 'slotLayout.chatView.empty.channels.text': 'No chat selected'; + 'slotLayout.chatView.empty.threads.text': 'No thread selected'; 'slotLayout.chatView.openChannelsView.ariaLabel': 'Open channels view'; 'slotLayout.chatView.openThreadsView.ariaLabel': 'Open threads view'; 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one': 'Open threads view, {{ count }} unread thread'; diff --git a/src/plugins/SlotLayout/ChatView.tsx b/src/plugins/SlotLayout/ChatView.tsx index 94c0290bf2..f860fb0e39 100644 --- a/src/plugins/SlotLayout/ChatView.tsx +++ b/src/plugins/SlotLayout/ChatView.tsx @@ -120,7 +120,12 @@ export type ChatViewProps = PropsWithChildren<{ layoutController?: LayoutController; layouts?: LayoutDescriptor[]; resolveDuplicateEntity?: ResolveDuplicateEntity; + /** + * Rendered by the built-in workspace layout in a slot with nothing bound. Without one, such a slot + * is blank, and a layout whose slots are all blank shows {@link ChatViewEmptyPlaceholder}. + */ SlotFallback?: ComponentType; + /** Per-slot {@link ChatViewProps.SlotFallback}. */ slotFallbackComponents?: Partial< Record> >; @@ -186,12 +191,6 @@ const workspaceLayoutStateSelector = (state: ChatViewLayoutState) => ({ viewState: getLayoutViewState(state), }); -const DefaultSlotFallback = () => ( -
- Select a channel to start messaging -
-); - const resolveSlotFallbackComponent = ({ slot, SlotFallback, @@ -202,7 +201,7 @@ const resolveSlotFallbackComponent = ({ slotFallbackComponents?: Partial< Record> >; -}) => slotFallbackComponents?.[slot] ?? SlotFallback ?? DefaultSlotFallback; +}) => slotFallbackComponents?.[slot] ?? SlotFallback; const BUILTIN_WORKSPACE_LAYOUT: ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; const DEFAULT_LIST_BINDING_KEY = 'list'; @@ -415,22 +414,20 @@ export const ChatView = ({ ) : layout === BUILTIN_WORKSPACE_LAYOUT ? ( (() => { + // an unbound slot with no fallback has no content; the layout decides what that shows const slots = viewState.availableSlots.map((slot) => { const content = renderSlotFromRegistry( getChatViewEntityBinding(viewState.slotBindings[slot]), slot, slotKindRegistry, ); + if (content != null) return { content, slot }; const Fallback = resolveSlotFallbackComponent({ slot, SlotFallback, slotFallbackComponents, }); - - return { - content: content ?? , - slot, - }; + return { content: Fallback ? : null, slot }; }); return } slots={slots} />; diff --git a/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx b/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx new file mode 100644 index 0000000000..0c1d817d15 --- /dev/null +++ b/src/plugins/SlotLayout/ChatViewEmptyPlaceholder.tsx @@ -0,0 +1,27 @@ +import React, { useContext } from 'react'; + +import { ChatViewContext } from './ChatView'; +import { useComponentContextIcons, useTranslationContext } from '../../context'; + +/** + * What a chat view shows while none of its slots has anything bound, worded for the active view + * (channels or threads). `ChatView`'s built-in workspace layout renders it; an app rendering its + * own views places it wherever its empty state goes. + */ +export const ChatViewEmptyPlaceholder = () => { + const { IconMessageBubble } = useComponentContextIcons(); + const { t } = useTranslationContext(); + // outside a ChatView there is no active view; the channels wording applies + const activeView = useContext(ChatViewContext)?.activeView ?? 'channels'; + + return ( +
+ +

+ {activeView === 'threads' + ? t('slotLayout.chatView.empty.threads.text', 'No thread selected') + : t('slotLayout.chatView.empty.channels.text', 'No chat selected')} +

+
+ ); +}; diff --git a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx index 44bb4f8c3f..54a84c7c7d 100644 --- a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx +++ b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; @@ -8,6 +9,8 @@ import { mockTranslationContextValue, } from '../../../mock-builders'; import { ChatView } from '../ChatView'; +import { LayoutController } from '../layoutController/LayoutController'; +import { createChatViewSlotBinding } from '../slotBinding'; const renderSelector = async (selectorProps?: any) => { const client = await getTestClientWithUser(); @@ -159,3 +162,108 @@ describe('ChatView.Selector', () => { expect(results).toHaveNoViolations(); }); }); + +describe('ChatView built-in workspace layout empty state', () => { + const renderWorkspace = async ({ + activeView, + bindChannel = false, + ...chatViewProps + }: Partial> & { + activeView?: 'channels' | 'threads'; + bindChannel?: boolean; + } = {}) => { + const client = await getTestClientWithUser(); + const layoutController = new LayoutController({ + initialState: { activeView, availableSlots: ['slot1', 'slot2'] }, + }); + if (bindChannel) { + const channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + layoutController.bind( + 'slot1', + createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), + ); + } + + return render( + + + ( +
{source.cid}
+ ), + }} + {...chatViewProps} + /> +
+
, + ); + }; + + it('shows one placeholder instead of the slots while every slot is empty', async () => { + const { container } = await renderWorkspace(); + + const placeholders = container.querySelectorAll( + '.str-chat__chat-view__empty-placeholder', + ); + expect(placeholders).toHaveLength(1); + expect(placeholders[0]).toHaveTextContent('No chat selected'); + expect(placeholders[0].querySelector('svg')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__workspace-layout-slot'), + ).not.toBeInTheDocument(); + }); + + it('words the placeholder for the threads view', async () => { + const { container } = await renderWorkspace({ activeView: 'threads' }); + + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).toHaveTextContent('No thread selected'); + }); + + it('shows no placeholder while a slot is in use, leaving the empty slot blank', async () => { + const { container } = await renderWorkspace({ bindChannel: true }); + + expect(screen.getByTestId('bound-channel')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).not.toBeInTheDocument(); + expect( + container.querySelectorAll('.str-chat__chat-view__workspace-layout-slot'), + ).toHaveLength(2); + }); + + it('renders SlotFallback instead of the placeholder while every slot is empty', async () => { + const { container } = await renderWorkspace({ + SlotFallback: ({ slot }) =>
, + }); + + expect(screen.getByTestId('fallback-slot1')).toBeInTheDocument(); + expect(screen.getByTestId('fallback-slot2')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__chat-view__empty-placeholder'), + ).not.toBeInTheDocument(); + }); + + it('renders SlotFallback in an empty slot while another slot is in use', async () => { + await renderWorkspace({ + bindChannel: true, + SlotFallback: ({ slot }) =>
, + }); + + expect(screen.getByTestId('fallback-slot2')).toBeInTheDocument(); + expect(screen.queryByTestId('fallback-slot1')).not.toBeInTheDocument(); + }); +}); diff --git a/src/plugins/SlotLayout/index.tsx b/src/plugins/SlotLayout/index.tsx index 2cf3bd48c6..c678e42cfa 100644 --- a/src/plugins/SlotLayout/index.tsx +++ b/src/plugins/SlotLayout/index.tsx @@ -1,4 +1,5 @@ export * from './ChatView'; +export * from './ChatViewEmptyPlaceholder'; export * from './ChatViewNavigationContext'; export * from './workspaceNavigationAdapter'; export * from './layoutController/layoutControllerTypes'; diff --git a/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx b/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx index 95038dbdeb..cfb2c63f9e 100644 --- a/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx +++ b/src/plugins/SlotLayout/layout/WorkspaceLayout.tsx @@ -1,6 +1,7 @@ import React from 'react'; import clsx from 'clsx'; import { Slot } from './Slot'; +import { ChatViewEmptyPlaceholder } from '../ChatViewEmptyPlaceholder'; import type { ReactNode } from 'react'; @@ -11,28 +12,40 @@ export type WorkspaceLayoutSlot = { export type WorkspaceLayoutProps = { navRail?: ReactNode; + /** + * The slots, each with what it shows. While none has anything to show (no slots, or no content + * in any), {@link ChatViewEmptyPlaceholder} is shown in their place. + */ slots: WorkspaceLayoutSlot[]; }; -export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => ( -
- {navRail ? ( -
{navRail}
- ) : null} -
- {slots.map(({ content, slot }) => ( - - {content} - - ))} +export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => { + const isEmpty = slots.every(({ content }) => content == null); + + return ( +
+ {navRail ? ( +
{navRail}
+ ) : null} +
+ {isEmpty ? ( + + ) : ( + slots.map(({ content, slot }) => ( + + {content} + + )) + )} +
-
-); + ); +}; diff --git a/src/plugins/SlotLayout/styling/ChatView.scss b/src/plugins/SlotLayout/styling/ChatView.scss index fcc640c216..ce1eadab84 100644 --- a/src/plugins/SlotLayout/styling/ChatView.scss +++ b/src/plugins/SlotLayout/styling/ChatView.scss @@ -135,6 +135,35 @@ justify-content: center; } +.str-chat__chat-view__empty-placeholder { + --str-chat-icon-color: var(--str-chat__text-tertiary); + + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--str-chat__spacing-sm); + height: 100%; + padding: var(--str-chat__spacing-3xl) var(--str-chat__spacing-xl); + background: var(--str-chat__background-core-app); + color: var(--str-chat__text-secondary); + font: var(--str-chat__font-caption-default); + text-align: center; + + svg { + width: var(--str-chat__icon-size-lg); + height: var(--str-chat__icon-size-lg); + color: var(--str-chat-icon-color); + } + + .str-chat__chat-view__empty-placeholder-text { + margin: 0; + max-width: 320px; + overflow-wrap: break-word; + } +} + .str-chat__chat-view__workspace-layout-slot { display: flex; flex: 1 1 0; From 64d684a8c99c397e5e6f82c1d95646e48f44589c Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:08:26 +0200 Subject: [PATCH 17/19] chore(examples): improve the new conversation dialog - Members are picked with the app's searchable select, the dialog keeps a fixed height, and the connected user is listed as a member by default. - A new 1:1 stays local until its first message, with its members shown from the start; one already loaded is opened as it is. - A group is created on the server when the dialog is submitted. - A dev switch opens a 1:1 as a new local channel even when one exists, to test the channel swap once the server answers with the existing one. Co-Authored-By: Claude Opus 5.5 --- .../vite/src/AppSettings/SearchableSelect.tsx | 32 +++- .../src/NewConversation/NewConversation.scss | 74 ++------ .../NewConversation/NewConversationDialog.tsx | 164 +++++++++--------- .../src/NewConversation/createConversation.ts | 110 ++++++++---- 4 files changed, 205 insertions(+), 175 deletions(-) diff --git a/examples/vite/src/AppSettings/SearchableSelect.tsx b/examples/vite/src/AppSettings/SearchableSelect.tsx index 5e1714e572..64d683df57 100644 --- a/examples/vite/src/AppSettings/SearchableSelect.tsx +++ b/examples/vite/src/AppSettings/SearchableSelect.tsx @@ -61,6 +61,8 @@ const SearchableSelectOptionItem = ({ const SearchableSelectDropdownItems = ({ allowCustomValue, + emptyOptionsLabel, + filterOptions, onSearchChange, onSelect, options, @@ -69,6 +71,8 @@ const SearchableSelectDropdownItems = ({ selectedValue, }: { allowCustomValue: boolean; + emptyOptionsLabel: string; + filterOptions: boolean; onSearchChange: (value: string) => void; onSelect: (value: T) => void; options: SearchableSelectOption[]; @@ -78,9 +82,9 @@ const SearchableSelectDropdownItems = ({ }) => { const trimmedQuery = searchQuery.trim(); const normalizedQuery = trimmedQuery.toLowerCase(); - const filteredOptions = options.filter((option) => - option.label.toLowerCase().includes(normalizedQuery), - ); + const filteredOptions = filterOptions + ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery)) + : options; // Lets the caller target something the option list does not know about. Offered only when the // query cannot be satisfied from the list, or already looks fully qualified (`type:id`), so it // does not clutter ordinary searches that do match. @@ -124,7 +128,7 @@ const SearchableSelectDropdownItems = ({ /> ))} {filteredOptions.length === 0 && !customOption && ( -
No matching options
+
{emptyOptionsLabel}
)} ); @@ -133,7 +137,10 @@ const SearchableSelectDropdownItems = ({ export const SearchableSelect = ({ allowCustomValue = false, emptyLabel, + emptyOptionsLabel = 'No matching options', + filterOptions = true, onChange, + onSearchChange, options, searchPlaceholder, value, @@ -142,12 +149,25 @@ export const SearchableSelect = ({ allowCustomValue?: boolean; /** Trigger text when `value` matches no option. Defaults to the existing first-option fallback. */ emptyLabel?: string; + /** Shown in the dropdown when no option is listed. */ + emptyOptionsLabel?: string; + /** + * Filters `options` by the search text (the default). Turn it off when the options already are the + * search's results, e.g. from a server query run in `onSearchChange`. + */ + filterOptions?: boolean; onChange: (value: T) => void; + /** Called with the search text as it changes, and with `''` when the dropdown opens or closes. */ + onSearchChange?: (query: string) => void; options: SearchableSelectOption[]; searchPlaceholder: string; value: T; }) => { - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQueryState] = useState(''); + const setSearchQuery = (query: string) => { + setSearchQueryState(query); + onSearchChange?.(query); + }; const selectedOption = options.find((option) => option.value === value) ?? null; // With a free-text value the trigger must show what was typed even though it is not an option. // Falls back to the first option only when neither new prop is in play, preserving the previous @@ -196,6 +216,8 @@ export const SearchableSelect = ({ > { const displayName = (user: UserResponse) => user.name || user.id; +const optionLabel = (user: UserResponse) => + user.name && user.name !== user.id ? `${user.name} (${user.id})` : user.id; + const ImagePicker = ({ image, name, @@ -125,10 +130,9 @@ const ImagePicker = ({ }; /** - * Starts a conversation: pick the members, optionally name it and give it an image. Nothing is - * created on the server here. One other member opens the 1:1 conversation with them, the existing - * one if there is one; more members start a new group. A new conversation is created on the - * server when its first message is sent. + * Starts a conversation: pick the members, optionally name it and give it an image. One other member + * opens the 1:1 conversation with them, the existing one if there is one; a new 1:1 is created on the + * server when its first message is sent. More members create a new group on the server right away. */ export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => { const { client } = useChatContext(); @@ -138,18 +142,28 @@ export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => { const [name, setName] = useState(''); const [image, setImage] = useState(); const [submitting, setSubmitting] = useState(false); + const [lookUpExisting, setLookUpExisting] = useState(true); const [error, setError] = useState(); const { searching, users } = useUserSearch(query); const isOneToOne = selected.length === 1; + // the connected user, who creates the conversation and is always one of its members + const me = client.user as UserResponse | undefined; const selectedIds = new Set(selected.map(({ id }) => id)); + const candidates = users.filter(({ id }) => !selectedIds.has(id)); + + const add = (userId: string) => { + const user = users.find(({ id }) => id === userId); + if (user) setSelected((current) => [...current, user]); + }; + const remove = (userId: string) => + setSelected((current) => current.filter(({ id }) => id !== userId)); - const toggle = (user: UserResponse) => - setSelected((current) => - current.some(({ id }) => id === user.id) - ? current.filter(({ id }) => id !== user.id) - : [...current, user], - ); + const searchStatus = !query.trim() + ? 'Type a name or id' + : searching + ? 'Searching…' + : `No one else matches “${query.trim()}”`; const submit = async (event: FormEvent) => { event.preventDefault(); @@ -159,12 +173,8 @@ export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => { try { const details = { image, name }; const { channel, existed } = isOneToOne - ? await openOneToOne(client, selected[0].id, details) - : createGroup( - client, - selected.map(({ id }) => id), - details, - ); + ? await openOneToOne(client, selected[0], details, { lookUpExisting }) + : await createGroup(client, selected, details); if (existed && (name.trim() || image)) { client.notifications.addInfo({ message: @@ -185,81 +195,79 @@ export const NewConversationDialog = ({ onClose }: { onClose: () => void }) => {
- setQuery(event.target.value)} - placeholder='Search people by name or id' - trailing={searching ? : undefined} - value={query} - /> +
+ + setName(event.target.value)} + placeholder={isOneToOne ? 'Name (optional)' : 'Group name (optional)'} + value={name} + /> +
- {selected.length > 0 && ( -
    - {selected.map((user) => ( -
  • - - {displayName(user)} - -
  • - ))} -
- )} +
+ + emptyLabel='Add people' + emptyOptionsLabel={searchStatus} + filterOptions={false} + onChange={add} + onSearchChange={setQuery} + options={candidates.map((user) => ({ + label: optionLabel(user), + value: user.id, + }))} + searchPlaceholder='Search people by name or id' + value='' + /> - {query.trim() && ( -
    - {users.length === 0 && !searching ? ( -
  • - No one matches “{query.trim()}”. +
      + {me && ( +
    • + + {displayName(me)} (you)
    • - ) : ( - users.map((user) => ( -
    • + )} + {selected.length ? ( + selected.map((user) => ( +
    • + + {displayName(user)}
    • )) + ) : ( +
    • Add at least one person.
    • )}
    - )} - {selected.length > 0 && ( -
    - - setName(event.target.value)} - placeholder={isOneToOne ? 'Name (optional)' : 'Group name (optional)'} - value={name} - /> -

    - {isOneToOne +

    + {!selected.length + ? 'Add one person for a 1:1 conversation, or more for a group.' + : isOneToOne ? 'Opens your conversation with this person. If it is new, it is created when you send the first message, with this name and image.' - : 'Starts a new group. It is created when you send the first message.'} -

    -
    - )} + : 'Creates a new group with these members, name and image.'} +

    + + {isOneToOne && ( + setLookUpExisting(!event.target.checked)} + title='Dev: open as a new local conversation even if one exists (tests the cid swap)' + /> + )} +
{error &&
{error}
}
diff --git a/examples/vite/src/NewConversation/createConversation.ts b/examples/vite/src/NewConversation/createConversation.ts index aa78c6c853..34846aebf7 100644 --- a/examples/vite/src/NewConversation/createConversation.ts +++ b/examples/vite/src/NewConversation/createConversation.ts @@ -1,7 +1,9 @@ import { type Channel, + type ChannelMemberResponse, localMessageToNewMessagePayload, type StreamChat, + type UserResponse, } from 'stream-chat'; export type ConversationDetails = { image?: string; name?: string }; @@ -19,19 +21,41 @@ const withoutEmpty = ({ image, name }: ConversationDetails) => ({ ...(name?.trim() ? { name: name.trim() } : {}), }); +const ownUser = (client: StreamChat) => client.user as unknown as UserResponse; + /** - * Keeps a channel local until its first message: nothing is loaded for it (there is nothing on the - * server yet), and sending the first message creates it on the server (`watch()`) before sending. + * Keeps a 1:1 that doesn't exist yet local until its first message: nothing is loaded for it (there + * is nothing on the server yet), and sending the first message creates it on the server (`watch()`) + * before sending. + * Its members are shown from the start, the connected user as the one creating it, so the channel + * reads as one the user is in; the server's members replace them once it is created. */ -const createOnFirstSend = (channel: Channel) => { +const createOnFirstSend = ( + channel: Channel, + client: StreamChat, + others: UserResponse[], +) => { + const creator = ownUser(client); + channel.state.members = Object.fromEntries( + [creator, ...others].map((user) => [ + user.id, + { + channel_role: user.id === creator.id ? 'owner' : 'channel_member', + user, + user_id: user.id, + } as ChannelMemberResponse, + ]), + ); channel.messagePaginator.seedFirstPageSync([], channel.messagePaginator.pageSize); const { requestHandlers } = channel.configState.getLatestValue(); channel.configState.partialNext({ requestHandlers: { ...requestHandlers, sendMessageRequest: async ({ localMessage, message, options }) => { - if (!channel.initialized) await channel.watch(); - const response = await channel.sendMessage({ + // without an id nothing but its query can be sent, and the query creates it on the server + if (channel.isProvisional) await channel.watch(); + // another instance may have been stored under the cid meanwhile and taken over from this one + const response = await (channel.supersededBy ?? channel).sendMessage({ message: message ?? localMessageToNewMessagePayload(localMessage), ...options, }); @@ -42,56 +66,80 @@ const createOnFirstSend = (channel: Channel) => { }; /** - * A 1:1 conversation is a distinct channel: one per pair of users, with the id the server derives - * from the members, so it is known before the channel exists. The channel this session already has - * is reused, loaded or not; otherwise one read-only query tells whether the server has it (its - * history then shows right away). Only a conversation that doesn't exist yet stays local until the - * first message, and only then are the name and image applied. + * A 1:1 conversation is a distinct channel: one per pair of users. One read-only query asks the + * server for it first, by its exact members, so an existing one opens with its history (it is then + * the stored instance). Only a conversation that doesn't exist yet is created locally, as a + * provisional channel: it stays local until the first message, and only then are the name and image + * applied. */ export const openOneToOne = async ( client: StreamChat, - otherUserId: string, + other: UserResponse, details: ConversationDetails, + { + /** + * 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. + */ + lookUpExisting = true, + }: { lookUpExisting?: boolean } = {}, ): Promise => { - const ownUserId = client.userID as string; + const memberIds = [client.userID as string, other.id]; + const candidates = !lookUpExisting + ? [] + : await client.queryChannelsAndHydrate( + { + filter_conditions: { + member_count: 2, + members: { $eq: memberIds }, + type: CHANNEL_TYPE, + }, + limit: 10, + }, + {}, + ); + // a 2-member channel created with an id of its own is a group with those two, not their 1:1 + const existing = candidates.find((channel) => channel.id?.startsWith('!members-')); + if (existing) return { channel: existing, existed: true }; + const channel = client.channelManager.ensure({ - type: CHANNEL_TYPE, data: { custom: withoutEmpty(details), - members: [{ user_id: ownUserId }, { user_id: otherUserId }], + members: memberIds.map((user_id) => ({ user_id })), }, + type: CHANNEL_TYPE, }); - if (channel.initialized) return { channel, existed: true }; - - const [found] = await client.queryChannelsAndHydrate( - { filter_conditions: { cid: channel.cid }, limit: 1 }, - {}, - ); - if (found) return { channel: found, existed: true }; - - createOnFirstSend(channel); + // `ensure` 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 }; }; /** * A group gets an id of its own: the same people can share several groups ("Project X", "Lunch"), - * so the members can't identify it. The id is fresh, so there is nothing to look up; the group stays - * local until the first message creates it with its members, name and image. + * so the members can't identify it. It is created on the server right away, with its members, name + * and image: with an id, the SDK sends its requests (typing events, drafts) as for any channel, and + * the server would refuse them for a channel it doesn't have. */ -export const createGroup = ( +export const createGroup = async ( client: StreamChat, - memberIds: string[], + others: UserResponse[], details: ConversationDetails, -): OpenedConversation => { - const ownUserId = client.userID as string; +): Promise => { const channel = client.channelManager.ensure({ data: { custom: withoutEmpty(details), - members: [ownUserId, ...memberIds].map((user_id) => ({ user_id })), + members: [client.userID as string, ...others.map(({ id }) => id)].map( + (user_id) => ({ + user_id, + }), + ), }, id: crypto.randomUUID(), type: CHANNEL_TYPE, }); - createOnFirstSend(channel); + await channel.watch(); return { channel, existed: false }; }; From b63b59084cfddd9f0cd72db49d22e7e0200bd922 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:08:30 +0200 Subject: [PATCH 18/19] chore(examples): add CDN upload destinations, with a mock CDN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Composer → Upload destination sends attachments to Stream, to a mock CDN served by the dev server (files stored in examples/vite/.mock-cdn), or to a CDN at a given URL (multipart `file` field, JSON `{ file, thumb_url? }` back). Either CDN sets `customCdn` and reports progress and cancellation; the slow and failing upload switches apply on top. Co-Authored-By: Claude Opus 5.5 --- examples/vite/.gitignore | 3 + examples/vite/mockCdnPlugin.ts | 117 ++++++++++++++++++ examples/vite/src/App.tsx | 35 ++++-- examples/vite/src/AppSettings/state.ts | 12 +- .../AppSettings/tabs/Composer/ComposerTab.tsx | 68 +++++++++- .../src/SendWhilePendingUploads/cdnUpload.ts | 66 ++++++++++ .../vite/src/SendWhilePendingUploads/index.ts | 1 + .../SendWhilePendingUploads/uploadHarness.ts | 28 +++-- examples/vite/tsconfig.node.json | 2 +- examples/vite/vite.config.ts | 2 + 10 files changed, 311 insertions(+), 23 deletions(-) create mode 100644 examples/vite/mockCdnPlugin.ts create mode 100644 examples/vite/src/SendWhilePendingUploads/cdnUpload.ts diff --git a/examples/vite/.gitignore b/examples/vite/.gitignore index fc5ae9f0cc..f337abe6ae 100644 --- a/examples/vite/.gitignore +++ b/examples/vite/.gitignore @@ -23,3 +23,6 @@ dist-ssr *.sln *.sw? .vercel + +# files stored by the mock CDN (mockCdnPlugin.ts) +.mock-cdn diff --git a/examples/vite/mockCdnPlugin.ts b/examples/vite/mockCdnPlugin.ts new file mode 100644 index 0000000000..3d06645d99 --- /dev/null +++ b/examples/vite/mockCdnPlugin.ts @@ -0,0 +1,117 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { extname, resolve } from 'node:path'; +import { Readable } from 'node:stream'; +import type { Connect, Plugin } from 'vite'; + +/** URL prefix the mock CDN is served under, on the dev (and preview) server's own origin. */ +export const MOCK_CDN_PATH = '/mock-cdn'; + +/** Stored file names: a UUID plus the uploaded file's extension, nothing a path could escape with. */ +const STORED_NAME = /^[0-9a-f-]{36}(\.[\w]{1,10})?$/i; + +type StoredFileMeta = { name: string; type: string }; + +const sendJson = (res: ServerResponse, status: number, body: unknown) => { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); +}; + +const readFormData = (req: IncomingMessage) => { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers.set(key, value); + else if (Array.isArray(value)) value.forEach((item) => headers.append(key, item)); + } + return new Request(`http://mock-cdn${req.url ?? ''}`, { + body: Readable.toWeb(req) as ReadableStream, + // required by Node for a streamed request body + duplex: 'half', + headers, + method: 'POST', + } as RequestInit).formData(); +}; + +/** + * A stand-in CDN for trying `doUploadRequest` against storage Stream does not host. + * + * - `POST /mock-cdn/upload` takes `multipart/form-data` with the file in the `file` field, stores it + * under `storageDir` and answers `{ file: }` — the shape `doUploadRequest` resolves with. + * - `GET /mock-cdn/files/` serves a stored file back. + * + * Files live on disk, so they outlive a dev-server restart. The URLs point at this server, so only + * browsers that can reach it can display them. + */ +export const mockCdnPlugin = ({ storageDir }: { storageDir: string }): Plugin => { + const handle: Connect.NextHandleFunction = (req, res, next) => { + const path = (req.url ?? '').split('?')[0]; + if (!path.startsWith(`${MOCK_CDN_PATH}/`)) return next(); + + void (async () => { + try { + if (req.method === 'POST' && path === `${MOCK_CDN_PATH}/upload`) { + const file = (await readFormData(req)).get('file'); + if (!(file instanceof Blob)) { + return sendJson(res, 400, { + message: 'Expected a file in the "file" field.', + }); + } + const name = + 'name' in file && typeof file.name === 'string' ? file.name : 'file'; + const storedName = `${randomUUID()}${extname(name).slice(0, 11)}`; + await mkdir(storageDir, { recursive: true }); + await writeFile( + resolve(storageDir, storedName), + Buffer.from(await file.arrayBuffer()), + ); + const meta: StoredFileMeta = { + name, + type: file.type || 'application/octet-stream', + }; + await writeFile( + resolve(storageDir, `${storedName}.json`), + JSON.stringify(meta), + ); + return sendJson(res, 201, { + file: `http://${req.headers.host}${MOCK_CDN_PATH}/files/${storedName}`, + }); + } + + const fileMatch = path.match(new RegExp(`^${MOCK_CDN_PATH}/files/([^/]+)$`)); + if (req.method === 'GET' && fileMatch && STORED_NAME.test(fileMatch[1])) { + const storedName = fileMatch[1]; + const meta = JSON.parse( + await readFile(resolve(storageDir, `${storedName}.json`), 'utf8'), + ) as StoredFileMeta; + res.statusCode = 200; + res.setHeader('Content-Type', meta.type); + res.setHeader( + 'Content-Disposition', + `inline; filename="${encodeURIComponent(meta.name)}"`, + ); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.end(await readFile(resolve(storageDir, storedName))); + } + + sendJson(res, 404, { message: 'Not found.' }); + } catch (error) { + const missing = (error as NodeJS.ErrnoException).code === 'ENOENT'; + sendJson(res, missing ? 404 : 500, { + message: missing ? 'Not found.' : String(error), + }); + } + })(); + }; + + return { + configurePreviewServer: (server) => { + server.middlewares.use(handle); + }, + configureServer: (server) => { + server.middlewares.use(handle); + }, + name: 'mock-cdn', + }; +}; diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index a243828d78..6263c42454 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -12,6 +12,7 @@ import type { LocalMessage, SortParamRequest, TextComposerMiddleware, + UploadRequestFn, } from 'stream-chat'; import { ChannelPaginator, @@ -97,7 +98,11 @@ import { InlineEditableMessage } from './InlineEditMessage'; import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; import { StreamDebugHandles } from './Debug'; -import { installUploadHarness } from './SendWhilePendingUploads'; +import { + installUploadHarness, + MOCK_CDN_UPLOAD_URL, + uploadToCdn, +} from './SendWhilePendingUploads'; import { streamI18n } from './i18n'; import { DocumentTitleManager, @@ -287,7 +292,7 @@ const formatDocumentTitle = ({ const App = () => { const { tokenProvider, userId, userImage, userName } = useUser(); const chatView = useAppSettingsSelector((state) => state.chatView); - const { failUploads, sendMessagesWithPendingUploads, slowUploads } = + const { failUploads, sendMessagesWithPendingUploads, slowUploads, uploadDestination } = useAppSettingsSelector((state) => state.composer); // Project to a stable-shape object rather than returning `state.layout` directly. `layout` // starts as `{}`, and useStateStore only diffs the keys present in its *cached* selection — so @@ -513,15 +518,26 @@ const App = () => { // Settings are read on every upload rather than captured here, so changing them in // Settings -> Composer takes effect without re-running setup - which matters because a // custom doUploadRequest cannot be un-set once installed. - if (slowUploads || failUploads !== 'off') { + if (slowUploads || failUploads !== 'off' || uploadDestination !== 'stream') { installUploadHarness(composer, () => { const { + customCdnUrl, failUploads: failureMode, slowUploadMs, slowUploads: slowArmed, + uploadDestination: destination, } = appSettingsStore.getLatestValue().composer; - - return { delayMs: slowArmed ? slowUploadMs : 0, failureMode }; + const upload: UploadRequestFn = + destination === 'stream' + ? composer.attachmentManager.doDefaultUploadRequest + : (fileLike, options) => + uploadToCdn( + destination === 'mock-cdn' ? MOCK_CDN_UPLOAD_URL : customCdnUrl, + fileLike, + options, + ); + + return { delayMs: slowArmed ? slowUploadMs : 0, failureMode, upload }; }); } @@ -563,7 +579,7 @@ const App = () => { location: { enabled: true }, }); }); - }, [chatClient, failUploads, slowUploads]); + }, [chatClient, failUploads, slowUploads, uploadDestination]); useEffect(() => { if (!chatClient) return; @@ -573,9 +589,12 @@ const App = () => { // subscriptions, and the latter is what mounting a channel does - so an open composer sees it // at once and the rest on their way in. chatClient.config.setConfig('messageComposer', { - attachments: { pendingUploadsEnabled: sendMessagesWithPendingUploads }, + attachments: { + customCdn: uploadDestination !== 'stream', + pendingUploadsEnabled: sendMessagesWithPendingUploads, + }, }); - }, [chatClient, sendMessagesWithPendingUploads]); + }, [chatClient, sendMessagesWithPendingUploads, uploadDestination]); const chatTheme = themeMode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; const initialAppLayoutStyle = useMemo( diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 892661fb9e..4d2a78194f 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -2,7 +2,7 @@ import { StateStore } from '@stream-io/state-store'; import { useStateStore } from 'stream-chat-react'; import { DEFAULT_LANGUAGE, streamI18n } from '../i18n'; -import type { UploadFailureMode } from '../SendWhilePendingUploads'; +import type { UploadDestination, UploadFailureMode } from '../SendWhilePendingUploads'; export type ReactionsSettingsState = { flipHorizontalPosition: boolean; @@ -100,6 +100,8 @@ export type LayoutSettingsState = { }; export type ComposerSettingsState = { + /** The CDN upload endpoint used while `uploadDestination` is `custom-url`. */ + customCdnUrl: string; /** * Allow sending a message while its attachments are still uploading. */ @@ -118,6 +120,12 @@ export type ComposerSettingsState = { * Useful for watching the default blocked behaviour too. */ slowUploads: boolean; + /** + * Where attachments are uploaded: Stream's storage, the dev server's mock CDN, or the CDN at + * `customCdnUrl`. Either CDN marks the composer's uploads `customCdn`, so Stream's upload + * permission and the channel type's `uploads` flag no longer apply. + */ + uploadDestination: UploadDestination; }; export type AppSettingsState = { @@ -176,10 +184,12 @@ const defaultAppSettingsState: AppSettingsState = { iconOnly: true, }, composer: { + customCdnUrl: '', failUploads: 'off', sendMessagesWithPendingUploads: false, slowUploadMs: 20000, slowUploads: false, + uploadDestination: 'stream', }, language: { code: DEFAULT_LANGUAGE, diff --git a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx index c68525d7b7..b295909eb8 100644 --- a/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Composer/ComposerTab.tsx @@ -1,12 +1,21 @@ import { useState } from 'react'; -import { Button, NumericInput, SwitchField } from 'stream-chat-react'; -import { FAILING_FILE_NAME_PREFIX } from '../../../SendWhilePendingUploads'; +import { Button, NumericInput, SwitchField, TextInput } from 'stream-chat-react'; +import { + FAILING_FILE_NAME_PREFIX, + type UploadDestination, +} from '../../../SendWhilePendingUploads'; import { appSettingsStore, useAppSettingsState } from '../../state'; import { SettingsTabBody, SettingsTabLayoutHeader, } from '../SettingsTabLayoutComponents.tsx'; +const uploadDestinations: { label: string; value: UploadDestination }[] = [ + { label: 'Stream', value: 'stream' }, + { label: 'Mock CDN', value: 'mock-cdn' }, + { label: 'CDN at a URL', value: 'custom-url' }, +]; + type ComposerTabProps = { close: () => void; }; @@ -14,7 +23,14 @@ type ComposerTabProps = { export const ComposerTab = ({ close }: ComposerTabProps) => { const { composer, - composer: { failUploads, sendMessagesWithPendingUploads, slowUploadMs, slowUploads }, + composer: { + customCdnUrl, + failUploads, + sendMessagesWithPendingUploads, + slowUploadMs, + slowUploads, + uploadDestination, + }, } = useAppSettingsState(); // NumericInput is a text input that also accepts '', which has no numeric equivalent, so the // typed value is held locally and only committed to the store once it parses. @@ -52,6 +68,52 @@ export const ComposerTab = ({ close }: ComposerTabProps) => {
+
+
Upload destination
+
+ {uploadDestinations.map(({ label, value }) => ( + + ))} +
+ {uploadDestination === 'custom-url' && ( + + appSettingsStore.partialNext({ + composer: { ...composer, customCdnUrl: event.target.value.trim() }, + }) + } + placeholder='https://cdn.example.com/upload' + type='url' + value={customCdnUrl} + /> + )} +
+ Where attachments are uploaded. Mock CDN stores files on disk + in examples/vite/.mock-cdn and serves them back from this dev + server, so only browsers that reach it can display them; it exists only under{' '} + yarn start:vite and vite preview, not in the + deployed app. CDN at a URL posts each file as{' '} + multipart/form-data in the file field and expects + JSON {'{ file, thumb_url? }'} back; the CDN has to allow this + origin (CORS). Either CDN sets customCdn, so Stream's upload + permission no longer applies. Combines with the slow and failing switches + below; applies to the next upload. +
+
+
Slow uploads
new DOMException('Upload aborted', 'AbortError'); + +/** + * Uploads a file to a CDN that accepts `multipart/form-data` with the file in the `file` field and + * answers with JSON `{ file: , thumb_url?: }` — what `doUploadRequest` resolves + * with. Reports progress through `options.onProgress` and stops on `options.abortSignal`. + */ +export const uploadToCdn = ( + url: string, + fileLike: FileReference | FileLike, + options?: UploadRequestOptions, +) => + new Promise((resolve, reject) => { + if (!url) { + reject(new Error('No CDN URL set: enter one in Settings → Composer.')); + return; + } + if (!(fileLike instanceof Blob)) { + reject(new Error('Only browser files can be uploaded to the CDN.')); + return; + } + const signal = options?.abortSignal; + if (signal?.aborted) { + reject(abortError()); + return; + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); + xhr.responseType = 'json'; + xhr.upload.onprogress = (event) => { + options?.onProgress?.( + event.lengthComputable + ? Math.round((event.loaded / event.total) * 100) + : undefined, + ); + }; + xhr.onload = () => { + const response = xhr.response as Partial | null; + if (xhr.status >= 200 && xhr.status < 300 && typeof response?.file === 'string') { + resolve(response as MinimumUploadRequestResult); + } else { + reject(new Error(`CDN upload failed with status ${xhr.status}.`)); + } + }; + xhr.onerror = () => reject(new Error('CDN upload failed: network error.')); + xhr.onabort = () => reject(abortError()); + signal?.addEventListener('abort', () => xhr.abort(), { once: true }); + + const body = new FormData(); + body.append('file', fileLike, 'name' in fileLike ? fileLike.name : 'file'); + xhr.send(body); + }); diff --git a/examples/vite/src/SendWhilePendingUploads/index.ts b/examples/vite/src/SendWhilePendingUploads/index.ts index 0df6afc01e..c773cef96a 100644 --- a/examples/vite/src/SendWhilePendingUploads/index.ts +++ b/examples/vite/src/SendWhilePendingUploads/index.ts @@ -1,3 +1,4 @@ +export { MOCK_CDN_UPLOAD_URL, type UploadDestination, uploadToCdn } from './cdnUpload'; export { FAILING_FILE_NAME_PREFIX, installUploadHarness, diff --git a/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts b/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts index c6db22ef20..e848c8e999 100644 --- a/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts +++ b/examples/vite/src/SendWhilePendingUploads/uploadHarness.ts @@ -1,5 +1,10 @@ -import type { MessageComposer, UploadRequestOptions } from 'stream-chat'; -import type { FileLike, FileReference } from 'stream-chat'; +import type { + FileLike, + FileReference, + MessageComposer, + UploadRequestFn, + UploadRequestOptions, +} from 'stream-chat'; /** Files whose name starts with this fail when the failure mode is `prefixed`. */ export const FAILING_FILE_NAME_PREFIX = 'fail-'; @@ -11,11 +16,13 @@ export type UploadHarnessSettings = { delayMs: number; /** Which uploads should reject instead of completing. */ failureMode: UploadFailureMode; + /** The request that stores the file: Stream's own, or one to a CDN. */ + upload: UploadRequestFn; }; /** - * Dev-only harness wrapping `doUploadRequest`, so uploads can be slowed down and made to fail on - * demand from **Settings → Composer**. + * Dev-only harness wrapping `doUploadRequest`, so uploads can be sent to a CDN, slowed down and made + * to fail on demand from **Settings → Composer**. * * Without the delay there is nothing to look at: this app's Stream project caps uploads at 3 MiB, * which lands in well under a second. Without the failure switch there is no way to reach the @@ -24,11 +31,12 @@ export type UploadHarnessSettings = { * Both live in one function because a custom `doUploadRequest` cannot be un-set — * `MessageComposer.updateConfig` merges via `mergeWith`, which skips `undefined` — and the * attachment manager holds only one. So the harness is installed once and reads the current - * settings on every upload; with the delay at `0` and failures off it is a pass-through. + * settings on every upload; with the delay at `0`, failures off and Stream as the destination it is + * a pass-through. * * Caveat: installing a custom `doUploadRequest` flips `hasCustomDoUploadRequest`, which slightly * changes the guard in `uploadFiles`. Irrelevant for the demo, but it is why the harness is - * installed only once one of the switches is armed rather than unconditionally. + * installed only once one of the switches is armed or a CDN is chosen rather than unconditionally. */ const PROGRESS_STEPS = 20; /** @@ -55,19 +63,19 @@ const shouldFail = (fileLike: FileReference | FileLike, mode: UploadFailureMode) export const installUploadHarness = ( composer: MessageComposer, /** - * Read at call time, not at install time, so changing either setting takes effect on the next + * Read at call time, not at install time, so changing any setting takes effect on the next * upload without re-running the composer setup. */ getSettings: () => UploadHarnessSettings, ) => { composer.attachmentManager.setCustomUploadFn( async (fileLike: FileReference | FileLike, options?: UploadRequestOptions) => { - const { delayMs, failureMode } = getSettings(); + const { delayMs, failureMode, upload } = getSettings(); const failing = shouldFail(fileLike, failureMode); if (delayMs <= 0) { if (failing) throw new Error('Simulated upload failure'); - return composer.attachmentManager.doDefaultUploadRequest(fileLike, options); + return upload(fileLike, options); } const rampMs = delayMs * RAMP_SHARE; @@ -91,7 +99,7 @@ export const installUploadHarness = ( } if (failing) throw new Error('Simulated upload failure'); - return composer.attachmentManager.doDefaultUploadRequest(fileLike, options); + return upload(fileLike, options); }, ); }; diff --git a/examples/vite/tsconfig.node.json b/examples/vite/tsconfig.node.json index 97ede7ee6f..5b459bab24 100644 --- a/examples/vite/tsconfig.node.json +++ b/examples/vite/tsconfig.node.json @@ -7,5 +7,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "mockCdnPlugin.ts"] } diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index ec08152c13..9dc992420c 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'; import { defineConfig, loadEnv } from 'vite'; import babel from 'vite-plugin-babel'; import react from '@vitejs/plugin-react'; +import { mockCdnPlugin } from './mockCdnPlugin'; const require = createRequire(import.meta.url); @@ -89,6 +90,7 @@ export default defineConfig(({ mode }) => { }, ] : []), + mockCdnPlugin({ storageDir: resolve(rootDir, '.mock-cdn') }), react(), babel({ babelConfig: { From a84983c6b88fc428a11c04963d784a5a50267676 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 22:08:47 +0200 Subject: [PATCH 19/19] chore(examples): empty disposed channel slots and show the empty placeholder The app mounts `DisposedChannelRelease`, so a slot whose channel is deleted (or the user removed from it) empties. The channels view shows `ChatViewEmptyPlaceholder` while no slot holds anything, and the threads view shows it instead of the message-list empty indicator. German and Italian copy for the placeholder. Co-Authored-By: Claude Opus 5.5 --- examples/vite/src/App.tsx | 3 ++- examples/vite/src/ChatLayout/Panels.tsx | 11 +++++++---- examples/vite/src/i18n/de.ts | 2 ++ examples/vite/src/i18n/it.ts | 2 ++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 6263c42454..18feeef0b2 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -41,7 +41,7 @@ import { useCreateChatClient, WithComponents, } from 'stream-chat-react'; -import { ChatView } from 'stream-chat-react/slot-layout'; +import { ChatView, DisposedChannelRelease } from 'stream-chat-react/slot-layout'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data/sets/14/native.json'; @@ -718,6 +718,7 @@ const App = () => { views={chatViews} > + diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index a9bbfa434a..b9d5634e84 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -19,7 +19,6 @@ import { ChannelHeader, ChannelListItem, MessageComposerUI as DefaultMessageComposerUI, - EmptyStateIndicator, MessageComposer, MessageList, type MessageListProps, @@ -36,6 +35,7 @@ import { } from 'stream-chat-react'; import { ChatView, + ChatViewEmptyPlaceholder, type ChatViewSelectorEntry, ThreadSlot, useActiveThread, @@ -485,6 +485,11 @@ const ResponsiveChannelPanels = ({ mainChannel }: { mainChannel?: StreamChannel {/* The resize handle belongs to the SLOT, not its contents: it's rendered once here and driven by whether the slot is open (base binding OR a layer such as the member profile), so a layer that covers the base — or is the only occupant — stays resizable. */} + {!mainChannel && !isSideOpen && ( +
+ +
+ )} {/* The base of the secondary slot (2nd channel or reply thread) is ALWAYS rendered at a stable position so it stays mounted — a member-profile layer covers it (below) rather @@ -679,9 +684,7 @@ export const ThreadsPanels = ({ })} > {!hasThread ? ( -
- -
+ ) : ( <> {mainThread && } diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index c63b3feaf3..1eaa1e0545 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -728,6 +728,8 @@ export const deTranslations = { 'search.sourceResults.searching.text': 'Suche nach {{ searchSourceType }}...', 'slotLayout.chatView.channels.text': 'Kanäle', 'slotLayout.chatView.chatViewControls.ariaLabel': 'Chat-Ansichtssteuerung', + 'slotLayout.chatView.empty.channels.text': 'Kein Chat ausgewählt', + 'slotLayout.chatView.empty.threads.text': 'Kein Thread ausgewählt', 'slotLayout.chatView.openChannelsView.ariaLabel': 'Kanalansicht öffnen', 'slotLayout.chatView.openThreadsView.ariaLabel': 'Thread-Ansicht öffnen', 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one': diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index a895f04a7d..4de66c0356 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -726,6 +726,8 @@ export const itTranslations = { 'search.sourceResults.searching.text': 'Ricerca di {{ searchSourceType }}...', 'slotLayout.chatView.channels.text': 'Canali', 'slotLayout.chatView.chatViewControls.ariaLabel': 'Controlli della vista chat', + 'slotLayout.chatView.empty.channels.text': 'Nessuna chat selezionata', + 'slotLayout.chatView.empty.threads.text': 'Nessun thread selezionato', 'slotLayout.chatView.openChannelsView.ariaLabel': 'Apri la vista dei canali', 'slotLayout.chatView.openThreadsView.ariaLabel': 'Apri la vista dei thread', 'slotLayout.chatView.openThreadsViewUnread.ariaLabel_one':