Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 211 additions & 3 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,6 +16,7 @@ import {
Channel,
ChannelAvatar,
ChannelHeader,
type ChatView as ChatViewType,
ChannelList,
Chat,
ChatView,
Expand All @@ -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';
Expand All @@ -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');
}
Expand Down Expand Up @@ -127,6 +199,8 @@ const CustomMessageReactions = (props: React.ComponentProps<typeof ReactionsList
const App = () => {
const { userId, tokenProvider } = useUser();
const { chatView } = useAppSettingsState();
const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []);
const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []);

const chatClient = useCreateChatClient({
apiKey,
Expand Down Expand Up @@ -200,6 +274,7 @@ const App = () => {
>
<Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
<ChatView>
<ChatStateSync initialChatView={initialChatView} />
<ChatView.Selector
itemSet={chatViewSelectorItemSet}
iconOnly={chatView.iconOnly}
Expand All @@ -208,6 +283,7 @@ const App = () => {
<ChannelList
ChannelSearch={Search}
Avatar={ChannelAvatar}
customActiveChannel={initialChannelId ?? undefined}
filters={filters}
options={options}
sort={sort}
Expand All @@ -225,7 +301,6 @@ const App = () => {
maxRows={10}
asyncMessagesMultiSendEnabled
/>
<ChannelExposer />
</Window>
</WithDragAndDropUpload>
<WithDragAndDropUpload className='str-chat__dropzone-root--thread'>
Expand All @@ -234,6 +309,7 @@ const App = () => {
</Channel>
</ChatView.Channels>
<ChatView.Threads>
<ThreadStateSync />
<ThreadList />
<ChatView.ThreadAdapter>
<Thread virtualized />
Expand All @@ -245,13 +321,145 @@ const App = () => {
);
};

const ChannelExposer = () => {
const ChatStateSync = ({ initialChatView }: { initialChatView?: ChatViewType }) => {
const { activeChatView, setActiveChatView } = useChatViewContext();
const { channel, client } = useChatContext();
const previousSyncedChatView = useRef<ChatViewType | undefined>(undefined);
const previousChannelId = useRef<string | undefined>(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
window.channel = channel;
return null;
};

const threadManagerSelector = (nextValue: ThreadManagerState) => ({
isLoading: nextValue.pagination.isLoading,
ready: nextValue.ready,
threads: nextValue.threads,
});

const ThreadStateSync = () => {
const selectedThreadId = useRef<string | undefined>(
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<string | undefined>(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;
16 changes: 16 additions & 0 deletions jest.env.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
}
1 change: 1 addition & 0 deletions src/components/Button/ToggleSidebarButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 1 addition & 2 deletions src/components/ChannelList/ChannelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,8 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
});

setChannels(newChannels);
return;
}

return;
}

if (setActiveChannelOnMount) {
Expand Down
35 changes: 35 additions & 0 deletions src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChatContext.Provider
value={{
channelsQueryState: channelsQueryStateMock,
client: chatClient,
searchController: new SearchController(),
setActiveChannel,
}}
>
<ChannelList
customActiveChannel='missing-channel-id'
filters={{}}
List={ChannelListComponent}
options={{ presence: true, state: true, watch: true }}
setActiveChannel={setActiveChannel}
setActiveChannelOnMount
watchers={watchersConfig}
/>
</ChatContext.Provider>,
);

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(
<ChatContext.Provider
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import clsx from 'clsx';
import {
type ChannelPreviewDeliveryStatus,
Expand Down
Loading