Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/modules/GroupChannel/context/GroupChannelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { getIsReactionEnabled } from '../../../utils/getIsReactionEnabled';

export { ThreadReplySelectType } from './const'; // export for external usage

type OnBeforeHandler<T> = (params: T) => T | Promise<T>;
export type OnBeforeHandler<T> = (params: T) => T | Promise<T> | void | Promise<void>;
type MessageListQueryParamsType = Omit<MessageCollectionParams, 'filter'> & MessageFilterParams;
type MessageActions = ReturnType<typeof useMessageActions>;
type MessageListDataSourceWithoutActions = Omit<ReturnType<typeof useGroupChannelMessages>, keyof MessageActions | `_dangerous_${string}`>;
Expand Down
154 changes: 154 additions & 0 deletions src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
});
81 changes: 63 additions & 18 deletions src/modules/GroupChannel/context/hooks/useMessageActions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof useGroupChannelMessages>;
type MessageActions = {
Expand All @@ -39,6 +41,13 @@ interface Params extends GroupChannelProviderProps, MessageListDataSource {
}

const pass = <T>(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.
Expand All @@ -60,7 +69,7 @@ export function useMessageActions(params: Params): MessageActions {
quoteMessage,
replyType,
} = params;

const { eventHandlers } = useSendbirdStateContext();
const buildInternalMessageParams = useCallback(
<T extends BaseMessageCreateParams>(basicParams: T): T => {
const messageParams = { ...basicParams } as T;
Expand All @@ -84,33 +93,71 @@ export function useMessageActions(params: Params): MessageActions {
[],
);

const processParams = useCallback(async <T extends keyof MessageParamsByType>(
handler: OnBeforeHandler<MessageParamsByType[T]>,
params: ReturnType<typeof buildInternalMessageParams>,
type: keyof MessageParamsByType,
): Promise<MessageParamsByType[T]> => {
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<UserMessageCreateParams>(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<FileMessageCreateParams>(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<MultipleFilesMessageCreateParams>(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) => {
Expand All @@ -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<UserMessageUpdateParams>(params);
const processedParams = await onBeforeUpdateUserMessage(internalParams);

const processedParams = await processParams(onBeforeUpdateUserMessage, internalParams, 'update');
return updateUserMessage(messageId, processedParams);
},
[buildInternalMessageParams, updateUserMessage],
[buildInternalMessageParams, updateUserMessage, processParams],
),
};
}
34 changes: 34 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down