diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 6777b442f..74a3cc367 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ChannelFilters, ChannelOptions, ChannelSort, LocalMessage, TextComposerMiddleware, + type ThreadManagerState, createCommandInjectionMiddleware, createDraftCommandInjectionMiddleware, createActiveCommandGuardMiddleware, @@ -15,6 +16,7 @@ import { Channel, ChannelAvatar, ChannelHeader, + type ChatView as ChatViewType, ChannelList, Chat, ChatView, @@ -29,9 +31,12 @@ import { ReactionsList, 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'; @@ -53,10 +58,77 @@ 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; +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 getSelectedThreadIdFromUrl = () => + new URLSearchParams(window.location.search).get(selectedThreadUrlParam); + +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}`, + ); +}; + +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}`, + ); +}; + +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'); } @@ -127,6 +199,8 @@ const CustomMessageReactions = (props: React.ComponentProps { const { userId, tokenProvider } = useUser(); const { chatView } = useAppSettingsState(); + const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); + const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); const chatClient = useCreateChatClient({ apiKey, @@ -200,6 +274,7 @@ const App = () => { > + { { maxRows={10} asyncMessagesMultiSendEnabled /> - @@ -234,6 +309,7 @@ const App = () => { + @@ -245,8 +321,41 @@ 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; + 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 @@ -254,4 +363,103 @@ const ChannelExposer = () => { return null; }; +const threadManagerSelector = (nextValue: ThreadManagerState) => ({ + isLoading: nextValue.pagination.isLoading, + ready: nextValue.ready, + threads: nextValue.threads, +}); + +const ThreadStateSync = () => { + const selectedThreadId = useRef( + getSelectedThreadIdFromUrl() ?? undefined, + ); + 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 (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, + ); + + if (matchingThreadFromList && activeThread !== matchingThreadFromList) { + setActiveThread(matchingThreadFromList); + return; + } + + if ( + matchingThreadFromList || + activeThread?.id === threadIdToRestore || + isRestoringThread.current || + attemptedThreadLookup.current || + isLoading || + !ready + ) { + return; + } + + let cancelled = false; + + attemptedThreadLookup.current = true; + isRestoringThread.current = true; + + client + .getThread(threadIdToRestore) + .then((thread) => { + if (!thread || cancelled) return; + + setActiveThread(thread); + }) + .catch(() => undefined) + .finally(() => { + if (cancelled) return; + + isRestoringThread.current = false; + }); + + return () => { + cancelled = true; + }; + }, [activeThread, client, isLoading, ready, setActiveThread, threads]); + + return null; +}; + export default App; 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(