Skip to content

Commit

Permalink
feat: add channel list context (#2187)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinCupela committed Dec 1, 2023
1 parent 32e4867 commit fd5ea67
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 4 deletions.
114 changes: 114 additions & 0 deletions docusaurus/docs/React/components/contexts/channel-list-context.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
id: channel_list_context
sidebar_position: 11
title: ChannelListContext
---

The context value is provided by `ChannelListContextProvider` which wraps the contents rendered by [`ChannelList`](../core-components/channel-list.mdx). It exposes API that the default and custom components rendered by `ChannelList` can take advantage of. The components that can consume the context are customizable via `ChannelListProps`:

- `Avatar` - component used to display channel image
- `ChannelSearch` - renders channel search input and results
- `EmptyStateIndicator` - rendered when the channels query returns and empty array
- `LoadingErrorIndicator` - rendered when the channels query fails
- `LoadingIndicator`- rendered during the channels query
- `List` - component rendering `LoadingErrorIndicator`, `LoadingIndicator`, `EmptyStateIndicator`, `Paginator` and the list of channel `Preview` components
- `Paginator` - takes care of requesting to load more channels into the list (pagination)
- `Preview` - renders the information of a channel in the channel list

## Basic Usage

Access the API from context with our custom hook:

```jsx
import { useChannelListContext } from 'stream-chat-react';

export const CustomComponent = () => {
const { channels, setChannels } = useChannelListContext();
// component logic ...
return(
{/* rendered elements */}
);
}
```

## Value

### channels

State representing the array of loaded channels. Channels query is executed by default only within the [`ChannelList` component](../core-components/channel-list.mdx) in the SDK.

| Type |
|-------------|
| `Channel[]` |

### setChannels

Sets the list of `Channel` objects to be rendered by `ChannelList` component. One have to be careful, when to call `setChannels` as the first channels query executed by the `ChannelList` overrides the whole [`channels` state](#channels). In that case it is better to subscribe to `client` event `channels.queried` and only then set the channels.
In the following example, we have a component that sets the active channel based on the id in the URL. It waits until the first channels page is loaded, and then it sets the active channel. If the channel is not present on the first page, it performs additional API request with `getChannel()`:

```tsx
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChannelList, ChannelListMessenger, ChannelListMessengerProps, getChannel, useChannelListContext, useChatContext } from 'stream-chat-react';

const DEFAULT_CHANNEL_ID = 'general';
const DEFAULT_CHANNEL_TYPE = 'messaging';

const List = (props: ChannelListMessengerProps) => {
const { channelId } = useParams();
const navigate = useNavigate();
const { client, channel, setActiveChannel } = useChatContext();
const { setChannels } = useChannelListContext();

useEffect(() => {
if (!channelId) return navigate(`/${DEFAULT_CHANNEL_ID}`);

if (channel?.id === channelId || !client) return;

let subscription: { unsubscribe: () => void } | undefined;
if(!channel?.id || channel?.id !== channelId) {
subscription = client.on('channels.queried', (event: Event) => {
const loadedChannelData = event.queriedChannels?.channels.find((response) => response.channel.id === channelId);

if (loadedChannelData) {
setActiveChannel(client.channel( DEFAULT_CHANNEL_TYPE, channelId));
subscription?.unsubscribe();
return;
}

return getChannel({client, id: channelId, type: DEFAULT_CHANNEL_TYPE}).then((newActiveChannel) => {
setActiveChannel(newActiveChannel);
setChannels((channels) => {
return ([newActiveChannel, ...channels.filter((ch) => ch.data?.cid !== newActiveChannel.data?.cid)]);
});
});
});
}

return () => {
subscription?.unsubscribe();
};
}, [channel?.id, channelId, setChannels, client, navigate, setActiveChannel]);

return <ChannelListMessenger {...props}/>;
};



const Sidebar = () => {
return (
// ...
<ChannelList
{/* some props */}
{/* setting active channel will be performed inside the custom List component */}
setActiveChannelOnMount={false}
List={List}
{/* some props */}
/>
// ...
}
```
| Type |
|---------------------------------------|
| `Dispatch<SetStateAction<Channel[]>>` |
5 changes: 3 additions & 2 deletions src/components/ChannelList/ChannelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { LoadingChannels } from '../Loading/LoadingChannels';
import { LoadMorePaginator, LoadMorePaginatorProps } from '../LoadMore/LoadMorePaginator';

import { ChannelListContextProvider } from '../../context';
import { useChatContext } from '../../context/ChatContext';

import type { Channel, ChannelFilters, ChannelOptions, ChannelSort, Event } from 'stream-chat';
Expand Down Expand Up @@ -336,7 +337,7 @@ const UnMemoizedChannelList = <

const showChannelList = !searchActive || additionalChannelSearchProps?.popupResults;
return (
<>
<ChannelListContextProvider value={{ channels, setChannels }}>
<div className={className} ref={channelListRef}>
{showChannelSearch && (
<ChannelSearch
Expand Down Expand Up @@ -374,7 +375,7 @@ const UnMemoizedChannelList = <
</List>
)}
</div>
</>
</ChannelListContextProvider>
);
};

Expand Down
47 changes: 45 additions & 2 deletions src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { nanoid } from 'nanoid';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
Expand Down Expand Up @@ -36,7 +36,7 @@ import {
ChannelPreviewMessenger,
} from '../../ChannelPreview';

import { ChatContext, useChatContext } from '../../../context/ChatContext';
import { ChatContext, useChannelListContext, useChatContext } from '../../../context';
import { ChannelListMessenger } from '../ChannelListMessenger';

expect.extend(toHaveNoViolations);
Expand Down Expand Up @@ -1663,4 +1663,47 @@ describe('ChannelList', () => {
dateNowSpy.mockRestore();
});
});

describe('context', () => {
it('allows to set the new list of channels', async () => {
let setChannelsFromOutside;
const channelsToBeLoaded = Array.from({ length: 5 }, generateChannel);
const channelsToBeSet = Array.from({ length: 5 }, generateChannel);
const channelsToIdString = (channels) => channels.map(({ id }) => id).join();
const channelsDataToIdString = (channels) => channels.map(({ channel: { id } }) => id).join();

const ChannelListCustom = () => {
const { channels, setChannels } = useChannelListContext();
useEffect(() => {
setChannelsFromOutside = setChannels;
}, []);
return <div>{channelsToIdString(channels)}</div>;
};
const props = {
filters: {},
List: ChannelListCustom,
Preview: ChannelPreviewComponent,
};

useMockedApis(chatClient, [queryChannelsApi(channelsToBeLoaded)]);

await act(async () => {
await render(
<Chat client={chatClient}>
<ChannelList {...props} />
</Chat>,
);
});

expect(screen.getByText(channelsDataToIdString(channelsToBeLoaded))).toBeInTheDocument();

await act(() => {
setChannelsFromOutside(chatClient.hydrateActiveChannels(channelsToBeSet));
});
expect(
screen.queryByText(channelsDataToIdString(channelsToBeLoaded)),
).not.toBeInTheDocument();
expect(screen.getByText(channelsDataToIdString(channelsToBeSet))).toBeInTheDocument();
});
});
});
61 changes: 61 additions & 0 deletions src/context/ChannelListContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, {
createContext,
Dispatch,
PropsWithChildren,
SetStateAction,
useContext,
} from 'react';

import type { Channel } from 'stream-chat';

import type { DefaultStreamChatGenerics } from '../types/types';

export type ChannelListContextValue<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
/**
* State representing the array of loaded channels.
* Channels query is executed by default only by ChannelList component in the SDK.
*/
channels: Channel<StreamChatGenerics>[];
/**
* Sets the list of Channel objects to be rendered by ChannelList component.
*/
setChannels: Dispatch<SetStateAction<Channel<StreamChatGenerics>[]>>;
};

export const ChannelListContext = createContext<ChannelListContextValue | undefined>(undefined);

/**
* Context provider for components rendered within the `ChannelList`
*/
export const ChannelListContextProvider = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>({
children,
value,
}: PropsWithChildren<{
value: ChannelListContextValue<StreamChatGenerics>;
}>) => (
<ChannelListContext.Provider value={(value as unknown) as ChannelListContextValue}>
{children}
</ChannelListContext.Provider>
);

export const useChannelListContext = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
componentName?: string,
) => {
const contextValue = useContext(ChannelListContext);

if (!contextValue) {
console.warn(
`The useChannelListContext hook was called outside of the ChannelListContext provider. Make sure this hook is called within the ChannelList component. The errored call is located in the ${componentName} component.`,
);

return {} as ChannelListContextValue<StreamChatGenerics>;
}

return (contextValue as unknown) as ChannelListContextValue<StreamChatGenerics>;
};
1 change: 1 addition & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './ChannelActionContext';
export * from './ChannelListContext';
export * from './ChannelStateContext';
export * from './ChatContext';
export * from './ComponentContext';
Expand Down

0 comments on commit fd5ea67

Please sign in to comment.