Skip to content

Commit 39831cc

Browse files
committed
feat: allow to send thread reply to channel
1 parent 759bf44 commit 39831cc

26 files changed

+477
-14
lines changed

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,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
useMessageContext,
8+
useTranslationContext,
9+
} from '../../context';
10+
11+
export const MessageIsThreadReplyInChannelButtonIndicator = () => {
12+
const { t } = useTranslationContext();
13+
const { channel } = useChannelStateContext();
14+
const { openThread } = useChannelActionContext();
15+
const { message } = useMessageContext();
16+
const parentMessageRef = useRef<LocalMessage | null | undefined>(undefined);
17+
18+
const querySearchParent = () =>
19+
channel
20+
.getClient()
21+
.search({ cid: channel.cid }, { id: message.parent_id })
22+
.then(({ results }) => {
23+
if (!results.length) return;
24+
parentMessageRef.current = formatMessage(results[0].message);
25+
});
26+
27+
useEffect(() => {
28+
if (
29+
parentMessageRef.current ||
30+
parentMessageRef.current === null ||
31+
!message.parent_id
32+
)
33+
return;
34+
const localMessage = channel.state.findMessage(message.parent_id);
35+
if (localMessage) {
36+
parentMessageRef.current = localMessage;
37+
return;
38+
}
39+
}, [channel, message]);
40+
41+
if (!message.parent_id) return null;
42+
43+
return (
44+
<div className='str-chat__message-is-thread-reply-button-wrapper'>
45+
<button
46+
className='str-chat__message-is-thread-reply-button'
47+
data-testid='message-is-thread-reply-button'
48+
onClick={async () => {
49+
if (!parentMessageRef.current) {
50+
// search query is performed here in order to prevent multiple search queries in useEffect
51+
// due to the message list 3x remounting its items
52+
await querySearchParent();
53+
if (parentMessageRef.current) {
54+
openThread(parentMessageRef.current);
55+
} else {
56+
// prevent further search queries if the message is not found in the DB
57+
parentMessageRef.current = null;
58+
}
59+
return;
60+
}
61+
openThread(parentMessageRef.current);
62+
}}
63+
>
64+
{t<string>('Thread reply')}
65+
</button>
66+
</div>
67+
);
68+
};

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 { MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator } from './MessageIsThreadReplyInChannelButtonIndicator';
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 />

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+
};

src/components/MessageInput/__tests__/EditMessageForm.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,4 +1470,13 @@ describe(`EditMessageForm`, () => {
14701470
jest.useRealTimers();
14711471
});
14721472
});
1473+
1474+
it('should not render the SendToChannelCheckbox content', async () => {
1475+
const { customChannel, customClient } = await setup();
1476+
await renderComponent({
1477+
customChannel,
1478+
customClient,
1479+
});
1480+
expect(screen.queryByTestId('send-to-channel-checkbox')).not.toBeInTheDocument();
1481+
});
14731482
});

src/components/MessageInput/__tests__/MessageInput.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,4 +1343,15 @@ describe(`MessageInputFlat`, () => {
13431343
jest.useRealTimers();
13441344
});
13451345
});
1346+
1347+
describe('SendToChannelCheckbox', () => {
1348+
it('does not render in the channel context', async () => {
1349+
const { customChannel, customClient } = await setup();
1350+
await renderComponent({
1351+
customChannel,
1352+
customClient,
1353+
});
1354+
expect(screen.queryByTestId('send-to-channel-checkbox')).not.toBeInTheDocument();
1355+
});
1356+
});
13461357
});

0 commit comments

Comments
 (0)