Skip to content

Commit b88a394

Browse files
authored
[CLNP-5022] feat: add message input related eventHandlers (#1214)
Addresses https://sendbird.atlassian.net/browse/CLNP-5022 ### Main Changes ``` eventHandlers?: { ... message?: { onSendMessageFailed?: (message: CoreMessageType, error: unknown) => void; onUpdateMessageFailed?: (message: CoreMessageType, error: unknown) => void; onFileUploadFailed?: (error: unknown) => void; // any message related event handlers can be added here } } ``` The original request is only about sending message related event, but just added two more (onUpdateMessageFailed / onFileUploadFailed)
1 parent e1d331c commit b88a394

File tree

3 files changed

+172
-34
lines changed

3 files changed

+172
-34
lines changed

src/lib/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export interface SBUEventHandlers {
6161
modal?: {
6262
onMounted?(params: { id: string; close(): void; }): void | (() => void);
6363
};
64+
message?: {
65+
onSendMessageFailed?: (message: CoreMessageType, error: unknown) => void;
66+
onUpdateMessageFailed?: (message: CoreMessageType, error: unknown) => void;
67+
onFileUploadFailed?: (error: unknown) => void;
68+
}
6469
}
6570

6671
export interface SendBirdStateConfig {

src/ui/MessageInput/__tests__/MessageInput.spec.js

+119
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,122 @@ describe('ui/MessageInput', () => {
238238
).toBe(1);
239239
});
240240
});
241+
242+
describe('MessageInput error handling', () => {
243+
beforeEach(() => {
244+
const stateContextValue = {
245+
config: {
246+
groupChannel: {
247+
enableDocument: true,
248+
},
249+
},
250+
eventHandlers: {
251+
message: {
252+
onSendMessageFailed: jest.fn(),
253+
onUpdateMessageFailed: jest.fn(),
254+
onFileUploadFailed: jest.fn(),
255+
},
256+
},
257+
};
258+
const localeContextValue = {
259+
stringSet: {},
260+
};
261+
262+
useContext.mockReturnValue(stateContextValue);
263+
useLocalization.mockReturnValue(localeContextValue);
264+
265+
renderHook(() => useSendbirdStateContext());
266+
renderHook(() => useLocalization());
267+
});
268+
269+
it('should call onSendMessageFailed when sendMessage throws an error by onKeyDown event', async () => {
270+
const mockErrorMessage = 'Send message failed';
271+
const onSendMessage = jest.fn(() => {
272+
throw new Error(mockErrorMessage);
273+
});
274+
const { eventHandlers } = useSendbirdStateContext();
275+
const textRef = { current: { innerText: null } };
276+
const mockText = 'Test Value';
277+
278+
render(<MessageInput onSendMessage={onSendMessage} ref={textRef} />);
279+
280+
const input = screen.getByRole('textbox');
281+
await userEvent.type(input, mockText);
282+
283+
fireEvent.keyDown(input, { key: 'Enter' });
284+
285+
expect(onSendMessage).toThrow(mockErrorMessage);
286+
expect(eventHandlers.message.onSendMessageFailed).toHaveBeenCalled();
287+
});
288+
289+
it('should call onSendMessageFailed when sendMessage throws an error by onClick event', async () => {
290+
const mockErrorMessage = 'Send message failed';
291+
const onSendMessage = jest.fn(() => {
292+
throw new Error(mockErrorMessage);
293+
});
294+
const { eventHandlers } = useSendbirdStateContext();
295+
const textRef = { current: { innerText: null } };
296+
const mockText = 'Test Value';
297+
298+
render(<MessageInput onSendMessage={onSendMessage} ref={textRef} />);
299+
300+
const input = screen.getByRole('textbox');
301+
await userEvent.type(input, mockText);
302+
303+
const sendIcon = document.getElementsByClassName('sendbird-message-input--send')[0];
304+
fireEvent.click(sendIcon);
305+
306+
expect(onSendMessage).toThrow(mockErrorMessage);
307+
expect(eventHandlers.message.onSendMessageFailed).toHaveBeenCalled();
308+
});
309+
310+
it('should call onUpdateMessageFailed when editMessage throws an error', async () => {
311+
const mockErrorMessage = 'Update message failed';
312+
const onUpdateMessage = jest.fn(() => {
313+
throw new Error(mockErrorMessage);
314+
});
315+
const { eventHandlers } = useSendbirdStateContext();
316+
const messageId = 123;
317+
const textRef = { current: { innerText: null } };
318+
const mockText = 'Updated Text';
319+
320+
render(
321+
<MessageInput
322+
isEdit
323+
message={{ messageId }}
324+
onUpdateMessage={onUpdateMessage}
325+
ref={textRef}
326+
/>
327+
);
328+
329+
const input = screen.getByRole('textbox');
330+
await userEvent.type(input, mockText);
331+
332+
const editButton = document.getElementsByClassName('sendbird-message-input--edit-action__save')[0];
333+
334+
fireEvent.click(editButton);
335+
336+
expect(onUpdateMessage).toThrow(mockErrorMessage);
337+
expect(eventHandlers.message.onUpdateMessageFailed).toHaveBeenCalled();
338+
});
339+
340+
it('should call onFileUploadFailed when file upload throws an error', async () => {
341+
const mockErrorMessage = 'File upload failed';
342+
const onFileUpload = jest.fn(() => {
343+
throw new Error(mockErrorMessage);
344+
});
345+
const { eventHandlers } = useSendbirdStateContext();
346+
const file = new File(['dummy content'], 'example.txt', { type: 'text/plain' });
347+
348+
render(<MessageInput onFileUpload={onFileUpload} />);
349+
350+
const fileInput = document.getElementsByClassName('sendbird-message-input--attach-input')[0];
351+
352+
fireEvent.change(fileInput, { currentTarget: { files: [file] } });
353+
354+
expect(onFileUpload).toThrow(mockErrorMessage);
355+
expect(eventHandlers.message.onFileUploadFailed).toHaveBeenCalled();
356+
});
357+
});
358+
359+

src/ui/MessageInput/index.tsx

+48-34
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
138138

139139
const textFieldId = messageFieldId || TEXT_FIELD_ID;
140140
const { stringSet } = useLocalization();
141-
const { config } = useSendbirdStateContext();
141+
const { config, eventHandlers } = useSendbirdStateContext();
142142

143143
const isFileUploadEnabled = checkIfFileUploadEnabled({
144144
channel,
@@ -391,38 +391,45 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
391391
}, [isMentionEnabled]);
392392

393393
const sendMessage = () => {
394-
const textField = internalRef?.current;
395-
if (!isEdit && textField?.textContent) {
396-
const { messageText, mentionTemplate } = extractTextAndMentions(textField.childNodes);
397-
const params = { message: messageText, mentionTemplate };
398-
onSendMessage(params);
399-
resetInput(internalRef);
394+
try {
395+
const textField = internalRef?.current;
396+
if (!isEdit && textField?.textContent) {
397+
const { messageText, mentionTemplate } = extractTextAndMentions(textField.childNodes);
398+
const params = { message: messageText, mentionTemplate };
399+
onSendMessage(params);
400+
resetInput(internalRef);
401+
/**
402+
* Note: contentEditable does not work as expected in mobile WebKit (Safari).
403+
* @see https://github.com/sendbird/sendbird-uikit-react/pull/1108
404+
*/
405+
if (isMobileIOS(navigator.userAgent)) {
406+
if (ghostInputRef.current) ghostInputRef.current.focus();
407+
requestAnimationFrame(() => textField.focus());
408+
} else {
409+
// important: keeps the keyboard open -> must add test on refactor
410+
textField.focus();
411+
}
400412

401-
/**
402-
* Note: contentEditable does not work as expected in mobile WebKit (Safari).
403-
* @see https://github.com/sendbird/sendbird-uikit-react/pull/1108
404-
*/
405-
if (isMobileIOS(navigator.userAgent)) {
406-
if (ghostInputRef.current) ghostInputRef.current.focus();
407-
requestAnimationFrame(() => textField.focus());
408-
} else {
409-
// important: keeps the keyboard open -> must add test on refactor
410-
textField.focus();
413+
setIsInput(false);
414+
setHeight();
411415
}
412-
413-
setIsInput(false);
414-
setHeight();
416+
} catch (error) {
417+
eventHandlers?.message?.onSendMessageFailed?.(message, error);
415418
}
416419
};
417420
const isEditDisabled = !internalRef?.current?.textContent?.trim();
418421
const editMessage = () => {
419-
const textField = internalRef?.current;
420-
const messageId = message?.messageId;
421-
if (isEdit && messageId && textField) {
422-
const { messageText, mentionTemplate } = extractTextAndMentions(textField.childNodes);
423-
const params = { messageId, message: messageText, mentionTemplate };
424-
onUpdateMessage(params);
425-
resetInput(internalRef);
422+
try {
423+
const textField = internalRef?.current;
424+
const messageId = message?.messageId;
425+
if (isEdit && messageId && textField) {
426+
const { messageText, mentionTemplate } = extractTextAndMentions(textField.childNodes);
427+
const params = { messageId, message: messageText, mentionTemplate };
428+
onUpdateMessage(params);
429+
resetInput(internalRef);
430+
}
431+
} catch (error) {
432+
eventHandlers?.message?.onUpdateMessageFailed?.(message, error);
426433
}
427434
};
428435
const onPaste = usePaste({
@@ -433,6 +440,19 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
433440
setHeight,
434441
});
435442

443+
const uploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
444+
const { files } = event.currentTarget;
445+
try {
446+
if (files) {
447+
onFileUpload(Array.from(files));
448+
}
449+
} catch (error) {
450+
eventHandlers?.message?.onFileUploadFailed?.(error);
451+
} finally {
452+
event.target.value = '';
453+
}
454+
};
455+
436456
return (
437457
<form className={classnames(
438458
...(Array.isArray(className) ? className : [className]),
@@ -559,13 +579,7 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
559579
type="file"
560580
ref={fileInputRef}
561581
// It will affect to <Channel /> and <Thread />
562-
onChange={(event) => {
563-
const { files } = event.currentTarget;
564-
if (files) {
565-
onFileUpload(files && files.length === 1 ? [files[0]] : Array.from(files));
566-
}
567-
event.target.value = '';
568-
}}
582+
onChange={(event) => uploadFile(event)}
569583
accept={getMimeTypesUIKitAccepts(acceptableMimeTypes)}
570584
multiple={isSelectingMultipleFilesEnabled && isChannelTypeSupportsMultipleFilesMessage(channel)}
571585
/>

0 commit comments

Comments
 (0)