From e716bb646a46737495af69d6dec846e9ba317943 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 10 Mar 2026 16:39:14 +0100 Subject: [PATCH 1/5] fix: make vite example channel selection bookmarkable --- examples/vite/src/App.tsx | 39 ++++++++++++++++++- jest.env.setup.js | 16 ++++++++ src/components/Button/ToggleSidebarButton.tsx | 1 + src/components/ChannelList/ChannelList.tsx | 3 +- .../ChannelList/__tests__/ChannelList.test.js | 35 +++++++++++++++++ .../SummarizedMessagePreview.tsx | 1 + 6 files changed, 92 insertions(+), 3 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 6777b442f..3cd28e2f6 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ChannelFilters, ChannelOptions, @@ -53,10 +53,30 @@ const parseUserIdFromToken = (token: string) => { }; const apiKey = import.meta.env.VITE_STREAM_API_KEY; +const selectedChannelUrlParam = 'channel'; const token = new URLSearchParams(window.location.search).get('token') || import.meta.env.VITE_USER_TOKEN; +const getSelectedChannelIdFromUrl = () => + new URLSearchParams(window.location.search).get(selectedChannelUrlParam); + +const updateSelectedChannelIdInUrl = (channelId?: string) => { + const url = new URL(window.location.href); + + if (channelId) { + url.searchParams.set(selectedChannelUrlParam, channelId); + } else { + url.searchParams.delete(selectedChannelUrlParam); + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + if (!apiKey) { throw new Error('VITE_STREAM_API_KEY is not defined'); } @@ -127,6 +147,7 @@ const CustomMessageReactions = (props: React.ComponentProps { const { userId, tokenProvider } = useUser(); const { chatView } = useAppSettingsState(); + const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); const chatClient = useCreateChatClient({ apiKey, @@ -208,6 +229,7 @@ const App = () => { { const ChannelExposer = () => { const { channel, client } = useChatContext(); + const previousChannelId = useRef(undefined); + + useEffect(() => { + if (channel?.id) { + previousChannelId.current = channel.id; + updateSelectedChannelIdInUrl(channel.id); + return; + } + + if (!previousChannelId.current) return; + + previousChannelId.current = undefined; + updateSelectedChannelIdInUrl(); + }, [channel?.id]); + // @ts-expect-error expose client and channel for debugging purposes window.client = client; // @ts-expect-error expose client and channel for debugging purposes diff --git a/jest.env.setup.js b/jest.env.setup.js index 648457f68..1df2eff37 100644 --- a/jest.env.setup.js +++ b/jest.env.setup.js @@ -52,3 +52,19 @@ if (typeof URL.createObjectURL === 'undefined') { if (typeof URL.revokeObjectURL === 'undefined') { URL.revokeObjectURL = () => null; } + +if (typeof window !== 'undefined' && typeof window.matchMedia === 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query) => ({ + addEventListener: () => null, + addListener: () => null, + dispatchEvent: () => false, + matches: false, + media: query, + onchange: null, + removeEventListener: () => null, + removeListener: () => null, + }), + }); +} diff --git a/src/components/Button/ToggleSidebarButton.tsx b/src/components/Button/ToggleSidebarButton.tsx index e14d3904d..6f44eccba 100644 --- a/src/components/Button/ToggleSidebarButton.tsx +++ b/src/components/Button/ToggleSidebarButton.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { useIsMobileViewport } from '../ChannelHeader/hooks/useIsMobileViewport'; import { useChatContext, useTranslationContext } from '../../context'; import { Button, type ButtonProps } from './Button'; diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 230cb221a..4d87e0187 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -265,9 +265,8 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { }); setChannels(newChannels); + return; } - - return; } if (setActiveChannelOnMount) { diff --git a/src/components/ChannelList/__tests__/ChannelList.test.js b/src/components/ChannelList/__tests__/ChannelList.test.js index 9d53e3a54..a24521cbf 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.js +++ b/src/components/ChannelList/__tests__/ChannelList.test.js @@ -622,6 +622,41 @@ describe('ChannelList', () => { expect(await testSetActiveChannelCall(channelInstance)).toBe(true); }); + it('should fall back to the first channel when `customActiveChannel` is not found', async () => { + chatClient.axiosInstance.post.mockReset(); + chatClient.axiosInstance.post + .mockResolvedValueOnce(queryChannelsApi([testChannel1, testChannel2]).response) + .mockResolvedValueOnce(queryChannelsApi([]).response); + + render( + + + , + ); + + const channelInstance = chatClient.channel( + testChannel1.channel.type, + testChannel1.channel.id, + ); + + expect(await testSetActiveChannelCall(channelInstance)).toBe(true); + }); + it('should render channel with id `customActiveChannel` at top of the list', async () => { const { container, getAllByRole, getByRole, getByTestId } = render( Date: Wed, 11 Mar 2026 09:33:24 +0100 Subject: [PATCH 2/5] fix: persist selected chat view in vite example url --- examples/vite/src/App.tsx | 52 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 3cd28e2f6..6538bcaf1 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -15,6 +15,7 @@ import { Channel, ChannelAvatar, ChannelHeader, + type ChatView as ChatViewType, ChannelList, Chat, ChatView, @@ -29,6 +30,7 @@ import { ReactionsList, WithDragAndDropUpload, useChatContext, + useChatViewContext, defaultReactionOptions, ReactionOptions, mapEmojiMartData, @@ -54,6 +56,7 @@ const parseUserIdFromToken = (token: string) => { const apiKey = import.meta.env.VITE_STREAM_API_KEY; const selectedChannelUrlParam = 'channel'; +const selectedChatViewUrlParam = 'view'; const token = new URLSearchParams(window.location.search).get('token') || import.meta.env.VITE_USER_TOKEN; @@ -61,6 +64,17 @@ const token = const getSelectedChannelIdFromUrl = () => new URLSearchParams(window.location.search).get(selectedChannelUrlParam); +const getSelectedChatViewFromUrl = (): ChatViewType | undefined => { + const selectedChatView = new URLSearchParams(window.location.search).get( + selectedChatViewUrlParam, + ); + + if (selectedChatView === 'threads') return 'threads'; + if (selectedChatView === 'channels' || selectedChatView === 'chat') return 'channels'; + + return undefined; +}; + const updateSelectedChannelIdInUrl = (channelId?: string) => { const url = new URL(window.location.href); @@ -77,6 +91,21 @@ const updateSelectedChannelIdInUrl = (channelId?: string) => { ); }; +const updateSelectedChatViewInUrl = (chatView: ChatViewType) => { + const url = new URL(window.location.href); + + url.searchParams.set( + selectedChatViewUrlParam, + chatView === 'threads' ? 'threads' : 'chat', + ); + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + if (!apiKey) { throw new Error('VITE_STREAM_API_KEY is not defined'); } @@ -148,6 +177,7 @@ const App = () => { const { userId, tokenProvider } = useUser(); const { chatView } = useAppSettingsState(); const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); + const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); const chatClient = useCreateChatClient({ apiKey, @@ -221,6 +251,7 @@ const App = () => { > + { maxRows={10} asyncMessagesMultiSendEnabled /> - @@ -267,10 +297,28 @@ const App = () => { ); }; -const ChannelExposer = () => { +const ChatStateSync = ({ initialChatView }: { initialChatView?: ChatViewType }) => { + const { activeChatView, setActiveChatView } = useChatViewContext(); const { channel, client } = useChatContext(); + const previousSyncedChatView = useRef(undefined); const previousChannelId = useRef(undefined); + useEffect(() => { + if ( + initialChatView && + previousSyncedChatView.current === undefined && + activeChatView !== initialChatView + ) { + setActiveChatView(initialChatView); + return; + } + + if (previousSyncedChatView.current === activeChatView) return; + + previousSyncedChatView.current = activeChatView; + updateSelectedChatViewInUrl(activeChatView); + }, [activeChatView, initialChatView, setActiveChatView]); + useEffect(() => { if (channel?.id) { previousChannelId.current = channel.id; From 3b02c8cda28ce8dd085d97b07539d82e3f63f87e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 09:39:32 +0100 Subject: [PATCH 3/5] fix: persist selected thread in vite example url --- examples/vite/src/App.tsx | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 6538bcaf1..093d3b7e5 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -5,6 +5,7 @@ import { ChannelSort, LocalMessage, TextComposerMiddleware, + type ThreadManagerState, createCommandInjectionMiddleware, createDraftCommandInjectionMiddleware, createActiveCommandGuardMiddleware, @@ -31,9 +32,11 @@ import { WithDragAndDropUpload, useChatContext, useChatViewContext, + useThreadsViewContext, defaultReactionOptions, ReactionOptions, mapEmojiMartData, + useStateStore, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; @@ -57,6 +60,7 @@ const parseUserIdFromToken = (token: string) => { const apiKey = import.meta.env.VITE_STREAM_API_KEY; const selectedChannelUrlParam = 'channel'; const selectedChatViewUrlParam = 'view'; +const selectedThreadUrlParam = 'thread'; const token = new URLSearchParams(window.location.search).get('token') || import.meta.env.VITE_USER_TOKEN; @@ -75,6 +79,9 @@ const getSelectedChatViewFromUrl = (): ChatViewType | undefined => { return undefined; }; +const getSelectedThreadIdFromUrl = () => + new URLSearchParams(window.location.search).get(selectedThreadUrlParam); + const updateSelectedChannelIdInUrl = (channelId?: string) => { const url = new URL(window.location.href); @@ -106,6 +113,22 @@ const updateSelectedChatViewInUrl = (chatView: ChatViewType) => { ); }; +const updateSelectedThreadIdInUrl = (threadId?: string) => { + const url = new URL(window.location.href); + + if (threadId) { + url.searchParams.set(selectedThreadUrlParam, threadId); + } else { + url.searchParams.delete(selectedThreadUrlParam); + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + if (!apiKey) { throw new Error('VITE_STREAM_API_KEY is not defined'); } @@ -286,6 +309,7 @@ const App = () => { + @@ -339,4 +363,89 @@ const ChatStateSync = ({ initialChatView }: { initialChatView?: ChatViewType }) return null; }; +const threadManagerSelector = (nextValue: ThreadManagerState) => ({ + isLoading: nextValue.pagination.isLoading, + ready: nextValue.ready, + threads: nextValue.threads, +}); + +const ThreadStateSync = () => { + const initialThreadId = useMemo(() => getSelectedThreadIdFromUrl(), []); + const { client } = useChatContext(); + const { activeThread, setActiveThread } = useThreadsViewContext(); + const { isLoading, ready, threads } = useStateStore( + client.threads.state, + threadManagerSelector, + ) ?? { + isLoading: false, + ready: false, + threads: [], + }; + const isRestoringThread = useRef(false); + const previousThreadId = useRef(undefined); + const attemptedThreadLookup = useRef(false); + + useEffect(() => { + if (!initialThreadId) return; + + const matchingThreadFromList = threads.find( + (thread) => thread.id === initialThreadId, + ); + + if (matchingThreadFromList && activeThread !== matchingThreadFromList) { + setActiveThread(matchingThreadFromList); + return; + } + + if ( + matchingThreadFromList || + activeThread?.id === initialThreadId || + isRestoringThread.current || + attemptedThreadLookup.current || + isLoading || + !ready + ) { + return; + } + + let cancelled = false; + + attemptedThreadLookup.current = true; + isRestoringThread.current = true; + + client + .getThread(initialThreadId) + .then((thread) => { + if (!thread || cancelled) return; + + setActiveThread(thread); + }) + .catch(() => undefined) + .finally(() => { + if (cancelled) return; + + isRestoringThread.current = false; + }); + + return () => { + cancelled = true; + }; + }, [activeThread, client, initialThreadId, isLoading, ready, setActiveThread, threads]); + + useEffect(() => { + if (activeThread?.id) { + previousThreadId.current = activeThread.id; + updateSelectedThreadIdInUrl(activeThread.id); + return; + } + + if (!previousThreadId.current) return; + + previousThreadId.current = undefined; + updateSelectedThreadIdInUrl(); + }, [activeThread?.id]); + + return null; +}; + export default App; From 475238c698238be25c91f181c9351c99b61840e7 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 09:45:26 +0100 Subject: [PATCH 4/5] fix: keep thread selection in sync when toggling views --- examples/vite/src/App.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 093d3b7e5..e023d8971 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -370,7 +370,9 @@ const threadManagerSelector = (nextValue: ThreadManagerState) => ({ }); const ThreadStateSync = () => { - const initialThreadId = useMemo(() => getSelectedThreadIdFromUrl(), []); + const selectedThreadId = useRef( + getSelectedThreadIdFromUrl() ?? undefined, + ); const { client } = useChatContext(); const { activeThread, setActiveThread } = useThreadsViewContext(); const { isLoading, ready, threads } = useStateStore( @@ -386,10 +388,12 @@ const ThreadStateSync = () => { const attemptedThreadLookup = useRef(false); useEffect(() => { - if (!initialThreadId) return; + const threadIdToRestore = selectedThreadId.current; + + if (!threadIdToRestore) return; const matchingThreadFromList = threads.find( - (thread) => thread.id === initialThreadId, + (thread) => thread.id === threadIdToRestore, ); if (matchingThreadFromList && activeThread !== matchingThreadFromList) { @@ -399,7 +403,7 @@ const ThreadStateSync = () => { if ( matchingThreadFromList || - activeThread?.id === initialThreadId || + activeThread?.id === threadIdToRestore || isRestoringThread.current || attemptedThreadLookup.current || isLoading || @@ -414,7 +418,7 @@ const ThreadStateSync = () => { isRestoringThread.current = true; client - .getThread(initialThreadId) + .getThread(threadIdToRestore) .then((thread) => { if (!thread || cancelled) return; @@ -430,18 +434,22 @@ const ThreadStateSync = () => { return () => { cancelled = true; }; - }, [activeThread, client, initialThreadId, isLoading, ready, setActiveThread, threads]); + }, [activeThread, client, isLoading, ready, setActiveThread, threads]); useEffect(() => { if (activeThread?.id) { + selectedThreadId.current = activeThread.id; previousThreadId.current = activeThread.id; + attemptedThreadLookup.current = false; updateSelectedThreadIdInUrl(activeThread.id); return; } if (!previousThreadId.current) return; + selectedThreadId.current = undefined; previousThreadId.current = undefined; + attemptedThreadLookup.current = false; updateSelectedThreadIdInUrl(); }, [activeThread?.id]); From 9e39a4baeb8e24b88ee757dc4e80280f62cebb5f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 11 Mar 2026 09:51:25 +0100 Subject: [PATCH 5/5] fix: stop thread restore from overriding thread switches --- examples/vite/src/App.tsx | 40 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index e023d8971..74a3cc367 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -387,11 +387,34 @@ const ThreadStateSync = () => { const previousThreadId = useRef(undefined); const attemptedThreadLookup = useRef(false); + useEffect(() => { + if (activeThread?.id) { + selectedThreadId.current = activeThread.id; + previousThreadId.current = activeThread.id; + attemptedThreadLookup.current = false; + updateSelectedThreadIdInUrl(activeThread.id); + return; + } + + if (!previousThreadId.current) return; + + selectedThreadId.current = undefined; + previousThreadId.current = undefined; + attemptedThreadLookup.current = false; + updateSelectedThreadIdInUrl(); + }, [activeThread?.id]); + useEffect(() => { const threadIdToRestore = selectedThreadId.current; if (!threadIdToRestore) return; + // If the user just picked another thread, let that selection win and let the + // URL-sync effect above update the restore target before we try to restore again. + if (activeThread?.id && activeThread.id !== threadIdToRestore) { + return; + } + const matchingThreadFromList = threads.find( (thread) => thread.id === threadIdToRestore, ); @@ -436,23 +459,6 @@ const ThreadStateSync = () => { }; }, [activeThread, client, isLoading, ready, setActiveThread, threads]); - useEffect(() => { - if (activeThread?.id) { - selectedThreadId.current = activeThread.id; - previousThreadId.current = activeThread.id; - attemptedThreadLookup.current = false; - updateSelectedThreadIdInUrl(activeThread.id); - return; - } - - if (!previousThreadId.current) return; - - selectedThreadId.current = undefined; - previousThreadId.current = undefined; - attemptedThreadLookup.current = false; - updateSelectedThreadIdInUrl(); - }, [activeThread?.id]); - return null; };