Skip to content

Commit 7fbc478

Browse files
authored
feat: allow to send thread reply to channel (#2733)
1 parent 759bf44 commit 7fbc478

29 files changed

+523
-26
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
"emoji-mart": "^5.4.0",
144144
"react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
145145
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0",
146-
"stream-chat": "^9.0.0"
146+
"stream-chat": "^9.6.0"
147147
},
148148
"peerDependenciesMeta": {
149149
"@breezystack/lamejs": {
@@ -184,7 +184,7 @@
184184
"@playwright/test": "^1.42.1",
185185
"@semantic-release/changelog": "^6.0.3",
186186
"@semantic-release/git": "^10.0.1",
187-
"@stream-io/stream-chat-css": "^5.9.3",
187+
"@stream-io/stream-chat-css": "^5.11.0",
188188
"@testing-library/dom": "^10.4.0",
189189
"@testing-library/jest-dom": "^6.6.3",
190190
"@testing-library/react": "^16.2.0",
@@ -237,7 +237,7 @@
237237
"react": "^19.0.0",
238238
"react-dom": "^19.0.0",
239239
"semantic-release": "^24.2.3",
240-
"stream-chat": "9.1.1",
240+
"stream-chat": "^9.6.0",
241241
"ts-jest": "^29.2.5",
242242
"typescript": "^5.4.5",
243243
"typescript-eslint": "^8.17.0"

src/components/Channel/Channel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
118118
| 'MessageBouncePrompt'
119119
| 'MessageBlocked'
120120
| 'MessageDeleted'
121+
| 'MessageIsThreadReplyInChannelButtonIndicator'
121122
| 'MessageListNotifications'
122123
| 'MessageListMainPanel'
123124
| 'MessageNotification'
@@ -141,6 +142,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
141142
| 'ReactionsList'
142143
| 'ReactionsListModal'
143144
| 'SendButton'
145+
| 'SendToChannelCheckbox'
144146
| 'StartRecordingAudioButton'
145147
| 'TextareaComposer'
146148
| 'ThreadHead'
@@ -1206,6 +1208,8 @@ const ChannelInner = (
12061208
MessageBlocked: props.MessageBlocked,
12071209
MessageBouncePrompt: props.MessageBouncePrompt,
12081210
MessageDeleted: props.MessageDeleted,
1211+
MessageIsThreadReplyInChannelButtonIndicator:
1212+
props.MessageIsThreadReplyInChannelButtonIndicator,
12091213
MessageListNotifications: props.MessageListNotifications,
12101214
MessageNotification: props.MessageNotification,
12111215
MessageOptions: props.MessageOptions,
@@ -1228,6 +1232,7 @@ const ChannelInner = (
12281232
ReactionsList: props.ReactionsList,
12291233
ReactionsListModal: props.ReactionsListModal,
12301234
SendButton: props.SendButton,
1235+
SendToChannelCheckbox: props.SendToChannelCheckbox,
12311236
StartRecordingAudioButton: props.StartRecordingAudioButton,
12321237
StopAIGenerationButton: props.StopAIGenerationButton,
12331238
StreamedMessageText: props.StreamedMessageText,
@@ -1269,6 +1274,7 @@ const ChannelInner = (
12691274
props.MessageBlocked,
12701275
props.MessageBouncePrompt,
12711276
props.MessageDeleted,
1277+
props.MessageIsThreadReplyInChannelButtonIndicator,
12721278
props.MessageListNotifications,
12731279
props.MessageNotification,
12741280
props.MessageOptions,
@@ -1291,6 +1297,7 @@ const ChannelInner = (
12911297
props.ReactionsList,
12921298
props.ReactionsListModal,
12931299
props.SendButton,
1300+
props.SendToChannelCheckbox,
12941301
props.StartRecordingAudioButton,
12951302
props.StopAIGenerationButton,
12961303
props.StreamedMessageText,

src/components/Message/MessageSimple.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from '.
1010
import { MessageStatus as DefaultMessageStatus } from './MessageStatus';
1111
import { MessageText } from './MessageText';
1212
import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp';
13+
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
14+
import { isDateSeparatorMessage } from '../MessageList';
15+
import { MessageThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageThreadReplyInChannelButtonIndicator';
1316
import {
1417
areMessageUIPropsEqual,
1518
isMessageBlocked,
@@ -35,9 +38,6 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp';
3538

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

38-
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
39-
import { isDateSeparatorMessage } from '../MessageList';
40-
4141
type MessageSimpleWithContextProps = MessageContextValue;
4242

4343
const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
@@ -72,8 +72,9 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
7272
// major release and use the new default instead
7373
MessageActions = MessageOptions,
7474
MessageBlocked = DefaultMessageBlocked,
75-
MessageDeleted = DefaultMessageDeleted,
7675
MessageBouncePrompt = DefaultMessageBouncePrompt,
76+
MessageDeleted = DefaultMessageDeleted,
77+
MessageIsThreadReplyInChannelButtonIndicator = DefaultMessageIsThreadReplyInChannelButtonIndicator,
7778
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
7879
MessageStatus = DefaultMessageStatus,
7980
MessageTimestamp = DefaultMessageTimestamp,
@@ -102,6 +103,8 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
102103

103104
const showMetadata = !groupedByUser || endOfGroup;
104105
const showReplyCountButton = !threadList && !!message.reply_count;
106+
const showIsReplyInChannel =
107+
!threadList && message.show_in_channel && message.parent_id;
105108
const allowRetry = message.status === 'failed' && message.error?.status !== 403;
106109
const isBounced = isMessageBounced(message);
107110
const isEdited = isMessageEdited(message) && !isAIGenerated;
@@ -131,7 +134,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
131134
'str-chat__message--with-reactions': hasReactions,
132135
'str-chat__message-send-can-be-retried':
133136
message?.status === 'failed' && message?.error?.status !== 403,
134-
'str-chat__message-with-thread-link': showReplyCountButton,
137+
'str-chat__message-with-thread-link': showReplyCountButton || showIsReplyInChannel,
135138
'str-chat__virtual-message__wrapper--end': endOfGroup,
136139
'str-chat__virtual-message__wrapper--first': firstOfGroup,
137140
'str-chat__virtual-message__wrapper--group': groupedByUser,
@@ -205,6 +208,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
205208
reply_count={message.reply_count}
206209
/>
207210
)}
211+
{showIsReplyInChannel && <MessageIsThreadReplyInChannelButtonIndicator />}
208212
{showMetadata && (
209213
<div className='str-chat__message-metadata'>
210214
<MessageStatus />
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import type { LocalMessage } from 'stream-chat';
3+
import { formatMessage } from 'stream-chat';
4+
import {
5+
useChannelActionContext,
6+
useChannelStateContext,
7+
useChatContext,
8+
useMessageContext,
9+
useTranslationContext,
10+
} from '../../context';
11+
12+
export const MessageThreadReplyInChannelButtonIndicator = () => {
13+
const { client } = useChatContext();
14+
const { t } = useTranslationContext();
15+
const { channel } = useChannelStateContext();
16+
const { openThread } = useChannelActionContext();
17+
const { message } = useMessageContext();
18+
const parentMessageRef = useRef<LocalMessage | null | undefined>(undefined);
19+
20+
const querySearchParent = () =>
21+
channel
22+
.getClient()
23+
.search({ cid: channel.cid }, { id: message.parent_id })
24+
.then(({ results }) => {
25+
if (!results.length) {
26+
throw new Error('Thread has not been found');
27+
}
28+
parentMessageRef.current = formatMessage(results[0].message);
29+
})
30+
.catch((error: Error) => {
31+
client.notifications.addError({
32+
message: t<string>('Thread has not been found'),
33+
options: {
34+
originalError: error,
35+
type: 'api:message:search:not-found',
36+
},
37+
origin: {
38+
context: { threadReply: message },
39+
emitter: 'MessageThreadReplyInChannelButtonIndicator',
40+
},
41+
});
42+
});
43+
44+
useEffect(() => {
45+
if (
46+
parentMessageRef.current ||
47+
parentMessageRef.current === null ||
48+
!message.parent_id
49+
)
50+
return;
51+
const localMessage = channel.state.findMessage(message.parent_id);
52+
if (localMessage) {
53+
parentMessageRef.current = localMessage;
54+
return;
55+
}
56+
}, [channel, message]);
57+
58+
if (!message.parent_id) return null;
59+
60+
return (
61+
<div className='str-chat__message-is-thread-reply-button-wrapper'>
62+
<button
63+
className='str-chat__message-is-thread-reply-button'
64+
data-testid='message-is-thread-reply-button'
65+
onClick={async () => {
66+
if (!parentMessageRef.current) {
67+
// search query is performed here in order to prevent multiple search queries in useEffect
68+
// due to the message list 3x remounting its items
69+
await querySearchParent();
70+
if (parentMessageRef.current) {
71+
openThread(parentMessageRef.current);
72+
} else {
73+
// prevent further search queries if the message is not found in the DB
74+
parentMessageRef.current = null;
75+
}
76+
return;
77+
}
78+
openThread(parentMessageRef.current);
79+
}}
80+
type='button'
81+
>
82+
{t<string>('Thread reply')}
83+
</button>
84+
</div>
85+
);
86+
};

src/components/Message/__tests__/MessageSimple.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ describe('<MessageSimple />', () => {
216216
expect(results).toHaveNoViolations();
217217
});
218218

219+
it('should render message with custom message-is-reply indicator', async () => {
220+
const message = generateAliceMessage({ parent_id: 'x', show_in_channel: true });
221+
const CustomMessageIsThreadReplyInChannelButtonIndicator = () => (
222+
<div data-testid='custom-message-is-reply'>Is Reply</div>
223+
);
224+
const { container, getByTestId } = await renderMessageSimple({
225+
components: {
226+
MessageIsThreadReplyInChannelButtonIndicator:
227+
CustomMessageIsThreadReplyInChannelButtonIndicator,
228+
},
229+
message,
230+
});
231+
expect(getByTestId('custom-message-is-reply')).toBeInTheDocument();
232+
const results = await axe(container);
233+
expect(results).toHaveNoViolations();
234+
});
235+
219236
it('should render message with custom options component when one is given', async () => {
220237
const message = generateAliceMessage({ text: '' });
221238
const CustomOptions = () => <div data-testid='custom-message-options'>Options</div>;
@@ -613,6 +630,69 @@ describe('<MessageSimple />', () => {
613630
expect(results).toHaveNoViolations();
614631
});
615632

633+
it('should display is-message-reply button', async () => {
634+
const message = generateAliceMessage({
635+
parent_id: 'x',
636+
show_in_channel: true,
637+
});
638+
const { container, getByTestId } = await renderMessageSimple({ message });
639+
expect(getByTestId('message-is-thread-reply-button')).toBeInTheDocument();
640+
const results = await axe(container);
641+
expect(results).toHaveNoViolations();
642+
});
643+
644+
it('should open thread when is-message-reply button is clicked', async () => {
645+
const parentMessage = generateMessage({ id: 'x' });
646+
const message = generateAliceMessage({
647+
parent_id: parentMessage.id,
648+
show_in_channel: true,
649+
});
650+
channel.state.messageSets[0].messages.unshift(parentMessage);
651+
const { container, getByTestId } = await renderMessageSimple({
652+
message,
653+
});
654+
expect(openThreadMock).not.toHaveBeenCalled();
655+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
656+
expect(openThreadMock).toHaveBeenCalledWith(expect.any(Object));
657+
const results = await axe(container);
658+
expect(results).toHaveNoViolations();
659+
});
660+
661+
it('should not open thread when is-message-reply button is clicked and parent message is not found', async () => {
662+
const parentMessage = generateMessage({ id: 'x' });
663+
const message = generateAliceMessage({
664+
parent_id: parentMessage.id,
665+
show_in_channel: true,
666+
});
667+
const { container, getByTestId } = await renderMessageSimple({
668+
message,
669+
});
670+
expect(openThreadMock).not.toHaveBeenCalled();
671+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
672+
expect(openThreadMock).not.toHaveBeenCalled();
673+
const results = await axe(container);
674+
expect(results).toHaveNoViolations();
675+
});
676+
677+
it('should query the parent if not found in local state', async () => {
678+
const parentMessage = generateMessage({ id: 'x' });
679+
const message = generateAliceMessage({
680+
parent_id: parentMessage.id,
681+
show_in_channel: true,
682+
});
683+
const searchSpy = jest.spyOn(client, 'search');
684+
const { container, getByTestId } = await renderMessageSimple({
685+
message,
686+
});
687+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
688+
expect(searchSpy).toHaveBeenCalledWith(
689+
{ cid: channel.cid },
690+
{ id: parentMessage.id },
691+
);
692+
const results = await axe(container);
693+
expect(results).toHaveNoViolations();
694+
});
695+
616696
it('should open thread when reply count button is clicked', async () => {
617697
const message = generateAliceMessage({
618698
reply_count: 1,

src/components/MessageActions/__tests__/MessageActions.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ describe('<MessageActions /> component', () => {
131131
expect(MessageActionsBoxMock).not.toHaveBeenCalled();
132132
const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
133133
expect(dialogOverlay.children).toHaveLength(0);
134-
await toggleOpenMessageActions();
134+
await act(async () => {
135+
await toggleOpenMessageActions();
136+
});
135137
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
136138
expect.objectContaining({ open: true }),
137139
undefined,

src/components/MessageInput/MessageInputFlat.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
QuotedMessagePreviewHeader,
2020
} from './QuotedMessagePreview';
2121
import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList';
22+
import { SendToChannelCheckbox as DefaultSendToChannelCheckbox } from './SendToChannelCheckbox';
2223
import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer';
2324
import { AIStates, useAIState } from '../AIStateIndicator';
2425
import { RecordingAttachmentType } from '../MediaRecorder/classes';
@@ -51,6 +52,7 @@ export const MessageInputFlat = () => {
5152
QuotedMessagePreview = DefaultQuotedMessagePreview,
5253
RecordingPermissionDeniedNotification = DefaultRecordingPermissionDeniedNotification,
5354
SendButton = DefaultSendButton,
55+
SendToChannelCheckbox = DefaultSendToChannelCheckbox,
5456
StartRecordingAudioButton = DefaultStartRecordingAudioButton,
5557
StopAIGenerationButton: StopAIGenerationButtonOverride,
5658
TextareaComposer = DefaultTextareaComposer,
@@ -146,6 +148,7 @@ export const MessageInputFlat = () => {
146148
)
147149
)}
148150
</div>
151+
<SendToChannelCheckbox />
149152
</WithDragAndDropUpload>
150153
);
151154
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useMessageComposer } from './hooks';
2+
import React from 'react';
3+
import type { MessageComposerState } from 'stream-chat';
4+
import { useStateStore } from '../../store';
5+
import { useTranslationContext } from '../../context';
6+
7+
const stateSelector = (state: MessageComposerState) => ({
8+
showReplyInChannel: state.showReplyInChannel,
9+
});
10+
11+
export const SendToChannelCheckbox = () => {
12+
const { t } = useTranslationContext();
13+
const messageComposer = useMessageComposer();
14+
const { showReplyInChannel } = useStateStore(messageComposer.state, stateSelector);
15+
16+
if (messageComposer.editedMessage || !messageComposer.threadId) return null;
17+
18+
return (
19+
<div className='str-chat__send-to-channel-checkbox__container'>
20+
<div className='str-chat__send-to-channel-checkbox__field'>
21+
<input
22+
id='send-to-channel-checkbox'
23+
onClick={messageComposer.toggleShowReplyInChannel}
24+
type='checkbox'
25+
value={showReplyInChannel.toString()}
26+
/>
27+
<label htmlFor='send-to-channel-checkbox'>
28+
{Object.keys(messageComposer.channel.state.members).length === 2
29+
? t<string>('Also send as a direct message')
30+
: t<string>('Also send in channel')}
31+
</label>
32+
</div>
33+
</div>
34+
);
35+
};

0 commit comments

Comments
 (0)