Skip to content
Open
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
1 change: 1 addition & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,7 @@ v14's `<ThreadProvider thread={t}>` rendered `<Channel channel={t.channel}>` aro
- The v14 thread close button is now `ThreadHeader`'s `EndContent` default. It still renders as `str-chat__close-thread-button`, only for threads the navigation reports as dismissable. `ChannelHeader` gains the same pair (`str-chat__close-channel-button`, `str-chat__channel-header__back-button`).
- The buttons act on the panel they render in. A layout that renders its own panels declares them with `WorkspacePanelProvider`. Without a workspace navigation (outside `ChatView`, with no provider), nothing can be dismissed, so they render nothing.
- Passing a component replaces the default button. Render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` inside yours to keep it.
- `ChannelHeader` also takes `Title`, which replaces only the title and receives `{ channel, displayTitle }` (`ChannelHeaderTitleProps`; the default is `ChannelHeaderTitle`). Render the title as a button there instead of rebuilding the header. `Subtitle` likewise replaces only the subtitle (default `ChannelHeaderSubtitle`). A header you build yourself keeps the default subtitle by rendering `ChannelHeaderSubtitle` (Online/Offline for a direct message, member and online counts otherwise, the typing indicator while someone types), or reads the status text with `useChannelHeaderOnlineStatus()`.
- `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader`, but `ChannelListHeader` shows it only while a channel is open in the workspace.

```tsx
Expand Down
36 changes: 33 additions & 3 deletions src/components/ChannelHeader/ChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type { Channel } from 'stream-chat';

import { type ChannelAvatarProps, ChannelAvatar as DefaultAvatar } from '../Avatar';
import { TypingIndicatorHeader } from '../TypingIndicator/TypingIndicatorHeader';
Expand All @@ -8,7 +9,24 @@ import { useChannelPreviewInfo } from '../ChannelListItem/hooks/useChannelPrevie
import { useChannel } from '../../context';
import { WorkspaceNavigationBackButton, WorkspaceNavigationCloseButton } from '../Button';

const ChannelHeaderSubtitle = () => {
export type ChannelHeaderTitleProps = {
/** The channel the header belongs to. */
channel: Channel;
/** The title to show: the `title` passed to `ChannelHeader`, else the channel display name. */
displayTitle?: string;
};

/** Default title of `ChannelHeader`: the display title in a `div`. */
export const ChannelHeaderTitle = ({ displayTitle }: ChannelHeaderTitleProps) => (
<div className='str-chat__channel-header__data__title'>{displayTitle}</div>
);

/**
* Default subtitle of `ChannelHeader`, for the channel in context. Shows the typing indicator while
* other users type, otherwise the online status from `useChannelHeaderOnlineStatus`, and
* transitions between the two. Renders nothing when there is neither.
*/
export const ChannelHeaderSubtitle = () => {
const channel = useChannel();
const onlineStatusText = useChannelHeaderOnlineStatus();
const hasTyping = useTypingUsers(channel).length > 0;
Expand Down Expand Up @@ -41,6 +59,16 @@ export type ChannelHeaderProps = {
* passed here replaces it and can render that button to keep it.
*/
StartContent?: React.ComponentType;
/**
* Replaces the subtitle below the title. Defaults to `ChannelHeaderSubtitle`. Read the channel
* with `useChannel()` and the status text with `useChannelHeaderOnlineStatus()`.
*/
Subtitle?: React.ComponentType;
/**
* Replaces the title. Defaults to `ChannelHeaderTitle`. Receives the channel and its display
* title, so it can render the title as a button, for example. The subtitle stays below it.
*/
Title?: React.ComponentType<ChannelHeaderTitleProps>;
/** Overrides the channel image. */
image?: string;
/** Overrides the channel title. */
Expand All @@ -54,6 +82,8 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
EndContent = WorkspaceNavigationCloseButton,
image: overrideImage,
StartContent = WorkspaceNavigationBackButton,
Subtitle = ChannelHeaderSubtitle,
Title = ChannelHeaderTitle,
title: overrideTitle,
} = props;

Expand All @@ -70,8 +100,8 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
<StartContent />
</div>
<div className='str-chat__channel-header__data'>
<div className='str-chat__channel-header__data__title'>{displayTitle}</div>
<ChannelHeaderSubtitle />
<Title channel={channel} displayTitle={displayTitle} />
<Subtitle />
</div>
<div className='str-chat__channel-header__end'>
<Avatar
Expand Down
113 changes: 111 additions & 2 deletions src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';

import { ChannelHeader } from '../ChannelHeader';
import { ChannelHeader, ChannelHeaderSubtitle } from '../ChannelHeader';

import { Channel } from '../../Channel';
import { Chat } from '../../Chat';
Expand Down Expand Up @@ -32,7 +32,7 @@ import type {
UserResponse,
} from 'stream-chat';
import type { ComponentContextValue } from '../../../context';
import type { ChannelHeaderProps } from '../ChannelHeader';
import type { ChannelHeaderProps, ChannelHeaderTitleProps } from '../ChannelHeader';
import type { GenerateChannelOptions } from '../../../mock-builders/generator/channel';

const AVATAR_IMG_TEST_ID = 'avatar-img';
Expand Down Expand Up @@ -585,4 +585,113 @@ describe('ChannelHeader', () => {
});
});
});

describe('Title prop', () => {
it('renders the display title in the default title element', async () => {
const { container } = await renderComponent({
channelData: channelCustom({ name: 'test-channel-1' }),
});

await waitFor(() => {
const titleEl = container.querySelector('.str-chat__channel-header__data__title');
expect(titleEl?.tagName).toBe('DIV');
expect(titleEl).toHaveTextContent('test-channel-1');
});
});

it('renders the Title passed to it in place of the default title', async () => {
const onClick = vi.fn();
const receivedProps: ChannelHeaderTitleProps[] = [];
const Title = (titleProps: ChannelHeaderTitleProps) => {
receivedProps.push(titleProps);
return (
<button
className='str-chat__channel-header__data__title'
onClick={onClick}
type='button'
>
{titleProps.displayTitle}
</button>
);
};

const { container } = await renderComponent({
channelData: { ...channelCustom({ name: 'test-channel-1' }), member_count: 5 },
props: { Title, title: 'Custom Title' },
});

const button = await screen.findByRole('button', { name: 'Custom Title' });
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
expect(receivedProps.at(-1)).toEqual({
channel: expect.objectContaining({ cid: `messaging:${testChannel1.channel.id}` }),
displayTitle: 'Custom Title',
});
expect(
container.querySelectorAll('.str-chat__channel-header__data__title'),
).toHaveLength(1);
// The subtitle stays below the replaced title.
expect(
container.querySelector('.str-chat__channel-header__data__subtitle'),
).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});
});

describe('Subtitle prop', () => {
it('renders the Subtitle passed to it in place of the default subtitle', async () => {
const Subtitle = () => <div data-testid='custom-subtitle'>Custom subtitle</div>;

const { container } = await renderComponent({
channelData: { ...channelCustom({ name: 'test-channel-1' }), member_count: 5 },
props: { Subtitle },
});

expect(await screen.findByTestId('custom-subtitle')).toBeInTheDocument();
expect(
container.querySelector('.str-chat__channel-header__data__subtitle'),
).not.toBeInTheDocument();
expect(screen.getByText('test-channel-1')).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});
});

describe('ChannelHeaderSubtitle', () => {
const renderSubtitle = async (watchers: UserResponse[]) => {
client = await getTestClientWithUser(user1);
testChannel1 = generateChannel({
...defaultChannelState,
channel: { member_count: 2 },
});
/* eslint-disable-next-line react-hooks/rules-of-hooks */
useMockedApis(client, [getOrCreateChannelApi(testChannel1)]);
const channel = client.channel('messaging', testChannel1.channel.id);
await channel.query();
channel.state.watchers = Object.fromEntries(
watchers.map((user) => [user.id, user]),
);

return render(
<Chat client={client}>
<Channel channel={channel}>
<ChannelHeaderSubtitle />
</Channel>
</Chat>,
);
};

it('shows Online for a direct message whose other member is watching', async () => {
const { container } = await renderSubtitle([user2]);

expect(await screen.findByText('Online')).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});

it('shows Offline for a direct message whose other member is not watching', async () => {
const { container } = await renderSubtitle([user1]);

expect(await screen.findByText('Offline')).toBeInTheDocument();
expect(await axe(container)).toHaveNoViolations();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const watcherCountSelector = (nextValue: ChannelWatchState) => ({
});

/**
* Online status text for the channel header: "Online" or "Offline" for a DM, otherwise
* "N members · M online". Returns null when the channel has no members.
* Online status text for the channel in context, as the channel header shows it: "Online" or
* "Offline" for a direct message, otherwise "N members · M online", translated. Returns null when
* the channel has no members. `ChannelHeaderSubtitle` renders it when nobody is typing.
*/
export function useChannelHeaderOnlineStatus(): string | null {
const { t } = useTranslationContext();
Expand Down
1 change: 1 addition & 0 deletions src/components/ChannelHeader/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './ChannelHeader';
export * from './hooks/useChannelHeaderOnlineStatus';
Loading