Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'MessageBouncePrompt'
| 'MessageBlocked'
| 'MessageDeleted'
| 'MessageIsThreadReplyInChannelButtonIndicator'
| 'MessageListNotifications'
| 'MessageListMainPanel'
| 'MessageNotification'
Expand All @@ -141,6 +142,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'ReactionsList'
| 'ReactionsListModal'
| 'SendButton'
| 'SendToChannelCheckbox'
| 'StartRecordingAudioButton'
| 'TextareaComposer'
| 'ThreadHead'
Expand Down Expand Up @@ -1206,6 +1208,8 @@ const ChannelInner = (
MessageBlocked: props.MessageBlocked,
MessageBouncePrompt: props.MessageBouncePrompt,
MessageDeleted: props.MessageDeleted,
MessageIsThreadReplyInChannelButtonIndicator:
props.MessageIsThreadReplyInChannelButtonIndicator,
MessageListNotifications: props.MessageListNotifications,
MessageNotification: props.MessageNotification,
MessageOptions: props.MessageOptions,
Expand All @@ -1228,6 +1232,7 @@ const ChannelInner = (
ReactionsList: props.ReactionsList,
ReactionsListModal: props.ReactionsListModal,
SendButton: props.SendButton,
SendToChannelCheckbox: props.SendToChannelCheckbox,
StartRecordingAudioButton: props.StartRecordingAudioButton,
StopAIGenerationButton: props.StopAIGenerationButton,
StreamedMessageText: props.StreamedMessageText,
Expand Down Expand Up @@ -1269,6 +1274,7 @@ const ChannelInner = (
props.MessageBlocked,
props.MessageBouncePrompt,
props.MessageDeleted,
props.MessageIsThreadReplyInChannelButtonIndicator,
props.MessageListNotifications,
props.MessageNotification,
props.MessageOptions,
Expand All @@ -1291,6 +1297,7 @@ const ChannelInner = (
props.ReactionsList,
props.ReactionsListModal,
props.SendButton,
props.SendToChannelCheckbox,
props.StartRecordingAudioButton,
props.StopAIGenerationButton,
props.StreamedMessageText,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useEffect, useRef } from 'react';
import type { LocalMessage } from 'stream-chat';
import { formatMessage } from 'stream-chat';
import {
useChannelActionContext,
useChannelStateContext,
useMessageContext,
useTranslationContext,
} from '../../context';

export const MessageIsThreadReplyInChannelButtonIndicator = () => {
const { t } = useTranslationContext();
const { channel } = useChannelStateContext();
const { openThread } = useChannelActionContext();
const { message } = useMessageContext();
const parentMessageRef = useRef<LocalMessage | null | undefined>(undefined);

const querySearchParent = () =>
channel
.getClient()
.search({ cid: channel.cid }, { id: message.parent_id })
.then(({ results }) => {
if (!results.length) return;
parentMessageRef.current = formatMessage(results[0].message);
});

useEffect(() => {
if (
parentMessageRef.current ||
parentMessageRef.current === null ||
!message.parent_id
)
return;
const localMessage = channel.state.findMessage(message.parent_id);
if (localMessage) {
parentMessageRef.current = localMessage;
return;
}
}, [channel, message]);

if (!message.parent_id) return null;

return (
<div className='str-chat__message-is-thread-reply-button-wrapper'>
<button
className='str-chat__message-is-thread-reply-button'
data-testid='message-is-thread-reply-button'
onClick={async () => {
if (!parentMessageRef.current) {
// search query is performed here in order to prevent multiple search queries in useEffect
// due to the message list 3x remounting its items
await querySearchParent();
if (parentMessageRef.current) {
openThread(parentMessageRef.current);
} else {
// prevent further search queries if the message is not found in the DB
parentMessageRef.current = null;
}
return;
}
openThread(parentMessageRef.current);
}}
>
{t<string>('Thread reply')}
</button>
Comment on lines +45 to +
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To prevent surprises with forms, can you please add a type="button prop to the <button /> element?

Also, please add error handling around await querySearchParent(), as in case it fails, the failure will be reported as "Unhandled Promise Rejection" in customer apps, and there is nothing they can do about it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, didn't we get rid of the generics of t<string>()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • generics - yes, in another PR that is still to be merged - was waiting for approval of client changes.
  • type='button' added
  • added error handling to thread search request

</div>
);
};
14 changes: 9 additions & 5 deletions src/components/Message/MessageSimple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from '.
import { MessageStatus as DefaultMessageStatus } from './MessageStatus';
import { MessageText } from './MessageText';
import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp';
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
import { isDateSeparatorMessage } from '../MessageList';
import { MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageIsThreadReplyInChannelButtonIndicator';
import {
areMessageUIPropsEqual,
isMessageBlocked,
Expand All @@ -35,9 +38,6 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp';

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

import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
import { isDateSeparatorMessage } from '../MessageList';

type MessageSimpleWithContextProps = MessageContextValue;

const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
Expand Down Expand Up @@ -72,8 +72,9 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
// major release and use the new default instead
MessageActions = MessageOptions,
MessageBlocked = DefaultMessageBlocked,
MessageDeleted = DefaultMessageDeleted,
MessageBouncePrompt = DefaultMessageBouncePrompt,
MessageDeleted = DefaultMessageDeleted,
MessageIsThreadReplyInChannelButtonIndicator = DefaultMessageIsThreadReplyInChannelButtonIndicator,
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
Expand Down Expand Up @@ -102,6 +103,8 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {

const showMetadata = !groupedByUser || endOfGroup;
const showReplyCountButton = !threadList && !!message.reply_count;
const showIsReplyInChannel =
!threadList && message.show_in_channel && message.parent_id;
const allowRetry = message.status === 'failed' && message.error?.status !== 403;
const isBounced = isMessageBounced(message);
const isEdited = isMessageEdited(message) && !isAIGenerated;
Expand Down Expand Up @@ -131,7 +134,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
'str-chat__message--with-reactions': hasReactions,
'str-chat__message-send-can-be-retried':
message?.status === 'failed' && message?.error?.status !== 403,
'str-chat__message-with-thread-link': showReplyCountButton,
'str-chat__message-with-thread-link': showReplyCountButton || showIsReplyInChannel,
'str-chat__virtual-message__wrapper--end': endOfGroup,
'str-chat__virtual-message__wrapper--first': firstOfGroup,
'str-chat__virtual-message__wrapper--group': groupedByUser,
Expand Down Expand Up @@ -205,6 +208,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
reply_count={message.reply_count}
/>
)}
{showIsReplyInChannel && <MessageIsThreadReplyInChannelButtonIndicator />}
{showMetadata && (
<div className='str-chat__message-metadata'>
<MessageStatus />
Expand Down
80 changes: 80 additions & 0 deletions src/components/Message/__tests__/MessageSimple.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,23 @@ describe('<MessageSimple />', () => {
expect(results).toHaveNoViolations();
});

it('should render message with custom message-is-reply indicator', async () => {
const message = generateAliceMessage({ parent_id: 'x', show_in_channel: true });
const CustomMessageIsThreadReplyInChannelButtonIndicator = () => (
<div data-testid='custom-message-is-reply'>Is Reply</div>
);
const { container, getByTestId } = await renderMessageSimple({
components: {
MessageIsThreadReplyInChannelButtonIndicator:
CustomMessageIsThreadReplyInChannelButtonIndicator,
},
message,
});
expect(getByTestId('custom-message-is-reply')).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should render message with custom options component when one is given', async () => {
const message = generateAliceMessage({ text: '' });
const CustomOptions = () => <div data-testid='custom-message-options'>Options</div>;
Expand Down Expand Up @@ -613,6 +630,69 @@ describe('<MessageSimple />', () => {
expect(results).toHaveNoViolations();
});

it('should display is-message-reply button', async () => {
const message = generateAliceMessage({
parent_id: 'x',
show_in_channel: true,
});
const { container, getByTestId } = await renderMessageSimple({ message });
expect(getByTestId('message-is-thread-reply-button')).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should open thread when is-message-reply button is clicked', async () => {
const parentMessage = generateMessage({ id: 'x' });
const message = generateAliceMessage({
parent_id: parentMessage.id,
show_in_channel: true,
});
channel.state.messageSets[0].messages.unshift(parentMessage);
const { container, getByTestId } = await renderMessageSimple({
message,
});
expect(openThreadMock).not.toHaveBeenCalled();
fireEvent.click(getByTestId('message-is-thread-reply-button'));
expect(openThreadMock).toHaveBeenCalledWith(expect.any(Object));
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should not open thread when is-message-reply button is clicked and parent message is not found', async () => {
const parentMessage = generateMessage({ id: 'x' });
const message = generateAliceMessage({
parent_id: parentMessage.id,
show_in_channel: true,
});
const { container, getByTestId } = await renderMessageSimple({
message,
});
expect(openThreadMock).not.toHaveBeenCalled();
fireEvent.click(getByTestId('message-is-thread-reply-button'));
expect(openThreadMock).not.toHaveBeenCalled();
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should query the parent if not found in local state', async () => {
const parentMessage = generateMessage({ id: 'x' });
const message = generateAliceMessage({
parent_id: parentMessage.id,
show_in_channel: true,
});
const searchSpy = jest.spyOn(client, 'search');
const { container, getByTestId } = await renderMessageSimple({
message,
});
fireEvent.click(getByTestId('message-is-thread-reply-button'));
expect(searchSpy).toHaveBeenCalledWith(
{ cid: channel.cid },
{ id: parentMessage.id },
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should open thread when reply count button is clicked', async () => {
const message = generateAliceMessage({
reply_count: 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ describe('<MessageActions /> component', () => {
expect(MessageActionsBoxMock).not.toHaveBeenCalled();
const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
expect(dialogOverlay.children).toHaveLength(0);
await toggleOpenMessageActions();
await act(async () => {
await toggleOpenMessageActions();
});
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
undefined,
Expand Down
3 changes: 3 additions & 0 deletions src/components/MessageInput/MessageInputFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
QuotedMessagePreviewHeader,
} from './QuotedMessagePreview';
import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList';
import { SendToChannelCheckbox as DefaultSendToChannelCheckbox } from './SendToChannelCheckbox';
import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer';
import { AIStates, useAIState } from '../AIStateIndicator';
import { RecordingAttachmentType } from '../MediaRecorder/classes';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const MessageInputFlat = () => {
QuotedMessagePreview = DefaultQuotedMessagePreview,
RecordingPermissionDeniedNotification = DefaultRecordingPermissionDeniedNotification,
SendButton = DefaultSendButton,
SendToChannelCheckbox = DefaultSendToChannelCheckbox,
StartRecordingAudioButton = DefaultStartRecordingAudioButton,
StopAIGenerationButton: StopAIGenerationButtonOverride,
TextareaComposer = DefaultTextareaComposer,
Expand Down Expand Up @@ -146,6 +148,7 @@ export const MessageInputFlat = () => {
)
)}
</div>
<SendToChannelCheckbox />
</WithDragAndDropUpload>
);
};
35 changes: 35 additions & 0 deletions src/components/MessageInput/SendToChannelCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMessageComposer } from './hooks';
import React from 'react';
import type { MessageComposerState } from 'stream-chat';
import { useStateStore } from '../../store';
import { useTranslationContext } from '../../context';

const stateSelector = (state: MessageComposerState) => ({
showReplyInChannel: state.showReplyInChannel,

Check failure on line 8 in src/components/MessageInput/SendToChannelCheckbox.tsx

View workflow job for this annotation

GitHub Actions / TypeScript

Property 'showReplyInChannel' does not exist on type 'MessageComposerState'.
});

export const SendToChannelCheckbox = () => {
const { t } = useTranslationContext();
const messageComposer = useMessageComposer();
const { showReplyInChannel } = useStateStore(messageComposer.state, stateSelector);

if (messageComposer.editedMessage || !messageComposer.threadId) return null;

return (
<div className='str-chat__send-to-channel-checkbox__container'>
<div className='str-chat__send-to-channel-checkbox__field'>
<input
id='send-to-channel-checkbox'
onClick={messageComposer.toggleShowReplyInChannel}

Check failure on line 23 in src/components/MessageInput/SendToChannelCheckbox.tsx

View workflow job for this annotation

GitHub Actions / TypeScript

Property 'toggleShowReplyInChannel' does not exist on type 'MessageComposer'.
type='checkbox'
value={showReplyInChannel.toString()}
/>
<label htmlFor='send-to-channel-checkbox'>
{Object.keys(messageComposer.channel.state.members).length === 2
? t<string>('Also send as a direct message')
: t<string>('Also send in channel')}
</label>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -1470,4 +1470,13 @@ describe(`EditMessageForm`, () => {
jest.useRealTimers();
});
});

it('should not render the SendToChannelCheckbox content', async () => {
const { customChannel, customClient } = await setup();
await renderComponent({
customChannel,
customClient,
});
expect(screen.queryByTestId('send-to-channel-checkbox')).not.toBeInTheDocument();
});
});
11 changes: 11 additions & 0 deletions src/components/MessageInput/__tests__/MessageInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1343,4 +1343,15 @@ describe(`MessageInputFlat`, () => {
jest.useRealTimers();
});
});

describe('SendToChannelCheckbox', () => {
it('does not render in the channel context', async () => {
const { customChannel, customClient } = await setup();
await renderComponent({
customChannel,
customClient,
});
expect(screen.queryByTestId('send-to-channel-checkbox')).not.toBeInTheDocument();
});
});
});
Loading
Loading