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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"emoji-mart": "^5.4.0",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
"stream-chat": "^9.0.0"
"stream-chat": "^9.6.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -184,7 +184,7 @@
"@playwright/test": "^1.42.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@stream-io/stream-chat-css": "^5.9.3",
"@stream-io/stream-chat-css": "^5.11.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
Expand Down Expand Up @@ -237,7 +237,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"semantic-release": "^24.2.3",
"stream-chat": "9.1.1",
"stream-chat": "^9.6.0",
"ts-jest": "^29.2.5",
"typescript": "^5.4.5",
"typescript-eslint": "^8.17.0"
Expand Down
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
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 { MessageThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageThreadReplyInChannelButtonIndicator';
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React, { useEffect, useRef } from 'react';
import type { LocalMessage } from 'stream-chat';
import { formatMessage } from 'stream-chat';
import {
useChannelActionContext,
useChannelStateContext,
useChatContext,
useMessageContext,
useTranslationContext,
} from '../../context';

export const MessageThreadReplyInChannelButtonIndicator = () => {
const { client } = useChatContext();
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) {
throw new Error('Thread has not been found');
}
parentMessageRef.current = formatMessage(results[0].message);
})
.catch((error: Error) => {
client.notifications.addError({
message: t<string>('Thread has not been found'),
options: {
originalError: error,
type: 'api:message:search:not-found',
},
origin: {
context: { threadReply: message },
emitter: 'MessageThreadReplyInChannelButtonIndicator',
},
});
});

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);
}}
type='button'
>
{t<string>('Thread reply')}
</button>
</div>
);
};
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,
});

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}
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>
);
};
Loading