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
22 changes: 17 additions & 5 deletions src/components/Thread/ThreadHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { useTypingContext } from '../../context/TypingContext';
import type { LocalMessage } from 'stream-chat';
import type { ThreadState } from 'stream-chat';
import { Button } from '../Button';
import { IconCrossMedium } from '../Icons';
import { IconCrossMedium, IconLayoutAlignLeft } from '../Icons';
import { ToggleSidebarButton } from '../Button/ToggleSidebarButton';
import { useChatViewContext } from '../ChatView';

const threadStateSelector = ({ replyCount }: ThreadState) => ({ replyCount });

Expand Down Expand Up @@ -40,8 +42,10 @@ const ThreadHeaderSubtitle = ({
({ parent_id, user }) => user?.id !== client.user?.id && parent_id === parentId,
);
const hasTyping = channelConfig?.typing_events !== false && typingInThread.length > 0;
const defaultSubtitle =
threadDisplayName + ' · ' + t('replyCount', { count: replyCount ?? 0 });
const replyCountText = t('replyCount', { count: replyCount ?? 0 });
const defaultSubtitle = threadDisplayName
? `${threadDisplayName} · ${replyCountText}`
: replyCountText;
return (
<div className='str-chat__thread-header-subtitle'>
<span
Expand All @@ -63,15 +67,18 @@ export type ThreadHeaderProps = {
closeThread: (event?: React.BaseSyntheticEvent) => void;
/** The thread parent message */
thread: LocalMessage;
/** UI component to display menu icon, defaults to IconLayoutAlignLeft*/
MenuIcon?: React.ComponentType;
/** Override the thread display title */
overrideTitle?: string;
};

export const ThreadHeader = (props: ThreadHeaderProps) => {
const { closeThread, overrideTitle, thread } = props;
const { closeThread, MenuIcon = IconLayoutAlignLeft, overrideTitle, thread } = props;

const { t } = useTranslationContext();
const { channel } = useChannelStateContext('ThreadHeader');
const { channel } = useChannelStateContext();
const { activeChatView } = useChatViewContext();
const { displayTitle: channelDisplayTitle } = useChannelPreviewInfo({ channel });

const threadInstance = useThreadContext();
Expand All @@ -93,6 +100,11 @@ export const ThreadHeader = (props: ThreadHeaderProps) => {

return (
<div className='str-chat__thread-header'>
{activeChatView === 'threads' && (
<ToggleSidebarButton canCollapse={!!threadInstance} mode='expand'>
<MenuIcon />
</ToggleSidebarButton>
)}
<div className='str-chat__thread-header-details'>
<div className='str-chat__thread-header-title'>{t('Thread')}</div>
<ThreadHeaderSubtitle
Expand Down
102 changes: 85 additions & 17 deletions src/components/Thread/__tests__/ThreadHeader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,47 @@ import React from 'react';
import { ChannelStateProvider } from '../../../context/ChannelStateContext';
import { ChatProvider } from '../../../context/ChatContext';
import { TranslationProvider } from '../../../context/TranslationContext';
import { useChannelDisplayName } from '../../ChannelPreview/hooks/useChannelDisplayName';
import { ThreadHeader } from '../ThreadHeader';

jest.mock('../../ChannelPreview/hooks/useChannelDisplayName', () => ({
useChannelDisplayName: jest.fn(),
jest.mock('../../ChannelPreview/hooks/useChannelPreviewInfo', () => ({
useChannelPreviewInfo: jest.fn(() => ({ displayTitle: undefined })),
}));

jest.mock('../../../store', () => ({
useStateStore: jest.fn(() => undefined),
}));

jest.mock('../../../context/TypingContext', () => ({
useTypingContext: jest.fn(() => ({ typing: {} })),
}));

jest.mock('../../TypingIndicator/TypingIndicatorHeader', () => ({
TypingIndicatorHeader: () => <div>Typing...</div>,
}));

jest.mock('../../Button/ToggleSidebarButton', () => ({
ToggleSidebarButton: ({ children }) => (
<div data-testid='toggle-sidebar-button'>{children}</div>
),
}));

jest.mock('../../Threads', () => ({
useThreadContext: jest.fn(() => undefined),
}));

jest.mock('../../ChatView', () => ({
useChatViewContext: jest.fn(() => ({ activeChatView: 'channels' })),
}));

const {
useChannelPreviewInfo,
} = require('../../ChannelPreview/hooks/useChannelPreviewInfo');
const { useChatViewContext } = require('../../ChatView');
const { useThreadContext } = require('../../Threads');

const alice = { id: 'alice', name: 'Alice' };
const bob = { id: 'bob', name: 'Bob' };

const createThread = (user) => ({
id: `${user?.id ?? 'thread'}-message`,
reply_count: 2,
Expand All @@ -36,20 +64,38 @@ const createChannel = (overrides = {}) => ({
...overrides,
});

const renderComponent = ({ channelOverrides = {}, props = {} } = {}) => {
const client = { off: jest.fn(), on: jest.fn(), userID: alice.id };
const renderComponent = ({
activeChatView = 'channels',
channelOverrides = {},
props = {},
threadContext = undefined,
} = {}) => {
const client = { off: jest.fn(), on: jest.fn(), user: alice, userID: alice.id };
const thread = createThread(alice);
const channel = createChannel(channelOverrides);

useChatViewContext.mockReturnValue({
activeChatView,
setActiveChatView: jest.fn(),
});
useThreadContext.mockReturnValue(threadContext);

return render(
<ChatProvider value={{ client, latestMessageDatesByChannels: {} }}>
<ChannelStateProvider value={{ channel }}>
<ChatProvider
value={{
client,
closeMobileNav: jest.fn(),
latestMessageDatesByChannels: {},
navOpen: false,
openMobileNav: jest.fn(),
}}
>
<ChannelStateProvider value={{ channel, thread }}>
<TranslationProvider
value={{
t: (key, options) => {
if (key === 'Thread') return 'Thread';
if (key === 'replyCount') return `${options.count} replies`;
if (key === 'Direct message') return 'Direct message';
if (key === 'aria/Close thread') return 'Close thread';

return key;
Expand All @@ -69,18 +115,18 @@ describe('ThreadHeader', () => {
jest.clearAllMocks();
});

it('renders the channel display title in the subtitle', async () => {
useChannelDisplayName.mockReturnValue('Bob');
it('renders the channel display title in the subtitle', () => {
useChannelPreviewInfo.mockReturnValue({ displayTitle: 'Bob' });

await renderComponent();
renderComponent();

expect(screen.getByText('Bob · 2 replies')).toBeInTheDocument();
});

it('falls back to the parent message author when the channel has no display title', async () => {
useChannelDisplayName.mockReturnValue(undefined);
it('falls back to the parent message author when the channel has no display title', () => {
useChannelPreviewInfo.mockReturnValue({ displayTitle: undefined });

await renderComponent({
renderComponent({
channelOverrides: {
state: {
members: {
Expand All @@ -96,10 +142,10 @@ describe('ThreadHeader', () => {
expect(screen.getByText('Alice · 2 replies')).toBeInTheDocument();
});

it('renders only the reply count when no title source is available', async () => {
useChannelDisplayName.mockReturnValue(undefined);
it('renders only the reply count when no title source is available', () => {
useChannelPreviewInfo.mockReturnValue({ displayTitle: undefined });

await renderComponent({
renderComponent({
channelOverrides: {
state: {
members: {
Expand All @@ -115,4 +161,26 @@ describe('ThreadHeader', () => {
expect(screen.getByText('2 replies')).toBeInTheDocument();
expect(screen.queryByText(/^undefined ·/)).not.toBeInTheDocument();
});

it('does not render the sidebar toggle in the channels view', () => {
useChannelPreviewInfo.mockReturnValue({ displayTitle: 'Bob' });

renderComponent({
activeChatView: 'channels',
threadContext: { id: 'thread-1' },
});

expect(screen.queryByTestId('toggle-sidebar-button')).not.toBeInTheDocument();
});

it('renders the sidebar toggle in the threads view', () => {
useChannelPreviewInfo.mockReturnValue({ displayTitle: 'Bob' });

renderComponent({
activeChatView: 'threads',
threadContext: { id: 'thread-1' },
});

expect(screen.getByTestId('toggle-sidebar-button')).toBeInTheDocument();
});
});
Loading