Skip to content

Commit

Permalink
feat: expose channels state on chat level (#2161)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinCupela authored Nov 7, 2023
1 parent 0c0a550 commit 7e5543b
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 75 deletions.
98 changes: 98 additions & 0 deletions docusaurus/docs/React/components/contexts/chat-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ The currently active channel, which populates the [`Channel`](../core-components
| ------- |
| Channel |

### 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[]` |

### channelsQueryState

Exposes API that:

- indicates, whether and what channels query has been triggered within [`ChannelList` component](../core-components/channel-list.mdx) by its channels pagination controller - `queryInProgress` of type `ChannelQueryState`
- allows to set the `queryInProgress` state with `setQueryInProgress` state setter
- keeps track of error response from the channels query - `error`
- allows to set the `error` state with `setError`

The `queryInProgress` values are:

- `uninitialized` - the initial state before the first channels query is triggered
- `reload` - the initial channels query (loading the first page) is in progress
- `load-more` - loading the next page of channels
- `null` - at least one channels page has been loaded and there is no query in progress at the moment

| Type |
|----------------------|
| `ChannelsQueryState` |

### closeMobileNav

The function to close mobile navigation.
Expand Down Expand Up @@ -99,6 +127,76 @@ You can override the default behavior by pulling it from context and then utiliz
| -------- |
| function |

### 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, getChannel, useChatContext} from 'stream-chat-react';
import {ChannelFilters, ChannelOptions, ChannelSort, Event} from 'stream-chat';

const DEFAULT_CHANNEL = 'general';
const CHANNEL_TYPE = 'messaging';

export const ChannelListWrapper = () => {
const { channelId } = useParams();
const navigate = useNavigate();
const { client, channel, setActiveChannel, setChannels } = useChatContext();

const filters: ChannelFilters = { type: CHANNEL_TYPE, members: { $in: [client.user?.id || ''] } };
const options: ChannelOptions = { state: true, presence: true, limit: 10 };
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };

// set active channel only if URL param changed
useEffect(() => {
if (!channelId) return navigate(`/${DEFAULT_CHANNEL}`);

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

let subscription: { unsubscribe: () => void } | undefined;
if(!channel?.id || channel?.id !== channelId) {
subscription = client.on('channels.queried', (event: Event) => {
// check, whether the channel has already been loaded with the first page
const loadedChannelData = event.queriedChannels?.channels.find((response) => response.channel.id === channelId);

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

return getChannel({client, id: channelId, type: 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 (
<ChannelList
setActiveChannelOnMount={false}
filters={filters}
sort={sort}
options={options}
/>
);
};
```

| Type |
|---------------------------------------|
| `Dispatch<SetStateAction<Channel[]>>` |

### theme

Deprecated and to be removed in a future major release. Use the `customStyles` prop to adjust CSS variables and [customize the theme](../../guides/theming/css-and-theming.mdx#css-variables) of your app.
Expand Down
148 changes: 77 additions & 71 deletions src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ const channelsQueryStateMock = {
setQueryInProgress: jest.fn(),
};

const ChatContextOverrider = ({ chatContext, children }) => {
const existingContext = useChatContext();
return (
<ChatContext.Provider
value={{
...existingContext,
channelsQueryState: channelsQueryStateMock,
...chatContext,
}}
>
{children}
</ChatContext.Provider>
);
};

/**
* We use the following custom UI components for preview and list.
* If we use ChannelPreviewMessenger or ChannelPreviewLastMessage here, then changes
Expand Down Expand Up @@ -116,6 +131,7 @@ describe('ChannelList', () => {
client: chatClient,
closeMobileNav,
navOpen: true,
setChannels: jest.fn(),
}}
>
<ChannelList {...props} />
Expand Down Expand Up @@ -147,6 +163,7 @@ describe('ChannelList', () => {
client: chatClient,
closeMobileNav,
navOpen: false,
setChannels: jest.fn(),
}}
>
<ChannelList {...props} />
Expand Down Expand Up @@ -392,6 +409,7 @@ describe('ChannelList', () => {

describe('Default and custom active channel', () => {
let setActiveChannel;
let setChannels;
const watchersConfig = { limit: 20, offset: 0 };
const testSetActiveChannelCall = (channelInstance) =>
waitFor(() => {
Expand All @@ -402,6 +420,7 @@ describe('ChannelList', () => {

beforeEach(() => {
setActiveChannel = jest.fn();
setChannels = jest.fn();
useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]);
});

Expand All @@ -412,6 +431,7 @@ describe('ChannelList', () => {
channelsQueryState: channelsQueryStateMock,
client: chatClient,
setActiveChannel,
setChannels,
}}
>
<ChannelList
Expand Down Expand Up @@ -443,6 +463,7 @@ describe('ChannelList', () => {
channelsQueryState: channelsQueryStateMock,
client: chatClient,
setActiveChannel,
setChannels,
}}
>
<ChannelList
Expand All @@ -466,41 +487,40 @@ describe('ChannelList', () => {
});

it('should render channel with id `customActiveChannel` at top of the list', async () => {
const { container, getAllByRole, getByRole, getByTestId } = render(
<ChatContext.Provider
value={{
channelsQueryState: channelsQueryStateMock,
client: chatClient,
setActiveChannel,
}}
>
<ChannelList
customActiveChannel={testChannel2.channel.id}
filters={{}}
List={ChannelListComponent}
options={{ presence: true, state: true, watch: true }}
Preview={ChannelPreviewComponent}
setActiveChannel={setActiveChannel}
setActiveChannelOnMount
watchers={watchersConfig}
/>
</ChatContext.Provider>,
);
useMockedApis(chatClient, [getOrCreateChannelApi(testChannel2)]);
jest
.spyOn(chatClient, 'queryChannels')
.mockImplementationOnce(() =>
chatClient.hydrateActiveChannels([testChannel1, testChannel2]),
);
await act(async () => {
await render(
<Chat client={chatClient}>
<ChannelList
customActiveChannel={testChannel2.channel.id}
filters={{}}
List={ChannelListComponent}
options={{ presence: true, state: true, watch: true }}
Preview={ChannelPreviewComponent}
watchers={watchersConfig}
/>
</Chat>,
);
});

// Wait for list of channels to load in DOM.
await waitFor(async () => {
expect(getByRole('list')).toBeInTheDocument();
const items = getAllByRole('listitem');
await waitFor(() => {
expect(screen.getByRole('list')).toBeInTheDocument();
const items = screen.getAllByRole('listitem');

// Get the closest listitem to the channel that received new message.
const channelPreview = getByTestId(testChannel2.channel.id).closest(
ROLE_LIST_ITEM_SELECTOR,
);
const channelPreview = screen
.getByTestId(testChannel2.channel.id)
.closest(ROLE_LIST_ITEM_SELECTOR);

expect(channelPreview.isEqualNode(items[0])).toBe(true);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
jest.restoreAllMocks();
});

describe('channel search', () => {
Expand Down Expand Up @@ -535,20 +555,16 @@ describe('ChannelList', () => {

const renderComponents = (chatContext = {}, channeListProps) =>
render(
<ChatContext.Provider
value={{
channelsQueryState: channelsQueryStateMock,
setActiveChannel,
...chatContext,
}}
>
<ChannelList
filters={{}}
options={{ presence: true, state: true }}
showChannelSearch
{...channeListProps}
/>
</ChatContext.Provider>,
<Chat client={chatContext.client}>
<ChatContextOverrider chatContext={{ ...chatContext, setActiveChannel }}>
<ChannelList
filters={{}}
options={{ presence: true, state: true }}
showChannelSearch
{...channeListProps}
/>
</ChatContextOverrider>
</Chat>,
);

it.each([['1'], ['2']])(
Expand Down Expand Up @@ -1193,19 +1209,20 @@ describe('ChannelList', () => {
it('should unset activeChannel if it was deleted', async () => {
const setActiveChannel = jest.fn();
const { container, getByRole } = render(
<ChatContext.Provider
value={{
channelsQueryState: channelsQueryStateMock,
client: chatClient,
setActiveChannel,
}}
>
<ChannelList
{...channelListProps}
channel={{ cid: testChannel1.channel.cid }}
setActiveChannel={setActiveChannel}
/>
</ChatContext.Provider>,
<Chat client={chatClient}>
<ChatContextOverrider
chatContext={{
channelsQueryState: channelsQueryStateMock,
setActiveChannel,
}}
>
<ChannelList
{...channelListProps}
channel={{ cid: testChannel1.channel.cid }}
setActiveChannel={setActiveChannel}
/>
</ChatContextOverrider>
</Chat>,
);

// Wait for list of channels to load in DOM.
Expand Down Expand Up @@ -1257,32 +1274,21 @@ describe('ChannelList', () => {
});

it('should unset activeChannel if it was hidden', async () => {
const setActiveChannel = jest.fn();
const { container, getByRole } = render(
<ChatContext.Provider
value={{
channelsQueryState: channelsQueryStateMock,
client: chatClient,
setActiveChannel,
}}
>
<ChannelList
{...channelListProps}
channel={{ cid: testChannel1.channel.cid }}
setActiveChannel={setActiveChannel}
/>
</ChatContext.Provider>,
<Chat client={chatClient}>
<ChannelList {...channelListProps} />
</Chat>,
);

// Wait for list of channels to load in DOM.
await waitFor(() => {
expect(screen.getByTestId(testChannel1.channel.id)).toBeInTheDocument();
expect(getByRole('list')).toBeInTheDocument();
});

act(() => dispatchChannelHiddenEvent(chatClient, testChannel1.channel));

await waitFor(() => {
expect(setActiveChannel).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId(testChannel1.channel.id)).not.toBeInTheDocument();
});
const results = await axe(container);
expect(results).toHaveNoViolations();
Expand Down
7 changes: 5 additions & 2 deletions src/components/ChannelList/hooks/usePaginatedChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ export const usePaginatedChannels = <
recoveryThrottleIntervalMs: number = RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS,
) => {
const {
channels,
channelsQueryState: { error, setError, setQueryInProgress },
} = useChatContext('usePaginatedChannels');
const [channels, setChannels] = useState<Array<Channel<StreamChatGenerics>>>([]);
setChannels,
} = useChatContext<StreamChatGenerics>('usePaginatedChannels');

const [hasNextPage, setHasNextPage] = useState(true);
const lastRecoveryTimestamp = useRef<number | undefined>();

Expand Down Expand Up @@ -115,6 +117,7 @@ export const usePaginatedChannels = <
queryChannels('reload');
}, [filterString, sortString]);

// FIXME: state refactor (breaking change) is needed - do not forward `channels` and `setChannel`
return {
channels,
hasNextPage,
Expand Down
Loading

0 comments on commit 7e5543b

Please sign in to comment.