diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 79aadb587..127fa4aaf 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1278,6 +1278,7 @@ v14's `` rendered `` 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 diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 8b6200b6c..8f4a19ee7 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -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'; @@ -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) => ( +
{displayTitle}
+); + +/** + * 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; @@ -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; /** Overrides the channel image. */ image?: string; /** Overrides the channel title. */ @@ -54,6 +82,8 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { EndContent = WorkspaceNavigationCloseButton, image: overrideImage, StartContent = WorkspaceNavigationBackButton, + Subtitle = ChannelHeaderSubtitle, + Title = ChannelHeaderTitle, title: overrideTitle, } = props; @@ -70,8 +100,8 @@ export const ChannelHeader = (props: ChannelHeaderProps) => {
-
{displayTitle}
- + + <Subtitle /> </div> <div className='str-chat__channel-header__end'> <Avatar diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx index c36c1ece7..f9f8ab6ae 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx @@ -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'; @@ -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'; @@ -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(); + }); + }); }); diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts index 65c3ef9b5..9ac02208d 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -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(); diff --git a/src/components/ChannelHeader/index.ts b/src/components/ChannelHeader/index.ts index a8a155add..4456a1263 100644 --- a/src/components/ChannelHeader/index.ts +++ b/src/components/ChannelHeader/index.ts @@ -1 +1,2 @@ export * from './ChannelHeader'; +export * from './hooks/useChannelHeaderOnlineStatus';