diff --git a/package.json b/package.json index 301492b08..8a98ae6ea 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@svgr/rollup": "^8.1.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/node": "^22.7.2", "@typescript-eslint/eslint-plugin": "^6.17.0", diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 8213cf5e1..3c9e46389 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -29,7 +29,7 @@ import { getIsReactionEnabled } from '../../../utils/getIsReactionEnabled'; export { ThreadReplySelectType } from './const'; // export for external usage -type OnBeforeHandler = (params: T) => T | Promise; +export type OnBeforeHandler = (params: T) => T | Promise | void | Promise; type MessageListQueryParamsType = Omit & MessageFilterParams; type MessageActions = ReturnType; type MessageListDataSourceWithoutActions = Omit, keyof MessageActions | `_dangerous_${string}`>; diff --git a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts new file mode 100644 index 000000000..b20163832 --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts @@ -0,0 +1,154 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useMessageActions } from '../hooks/useMessageActions'; +import { UserMessageCreateParams, FileMessageCreateParams } from '@sendbird/chat/message'; + +const mockEventHandlers = { + message: { + onSendMessageFailed: jest.fn(), + onUpdateMessageFailed: jest.fn(), + onFileUploadFailed: jest.fn(), + }, +}; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: () => ({ + eventHandlers: mockEventHandlers, + }), +})); + +describe('useMessageActions', () => { + const mockParams = { + sendUserMessage: jest.fn(), + sendFileMessage: jest.fn(), + sendMultipleFilesMessage: jest.fn(), + updateUserMessage: jest.fn(), + scrollToBottom: jest.fn(), + replyType: 'NONE', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('processParams', () => { + it('should handle successful user message', async () => { + const { result } = renderHook(() => useMessageActions(mockParams)); + const params: UserMessageCreateParams = { message: 'test' }; + + await result.current.sendUserMessage(params); + + expect(mockParams.sendUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'test' }), + expect.any(Function), + ); + }); + + it('should handle void return from onBeforeSendFileMessage', async () => { + const onBeforeSendFileMessage = jest.fn(); + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendFileMessage, + }), + ); + + const fileParams: FileMessageCreateParams = { + file: new File([], 'test.txt'), + }; + + await result.current.sendFileMessage(fileParams); + + expect(onBeforeSendFileMessage).toHaveBeenCalled(); + expect(mockParams.sendFileMessage).toHaveBeenCalledWith( + expect.objectContaining(fileParams), + expect.any(Function), + ); + }); + + it('should handle file upload error', async () => { + // Arrange + const error = new Error('Upload failed'); + const onBeforeSendFileMessage = jest.fn().mockRejectedValue(error); + const fileParams: FileMessageCreateParams = { + file: new File([], 'test.txt'), + fileName: 'test.txt', + }; + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendFileMessage, + }), + ); + + await expect(async () => { + await result.current.sendFileMessage(fileParams); + }).rejects.toThrow('Upload failed'); + + // Wait for next tick to ensure all promises are resolved + await new Promise(process.nextTick); + + expect(onBeforeSendFileMessage).toHaveBeenCalled(); + expect(mockEventHandlers.message.onFileUploadFailed).toHaveBeenCalledWith(error); + expect(mockEventHandlers.message.onSendMessageFailed).toHaveBeenCalledWith( + expect.objectContaining({ + file: fileParams.file, + fileName: fileParams.fileName, + }), + error, + ); + }); + + it('should handle message update error', async () => { + // Arrange + const error = new Error('Update failed'); + const onBeforeUpdateUserMessage = jest.fn().mockRejectedValue(error); + const messageParams = { + messageId: 1, + message: 'update message', + }; + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeUpdateUserMessage, + }), + ); + + await expect(async () => { + await result.current.updateUserMessage(messageParams.messageId, { + message: messageParams.message, + }); + }).rejects.toThrow('Update failed'); + + // Wait for next tick to ensure all promises are resolved + await new Promise(process.nextTick); + + expect(onBeforeUpdateUserMessage).toHaveBeenCalled(); + expect(mockEventHandlers.message.onUpdateMessageFailed).toHaveBeenCalledWith( + expect.objectContaining({ + message: messageParams.message, + }), + error, + ); + }); + + it('should preserve modified params from onBefore handlers', async () => { + const onBeforeSendUserMessage = jest.fn().mockImplementation((params) => ({ + ...params, + message: 'modified', + })); + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendUserMessage, + }), + ); + + await result.current.sendUserMessage({ message: 'original' }); + + expect(mockParams.sendUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'modified' }), + expect.any(Function), + ); + }); + }); +}); diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts index f6f6763a1..a61d4da0a 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -1,3 +1,4 @@ +import { match } from 'ts-pattern'; import { useCallback } from 'react'; import { useGroupChannelMessages } from '@sendbird/uikit-tools'; import { MessageMetaArray } from '@sendbird/chat/message'; @@ -19,9 +20,10 @@ import { VOICE_MESSAGE_FILE_NAME, VOICE_MESSAGE_MIME_TYPE, } from '../../../../utils/consts'; -import type { SendableMessageType } from '../../../../utils'; +import type { SendableMessageType, CoreMessageType } from '../../../../utils'; import type { ReplyType } from '../../../../types'; -import type { GroupChannelProviderProps } from '../GroupChannelProvider'; +import type { GroupChannelProviderProps, OnBeforeHandler } from '../GroupChannelProvider'; +import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; type MessageListDataSource = ReturnType; type MessageActions = { @@ -39,6 +41,13 @@ interface Params extends GroupChannelProviderProps, MessageListDataSource { } const pass = (value: T) => value; +type MessageParamsByType = { + user: UserMessageCreateParams; + file: FileMessageCreateParams; + multipleFiles: MultipleFilesMessageCreateParams; + voice: FileMessageCreateParams; + update: UserMessageUpdateParams; +}; /** * @description This hook controls common processes related to message sending, updating. @@ -60,7 +69,7 @@ export function useMessageActions(params: Params): MessageActions { quoteMessage, replyType, } = params; - + const { eventHandlers } = useSendbirdStateContext(); const buildInternalMessageParams = useCallback( (basicParams: T): T => { const messageParams = { ...basicParams } as T; @@ -84,33 +93,71 @@ export function useMessageActions(params: Params): MessageActions { [], ); + const processParams = useCallback(async ( + handler: OnBeforeHandler, + params: ReturnType, + type: keyof MessageParamsByType, + ): Promise => { + try { + const result = await handler(params as MessageParamsByType[T]); + return (result === undefined ? params : result) as MessageParamsByType[T]; + } catch (error) { + if (typeof eventHandlers?.message === 'object') { + match(type) + .with('file', 'voice', () => { + if ((params as any).file) { + eventHandlers.message.onFileUploadFailed?.(error); + } + eventHandlers.message.onSendMessageFailed?.(params as CoreMessageType, error); + }) + .with('multipleFiles', () => { + if ((params as MultipleFilesMessageCreateParams).fileInfoList) { + eventHandlers.message.onFileUploadFailed?.(error); + } + eventHandlers.message.onSendMessageFailed?.(params as CoreMessageType, error); + }) + .with('user', () => { + eventHandlers.message.onSendMessageFailed?.( + params as CoreMessageType, + error, + ); + }) + .with('update', () => { + eventHandlers.message.onUpdateMessageFailed?.( + params as CoreMessageType, + error, + ); + }) + .exhaustive(); + } + throw error; + } + }, [eventHandlers]); + return { sendUserMessage: useCallback( async (params) => { const internalParams = buildInternalMessageParams(params); - const processedParams = await onBeforeSendUserMessage(internalParams); - + const processedParams = await processParams(onBeforeSendUserMessage, internalParams, 'user') as UserMessageCreateParams; return sendUserMessage(processedParams, asyncScrollToBottom); }, - [buildInternalMessageParams, sendUserMessage, scrollToBottom], + [buildInternalMessageParams, sendUserMessage, scrollToBottom, processParams], ), sendFileMessage: useCallback( async (params) => { const internalParams = buildInternalMessageParams(params); - const processedParams = await onBeforeSendFileMessage(internalParams); - + const processedParams = await processParams(onBeforeSendFileMessage, internalParams, 'file') as FileMessageCreateParams; return sendFileMessage(processedParams, asyncScrollToBottom); }, - [buildInternalMessageParams, sendFileMessage, scrollToBottom], + [buildInternalMessageParams, sendFileMessage, scrollToBottom, processParams], ), sendMultipleFilesMessage: useCallback( async (params) => { const internalParams = buildInternalMessageParams(params); - const processedParams = await onBeforeSendMultipleFilesMessage(internalParams); - + const processedParams = await processParams(onBeforeSendMultipleFilesMessage, internalParams, 'multipleFiles') as MultipleFilesMessageCreateParams; return sendMultipleFilesMessage(processedParams, asyncScrollToBottom); }, - [buildInternalMessageParams, sendMultipleFilesMessage, scrollToBottom], + [buildInternalMessageParams, sendMultipleFilesMessage, scrollToBottom, processParams], ), sendVoiceMessage: useCallback( async (params: FileMessageCreateParams, duration: number) => { @@ -129,20 +176,18 @@ export function useMessageActions(params: Params): MessageActions { }), ], }); - const processedParams = await onBeforeSendVoiceMessage(internalParams); - + const processedParams = await processParams(onBeforeSendVoiceMessage, internalParams, 'voice'); return sendFileMessage(processedParams, asyncScrollToBottom); }, - [buildInternalMessageParams, sendFileMessage, scrollToBottom], + [buildInternalMessageParams, sendFileMessage, scrollToBottom, processParams], ), updateUserMessage: useCallback( async (messageId: number, params: UserMessageUpdateParams) => { const internalParams = buildInternalMessageParams(params); - const processedParams = await onBeforeUpdateUserMessage(internalParams); - + const processedParams = await processParams(onBeforeUpdateUserMessage, internalParams, 'update'); return updateUserMessage(messageId, processedParams); }, - [buildInternalMessageParams, updateUserMessage], + [buildInternalMessageParams, updateUserMessage, processParams], ), }; } diff --git a/yarn.lock b/yarn.lock index bb3c58727..740d12dcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2778,6 +2778,7 @@ __metadata: "@svgr/rollup": ^8.1.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^13.4.0 + "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ^14.4.3 "@types/node": ^22.7.2 "@typescript-eslint/eslint-plugin": ^6.17.0 @@ -3468,6 +3469,28 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-hooks@npm:^8.0.1": + version: 8.0.1 + resolution: "@testing-library/react-hooks@npm:8.0.1" + dependencies: + "@babel/runtime": ^7.12.5 + react-error-boundary: ^3.1.0 + peerDependencies: + "@types/react": ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + checksum: 7fe44352e920deb5cb1876f80d64e48615232072c9d5382f1e0284b3aab46bb1c659a040b774c45cdf084a5257b8fe463f7e08695ad8480d8a15635d4d3d1f6d + languageName: node + linkType: hard + "@testing-library/react@npm:^13.4.0": version: 13.4.0 resolution: "@testing-library/react@npm:13.4.0" @@ -12532,6 +12555,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^3.1.0": + version: 3.1.4 + resolution: "react-error-boundary@npm:3.1.4" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: f36270a5d775a25c8920f854c0d91649ceea417b15b5bc51e270a959b0476647bb79abb4da3be7dd9a4597b029214e8fe43ea914a7f16fa7543c91f784977f1b + languageName: node + linkType: hard + "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0"