Skip to content

Commit 8a5b821

Browse files
committed
Merge branch 'master' into feat/snooze-message-reminders
# Conflicts: # package.json # src/components/Message/MessageSimple.tsx # src/components/MessageInput/__tests__/EditMessageForm.test.js # yarn.lock
2 parents ab5d857 + 7fbc478 commit 8a5b821

29 files changed

Lines changed: 529 additions & 29 deletions

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.5.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.10.0",
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.5.0",
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: 8 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'
@@ -142,6 +143,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
142143
| 'ReactionsListModal'
143144
| 'ReminderNotification'
144145
| 'SendButton'
146+
| 'SendToChannelCheckbox'
145147
| 'StartRecordingAudioButton'
146148
| 'TextareaComposer'
147149
| 'ThreadHead'
@@ -327,6 +329,7 @@ const ChannelInner = (
327329
...initialState,
328330
hasMore: channel.state.messagePagination.hasPrev,
329331
loading: !channel.initialized,
332+
messages: channel.state.messages,
330333
},
331334
);
332335
const jumpToMessageFromSearch = useSearchFocusedMessage();
@@ -1206,6 +1209,8 @@ const ChannelInner = (
12061209
MessageBlocked: props.MessageBlocked,
12071210
MessageBouncePrompt: props.MessageBouncePrompt,
12081211
MessageDeleted: props.MessageDeleted,
1212+
MessageIsThreadReplyInChannelButtonIndicator:
1213+
props.MessageIsThreadReplyInChannelButtonIndicator,
12091214
MessageListNotifications: props.MessageListNotifications,
12101215
MessageNotification: props.MessageNotification,
12111216
MessageOptions: props.MessageOptions,
@@ -1229,6 +1234,7 @@ const ChannelInner = (
12291234
ReactionsListModal: props.ReactionsListModal,
12301235
ReminderNotification: props.ReminderNotification,
12311236
SendButton: props.SendButton,
1237+
SendToChannelCheckbox: props.SendToChannelCheckbox,
12321238
StartRecordingAudioButton: props.StartRecordingAudioButton,
12331239
StopAIGenerationButton: props.StopAIGenerationButton,
12341240
StreamedMessageText: props.StreamedMessageText,
@@ -1270,6 +1276,7 @@ const ChannelInner = (
12701276
props.MessageBlocked,
12711277
props.MessageBouncePrompt,
12721278
props.MessageDeleted,
1279+
props.MessageIsThreadReplyInChannelButtonIndicator,
12731280
props.MessageListNotifications,
12741281
props.MessageNotification,
12751282
props.MessageOptions,
@@ -1293,6 +1300,7 @@ const ChannelInner = (
12931300
props.ReactionsListModal,
12941301
props.ReminderNotification,
12951302
props.SendButton,
1303+
props.SendToChannelCheckbox,
12961304
props.StartRecordingAudioButton,
12971305
props.StopAIGenerationButton,
12981306
props.StreamedMessageText,

src/components/Message/MessageSimple.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ 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';
16+
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
17+
import { useMessageReminder } from './hooks';
1318
import {
1419
areMessageUIPropsEqual,
1520
isMessageBlocked,
@@ -35,11 +40,6 @@ import { MessageEditedTimestamp } from './MessageEditedTimestamp';
3540

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

38-
import { StreamedMessageText as DefaultStreamedMessageText } from './StreamedMessageText';
39-
import { isDateSeparatorMessage } from '../MessageList';
40-
import { ReminderNotification as DefaultReminderNotification } from './ReminderNotification';
41-
import { useMessageReminder } from './hooks';
42-
4343
type MessageSimpleWithContextProps = MessageContextValue;
4444

4545
const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
@@ -75,8 +75,9 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
7575
// major release and use the new default instead
7676
MessageActions = MessageOptions,
7777
MessageBlocked = DefaultMessageBlocked,
78-
MessageDeleted = DefaultMessageDeleted,
7978
MessageBouncePrompt = DefaultMessageBouncePrompt,
79+
MessageDeleted = DefaultMessageDeleted,
80+
MessageIsThreadReplyInChannelButtonIndicator = DefaultMessageIsThreadReplyInChannelButtonIndicator,
8081
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
8182
MessageStatus = DefaultMessageStatus,
8283
MessageTimestamp = DefaultMessageTimestamp,
@@ -106,6 +107,8 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
106107

107108
const showMetadata = !groupedByUser || endOfGroup;
108109
const showReplyCountButton = !threadList && !!message.reply_count;
110+
const showIsReplyInChannel =
111+
!threadList && message.show_in_channel && message.parent_id;
109112
const allowRetry = message.status === 'failed' && message.error?.status !== 403;
110113
const isBounced = isMessageBounced(message);
111114
const isEdited = isMessageEdited(message) && !isAIGenerated;
@@ -135,7 +138,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
135138
'str-chat__message--with-reactions': hasReactions,
136139
'str-chat__message-send-can-be-retried':
137140
message?.status === 'failed' && message?.error?.status !== 403,
138-
'str-chat__message-with-thread-link': showReplyCountButton,
141+
'str-chat__message-with-thread-link': showReplyCountButton || showIsReplyInChannel,
139142
'str-chat__virtual-message__wrapper--end': endOfGroup,
140143
'str-chat__virtual-message__wrapper--first': firstOfGroup,
141144
'str-chat__virtual-message__wrapper--group': groupedByUser,
@@ -210,6 +213,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
210213
reply_count={message.reply_count}
211214
/>
212215
)}
216+
{showIsReplyInChannel && <MessageIsThreadReplyInChannelButtonIndicator />}
213217
{showMetadata && (
214218
<div className='str-chat__message-metadata'>
215219
<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
@@ -217,6 +217,23 @@ describe('<MessageSimple />', () => {
217217
expect(results).toHaveNoViolations();
218218
});
219219

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

656+
it('should display is-message-reply button', async () => {
657+
const message = generateAliceMessage({
658+
parent_id: 'x',
659+
show_in_channel: true,
660+
});
661+
const { container, getByTestId } = await renderMessageSimple({ message });
662+
expect(getByTestId('message-is-thread-reply-button')).toBeInTheDocument();
663+
const results = await axe(container);
664+
expect(results).toHaveNoViolations();
665+
});
666+
667+
it('should open thread when is-message-reply button is clicked', async () => {
668+
const parentMessage = generateMessage({ id: 'x' });
669+
const message = generateAliceMessage({
670+
parent_id: parentMessage.id,
671+
show_in_channel: true,
672+
});
673+
channel.state.messageSets[0].messages.unshift(parentMessage);
674+
const { container, getByTestId } = await renderMessageSimple({
675+
message,
676+
});
677+
expect(openThreadMock).not.toHaveBeenCalled();
678+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
679+
expect(openThreadMock).toHaveBeenCalledWith(expect.any(Object));
680+
const results = await axe(container);
681+
expect(results).toHaveNoViolations();
682+
});
683+
684+
it('should not open thread when is-message-reply button is clicked and parent message is not found', async () => {
685+
const parentMessage = generateMessage({ id: 'x' });
686+
const message = generateAliceMessage({
687+
parent_id: parentMessage.id,
688+
show_in_channel: true,
689+
});
690+
const { container, getByTestId } = await renderMessageSimple({
691+
message,
692+
});
693+
expect(openThreadMock).not.toHaveBeenCalled();
694+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
695+
expect(openThreadMock).not.toHaveBeenCalled();
696+
const results = await axe(container);
697+
expect(results).toHaveNoViolations();
698+
});
699+
700+
it('should query the parent if not found in local state', async () => {
701+
const parentMessage = generateMessage({ id: 'x' });
702+
const message = generateAliceMessage({
703+
parent_id: parentMessage.id,
704+
show_in_channel: true,
705+
});
706+
const searchSpy = jest.spyOn(client, 'search');
707+
const { container, getByTestId } = await renderMessageSimple({
708+
message,
709+
});
710+
fireEvent.click(getByTestId('message-is-thread-reply-button'));
711+
expect(searchSpy).toHaveBeenCalledWith(
712+
{ cid: channel.cid },
713+
{ id: parentMessage.id },
714+
);
715+
const results = await axe(container);
716+
expect(results).toHaveNoViolations();
717+
});
718+
639719
it('should open thread when reply count button is clicked', async () => {
640720
const message = generateAliceMessage({
641721
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)