diff --git a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx
new file mode 100644
index 000000000..0a1142b87
--- /dev/null
+++ b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx
@@ -0,0 +1,392 @@
+import * as useThreadModule from '../../../context/useThread';
+import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../../types';
+import { EmojiContainer } from '@sendbird/chat';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { LocalizationContext } from '../../../../../lib/LocalizationContext';
+import ThreadUI from '../index';
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+
+const mockSendUserMessage = jest.fn();
+
+const mockChannel = {
+ url: 'test-channel',
+ members: [{ userId: 'test-user-id', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)),
+ sendUserMessage: mockSendUserMessage,
+ isGroupChannel: jest.fn().mockImplementation(() => true),
+};
+
+const mockNewMessage = (message) => ({
+ messageId: 42,
+ message: message ?? 'new message',
+});
+
+const mockMessage = {
+ messageId: 1,
+ message: 'first message',
+};
+
+const mockGetMessage = jest.fn().mockResolvedValue(mockMessage);
+const mockGetChannel = jest.fn().mockResolvedValue(mockChannel);
+
+const mockState = {
+ stores: {
+ sdkStore: {
+ sdk: {
+ getMessage: mockGetMessage,
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ },
+ initialized: true,
+ },
+ userStore: { user: { userId: 'test-user-id' } },
+ },
+ config: {
+ logger: console,
+ isOnline: true,
+ pubSub: {
+ publish: jest.fn(),
+ },
+ groupChannel: {
+ enableMention: true,
+ enableReactions: true,
+ replyType: 'THREAD',
+ },
+ },
+};
+
+jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ state: mockState })),
+ useSendbird: jest.fn(() => ({ state: mockState })),
+}));
+
+jest.mock('../../../context/useThread');
+
+const mockStringSet = {
+ DATE_FORMAT__MESSAGE_CREATED_AT: 'p',
+};
+
+const mockLocalizationContext = {
+ stringSet: mockStringSet,
+};
+
+const defaultMockState = {
+ channelUrl: '',
+ message: null,
+ onHeaderActionClick: undefined,
+ onMoveToParentMessage: undefined,
+ onBeforeSendUserMessage: undefined,
+ onBeforeSendFileMessage: undefined,
+ onBeforeSendVoiceMessage: undefined,
+ onBeforeSendMultipleFilesMessage: undefined,
+ onBeforeDownloadFileMessage: undefined,
+ isMultipleFilesMessageEnabled: undefined,
+ filterEmojiCategoryIds: undefined,
+ currentChannel: undefined,
+ allThreadMessages: [
+ {
+ messageId: 2,
+ message: 'threaded message 1',
+ isUserMessage: () => true,
+ },
+ ],
+ localThreadMessages: [],
+ parentMessage: null,
+ channelState: ChannelStateTypes.INITIALIZED,
+ parentMessageState: ParentMessageStateTypes.INITIALIZED,
+ threadListState: ThreadListStateTypes.INITIALIZED,
+ hasMorePrev: false,
+ hasMoreNext: false,
+ emojiContainer: {} as EmojiContainer,
+ isMuted: false,
+ isChannelFrozen: false,
+ currentUserId: '',
+ typingMembers: [],
+ nicknamesMap: null,
+};
+
+const defaultMockActions = {
+ fetchPrevThreads: jest.fn((callback) => {
+ callback();
+ }),
+ fetchNextThreads: jest.fn((callback) => {
+ callback();
+ }),
+};
+
+describe('CreateChannelUI Integration Tests', () => {
+ const mockUseThread = useThreadModule.default as jest.Mock;
+
+ const renderComponent = (mockState = {}, mockActions = {}) => {
+ mockUseThread.mockReturnValue({
+ state: { ...defaultMockState, ...mockState },
+ actions: { ...defaultMockActions, ...mockActions },
+ });
+
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('display initial state correctly', async () => {
+ await act(async () => {
+ renderComponent(
+ {
+ parentMessage: {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ },
+ },
+ );
+ });
+
+ expect(screen.getByText('parent message')).toBeInTheDocument();
+ expect(screen.getByText('threaded message 1')).toBeInTheDocument();
+ });
+
+ it('fetchPrevThread is correctly called when scroll is top', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ getThreadedMessagesByTimestamp: () => ({
+ parentMessage,
+ threadedMessages: [
+ { messageId: 3, message: 'threaded message -1', isUserMessage: () => true },
+ { messageId: 4, message: 'threaded message 0', isUserMessage: () => true },
+ ],
+ }),
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ hasMorePrev: true,
+ },
+ );
+
+ container = result.container;
+ });
+
+ const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0];
+ fireEvent.scroll(scrollContainer, { target: { scrollY: -1 } });
+
+ await waitFor(() => {
+ expect(defaultMockActions.fetchPrevThreads).toBeCalledTimes(1);
+ });
+ });
+
+ it('fetchNextThreads is correctly called when scroll is bottom', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ getThreadedMessagesByTimestamp: () => ({
+ parentMessage,
+ threadedMessages: [
+ { messageId: 3, message: 'threaded message -1', isUserMessage: () => true },
+ { messageId: 4, message: 'threaded message 0', isUserMessage: () => true },
+ ],
+ }),
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ hasMoreNext: true,
+ },
+ );
+
+ container = result.container;
+ });
+
+ const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0];
+ fireEvent.scroll(scrollContainer, { target: { scrollY: scrollContainer.scrollHeight + 1 } });
+
+ await waitFor(() => {
+ expect(defaultMockActions.fetchNextThreads).toBeCalledTimes(1);
+ });
+ });
+
+ it('show proper placeholder when ParentMessageStateTypes is NIL', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ parentMessageState: ParentMessageStateTypes.NIL,
+ },
+ );
+
+ container = result.container;
+ });
+
+ await waitFor(() => {
+ const placeholder = container.getElementsByClassName('placeholder-nil')[0];
+ expect(placeholder).not.toBe(undefined);
+ });
+
+ });
+
+ it('show proper placeholder when ParentMessageStateTypes is LOADING', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ parentMessageState: ParentMessageStateTypes.LOADING,
+ },
+ );
+
+ container = result.container;
+ });
+
+ await waitFor(() => {
+ const placeholder = container.getElementsByClassName('placeholder-loading')[0];
+ expect(placeholder).not.toBe(undefined);
+ });
+
+ });
+
+ it('show proper placeholder when ParentMessageStateTypes is INVALID', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ parentMessageState: ParentMessageStateTypes.INVALID,
+ },
+ );
+
+ container = result.container;
+ });
+
+ await waitFor(() => {
+ const placeholder = container.getElementsByClassName('placeholder-invalid')[0];
+ expect(placeholder).not.toBe(undefined);
+ });
+ });
+
+ it('show proper placeholder when ThreadListState is LOADING', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ threadListState: ThreadListStateTypes.LOADING,
+ },
+ );
+
+ container = result.container;
+ });
+
+ await waitFor(() => {
+ const placeholder = container.getElementsByClassName('placeholder-loading')[0];
+ expect(placeholder).not.toBe(undefined);
+ });
+ });
+
+ it('show proper placeholder when ThreadListState is INVALID', async () => {
+ let container;
+ const parentMessage = {
+ messageId: 1,
+ message: 'parent message',
+ isUserMessage: () => true,
+ isTextMessage: true,
+ createdAt: 0,
+ sender: {
+ userId: 'test-user-id',
+ },
+ };
+
+ await act(async () => {
+ const result = renderComponent(
+ {
+ parentMessage,
+ threadListState: ThreadListStateTypes.INVALID,
+ },
+ );
+
+ container = result.container;
+ });
+
+ await waitFor(() => {
+ const placeholder = container.getElementsByClassName('placeholder-invalid')[0];
+ expect(placeholder).not.toBe(undefined);
+ });
+ });
+
+});
diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx
index bef0ad80b..8d3b0c9b1 100644
--- a/src/modules/Thread/context/ThreadProvider.tsx
+++ b/src/modules/Thread/context/ThreadProvider.tsx
@@ -70,7 +70,7 @@ export interface ThreadState {
nicknamesMap: Map;
}
-const initialState = {
+const initialState: ThreadState = {
channelUrl: '',
message: null,
onHeaderActionClick: undefined,
diff --git a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx
index 43b0d0d66..e197dcdbf 100644
--- a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx
+++ b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx
@@ -1,21 +1,68 @@
import React from 'react';
import { act, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
-import { ThreadProvider } from '../ThreadProvider';
+import { ThreadProvider, ThreadState } from '../ThreadProvider';
import useThread from '../useThread';
import { SendableMessageType } from '../../../../utils';
import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
+import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types';
+import { EmojiContainer } from '@sendbird/chat';
+
+class MockMessageMethod {
+ _onPending: (message: SendableMessageType) => void;
+
+ _onFailed: (message: SendableMessageType) => void;
+
+ _onSucceeded: (message: SendableMessageType) => void;
+
+ constructor(message, willSucceed = true) {
+ this._onPending = undefined;
+ this._onFailed = undefined;
+ this._onSucceeded = undefined;
+
+ this.init(message, willSucceed);
+ }
+
+ init(message, willSucceed) {
+ setTimeout(() => this._onPending?.(message), 0);
+ setTimeout(() => {
+ if (willSucceed) {
+ this._onSucceeded?.(message);
+ } else {
+ this._onFailed?.(message);
+ }
+ }, 300);
+ }
+
+ onPending(func) {
+ this._onPending = func;
+ return this;
+ }
+
+ onFailed(func) {
+ this._onFailed = func;
+ return this;
+ }
+
+ onSucceeded(func) {
+ this._onSucceeded = func;
+ return this;
+ }
+}
+
+const mockSendUserMessage = jest.fn();
const mockChannel = {
url: 'test-channel',
members: [{ userId: '1', nickname: 'user1' }],
- updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)),
+ sendUserMessage: mockSendUserMessage,
};
-const mockNewMessage = {
+const mockNewMessage = (message) => ({
messageId: 42,
- message: 'new message',
-};
+ message: message ?? 'new message',
+});
const mockGetChannel = jest.fn().mockResolvedValue(mockChannel);
@@ -48,26 +95,54 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
}));
describe('ThreadProvider', () => {
+ const initialState: ThreadState = {
+ channelUrl: '',
+ message: null,
+ onHeaderActionClick: undefined,
+ onMoveToParentMessage: undefined,
+ onBeforeSendUserMessage: undefined,
+ onBeforeSendFileMessage: undefined,
+ onBeforeSendVoiceMessage: undefined,
+ onBeforeSendMultipleFilesMessage: undefined,
+ onBeforeDownloadFileMessage: undefined,
+ isMultipleFilesMessageEnabled: undefined,
+ filterEmojiCategoryIds: undefined,
+ currentChannel: null,
+ allThreadMessages: [],
+ localThreadMessages: [],
+ parentMessage: null,
+ channelState: ChannelStateTypes.NIL,
+ parentMessageState: ParentMessageStateTypes.NIL,
+ threadListState: ThreadListStateTypes.NIL,
+ hasMorePrev: false,
+ hasMoreNext: false,
+ emojiContainer: {} as EmojiContainer,
+ isMuted: false,
+ isChannelFrozen: false,
+ currentUserId: '',
+ typingMembers: [],
+ nicknamesMap: null,
+ };
+
const initialMockMessage = {
messageId: 1,
} as SendableMessageType;
beforeEach(() => {
+ jest.clearAllMocks();
const stateContextValue = { state: mockState };
- useSendbird.mockReturnValue(stateContextValue);
+ (useSendbird as jest.Mock).mockReturnValue(stateContextValue);
renderHook(() => useSendbird());
});
it('provides the correct initial state', async () => {
const wrapper = ({ children }) => (
- {children}
+ {children}
);
await act(async () => {
const { result } = renderHook(() => useThread(), { wrapper });
- await waitFor(() => {
- expect(result.current.state.message).toBe(initialMockMessage);
- });
+ expect(result.current.state).toEqual(initialState);
});
});
@@ -154,70 +229,25 @@ describe('ThreadProvider', () => {
});
});
- // it('calls sendMessage correctly', async () => {
- // const wrapper = ({ children }) => (
- //
- // {children}
- //
- // );
- //
- // const { result } = renderHook(() => useThreadContext(), { wrapper });
- // const sendMessageMock = jest.fn();
- //
- // result.current.sendMessage({ message: 'Test Message' });
- //
- // expect(sendMessageMock).toHaveBeenCalledWith({ message: 'Test Message' });
- // });
- //
- // it('handles channel events correctly', () => {
- // const wrapper = ({ children }) => (
- //
- // {children}
- //
- // );
- //
- // render();
- // // Add assertions for handling channel events
- // });
- //
- // it('updates state when nicknamesMap is updated', async () => {
- // const wrapper = ({ children }) => (
- //
- // {children}
- //
- // );
- //
- // const { result } = renderHook(() => useThreadContext(), { wrapper });
- //
- // await act(async () => {
- // result.current.updateState({
- // nicknamesMap: new Map([['user1', 'User One'], ['user2', 'User Two']]),
- // });
- // await waitFor(() => {
- // expect(result.current.nicknamesMap.get('user1')).toBe('User One');
- // });
- // });
- // });
- //
- // it('calls onMoveToParentMessage when provided', async () => {
- // const onMoveToParentMessageMock = jest.fn();
- // const wrapper = ({ children }) => (
- //
- // {children}
- //
- // );
- //
- // const { result } = renderHook(() => useThreadContext(), { wrapper });
- //
- // await act(async () => {
- // result.current.onMoveToParentMessage({ message: { messageId: 1 }, channel: {} });
- // await waitFor(() => {
- // expect(onMoveToParentMessageMock).toHaveBeenCalled();
- // });
- // });
- // });
+ it('update state correctly when sendMessage is called', async () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+
+ mockSendUserMessage.mockImplementation((propsMessage) => new MockMessageMethod(mockNewMessage(propsMessage), true));
+ result.current.actions.sendMessage({ message: 'Test Message' });
+
+ await waitFor(() => {
+ expect(result.current.state.localThreadMessages.at(-1)).toHaveProperty('messageId', 42);
+ });
+ });
+
});
diff --git a/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts
new file mode 100644
index 000000000..709db6415
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts
@@ -0,0 +1,140 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+
+import useDeleteMessageCallback from '../hooks/useDeleteMessageCallback';
+import { SendableMessageType } from '../../../../utils';
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+const mockOnMessageDeletedByReqId = jest.fn();
+const mockOnMessageDeleted = jest.fn();
+const mockDeleteMessage = jest.fn();
+
+describe('useDeleteMessageCallback', () => {
+ const mockChannel = {
+ deleteMessage: mockDeleteMessage,
+ } as unknown as GroupChannel;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('delete failed message from local', async () => {
+ const { result } = renderHook(() => useDeleteMessageCallback(
+ {
+ currentChannel: mockChannel,
+ onMessageDeletedByReqId: mockOnMessageDeletedByReqId,
+ onMessageDeleted: mockOnMessageDeleted,
+ },
+ { logger: mockLogger },
+ ),
+ );
+
+ const failedMessage = {
+ messageId: 123,
+ reqId: 'test-req-id',
+ sendingStatus: 'failed',
+ };
+
+ await result.current(failedMessage as SendableMessageType);
+
+ expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id');
+ expect(mockDeleteMessage).toHaveBeenCalled();
+ });
+
+ it('delete pending message from local', async () => {
+ const { result } = renderHook(() => useDeleteMessageCallback(
+ {
+ currentChannel: mockChannel,
+ onMessageDeletedByReqId: mockOnMessageDeletedByReqId,
+ onMessageDeleted: mockOnMessageDeleted,
+ },
+ { logger: mockLogger },
+ ),
+ );
+
+ const pendingMessage = {
+ messageId: 123,
+ reqId: 'test-req-id',
+ sendingStatus: 'pending',
+ };
+
+ await result.current(pendingMessage as SendableMessageType);
+
+ expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id');
+ expect(mockDeleteMessage).toHaveBeenCalled();
+ });
+
+ it('delete success message from remote', async () => {
+ mockDeleteMessage.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => useDeleteMessageCallback(
+ {
+ currentChannel: mockChannel,
+ onMessageDeletedByReqId: mockOnMessageDeletedByReqId,
+ onMessageDeleted: mockOnMessageDeleted,
+ },
+ { logger: mockLogger },
+ ),
+ );
+
+ const successMessage = {
+ messageId: 123,
+ reqId: 'test-req-id',
+ sendingStatus: 'succeeded',
+ };
+
+ await result.current(successMessage as SendableMessageType);
+
+ expect(mockDeleteMessage).toHaveBeenCalledWith(successMessage);
+ expect(mockOnMessageDeleted).toHaveBeenCalledWith(mockChannel, 123);
+ });
+
+ it('delete failed message from remote', async () => {
+ const errorMessage = 'Failed to delete message';
+ mockDeleteMessage.mockRejectedValueOnce(new Error(errorMessage));
+
+ const { result } = renderHook(() => useDeleteMessageCallback(
+ {
+ currentChannel: mockChannel,
+ onMessageDeletedByReqId: mockOnMessageDeletedByReqId,
+ onMessageDeleted: mockOnMessageDeleted,
+ },
+ { logger: mockLogger },
+ ),
+ );
+
+ const message = {
+ messageId: 123,
+ reqId: 'test-req-id',
+ sendingStatus: 'succeeded',
+ };
+
+ await expect(result.current(message as SendableMessageType)).rejects.toThrow(errorMessage);
+ expect(mockLogger.warning).toHaveBeenCalled();
+ });
+
+ it('currentChannel is null', async () => {
+ const { result } = renderHook(() => useDeleteMessageCallback(
+ {
+ currentChannel: null,
+ onMessageDeletedByReqId: mockOnMessageDeletedByReqId,
+ onMessageDeleted: mockOnMessageDeleted,
+ },
+ { logger: mockLogger },
+ ),
+ );
+
+ const message = {
+ messageId: 123,
+ reqId: 'test-req-id',
+ sendingStatus: 'succeeded',
+ };
+
+ await result.current(message as SendableMessageType);
+ expect(mockDeleteMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts
new file mode 100644
index 000000000..cb3db9553
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts
@@ -0,0 +1,68 @@
+import { renderHook } from '@testing-library/react-hooks';
+import useGetAllEmoji from '../hooks/useGetAllEmoji';
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockSetEmojiContainer = jest.fn();
+const mockLogger = {
+ info: jest.fn(),
+ error: jest.fn(),
+ warning: jest.fn(),
+};
+
+describe('useGetAllEmoji', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('doesnt call getAllEmoji when sdk is null', () => {
+ renderHook(() => useGetAllEmoji(
+ { sdk: null },
+ { logger: mockLogger },
+ ));
+
+ expect(mockSetEmojiContainer).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ });
+
+ it('doesnt call getAllEmoji when sdk.getAllEmoji is undefined', () => {
+ renderHook(() => useGetAllEmoji(
+ { sdk: {} },
+ { logger: mockLogger },
+ ));
+
+ expect(mockSetEmojiContainer).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ });
+
+ it('gets emoji container successfully', async () => {
+ const mockEmojiContainer = {
+ emojis: ['😀', '🤣', '🥰'],
+ };
+ const mockGetAllEmoji = jest.fn().mockResolvedValue(mockEmojiContainer);
+ const mockSdk = {
+ getAllEmoji: mockGetAllEmoji,
+ };
+
+ renderHook(() => useGetAllEmoji(
+ { sdk: mockSdk },
+ { logger: mockLogger },
+ ));
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetAllEmoji).toHaveBeenCalled();
+ expect(mockSetEmojiContainer).toHaveBeenCalledWith(mockEmojiContainer);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useGetAllEmoji: Getting emojis succeeded.',
+ mockEmojiContainer,
+ );
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useGetChannel.spec.ts b/src/modules/Thread/context/__test__/useGetChannel.spec.ts
new file mode 100644
index 000000000..0096bd9b9
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useGetChannel.spec.ts
@@ -0,0 +1,196 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import useGetChannel from '../hooks/useGetChannel';
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ getChannelStart: mockGetChannelStart,
+ getChannelSuccess: mockGetChannelSuccess,
+ getChannelFailure: mockGetChannelFailure,
+ },
+ }),
+}));
+
+const mockGetChannelStart = jest.fn();
+const mockGetChannelSuccess = jest.fn();
+const mockGetChannelFailure = jest.fn();
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useGetChannel', () => {
+ const mockGroupChannel = {} as GroupChannel;
+ const mockGetChannel = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('doesnt call getChannel when sdkInit is false', () => {
+ const sdk = {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ };
+
+ renderHook(() => useGetChannel(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: false,
+ message: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetChannelStart).not.toHaveBeenCalled();
+ expect(mockGetChannel).not.toHaveBeenCalled();
+ });
+
+ it('doesnt call getChannel when channelUrl is empty', () => {
+ const sdk = {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ };
+
+ renderHook(() => useGetChannel(
+ {
+ channelUrl: '',
+ sdkInit: true,
+ message: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetChannelStart).not.toHaveBeenCalled();
+ expect(mockGetChannel).not.toHaveBeenCalled();
+ });
+
+ it('doesnt call getChannel when sdk.groupChannel is undefined', () => {
+ const sdk = {};
+
+ renderHook(() => useGetChannel(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ message: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetChannelStart).not.toHaveBeenCalled();
+ expect(mockGetChannel).not.toHaveBeenCalled();
+ });
+
+ it('gets channel successfully', async () => {
+ mockGetChannel.mockResolvedValueOnce(mockGroupChannel);
+ const sdk = {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ };
+
+ renderHook(() => useGetChannel(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ message: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetChannelStart).toHaveBeenCalled();
+ expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url');
+ expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel);
+ expect(mockGetChannelFailure).not.toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useInitialize: Get channel succeeded',
+ mockGroupChannel,
+ );
+ });
+
+ it('handles error when getting channel fails', async () => {
+ const mockError = new Error('Failed to get channel');
+ mockGetChannel.mockRejectedValueOnce(mockError);
+ const sdk = {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ };
+
+ renderHook(() => useGetChannel(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ message: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetChannelStart).toHaveBeenCalled();
+ expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url');
+ expect(mockGetChannelSuccess).not.toHaveBeenCalled();
+ expect(mockGetChannelFailure).toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useInitialize: Get channel failed',
+ mockError,
+ );
+ });
+
+ it('calls getChannel again when message or sdkInit changes', async () => {
+ mockGetChannel.mockResolvedValue(mockGroupChannel);
+ const sdk = {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ };
+
+ const { rerender } = renderHook(
+ ({ message, sdkInit }) => useGetChannel(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit,
+ message,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ),
+ {
+ initialProps: { message: null, sdkInit: false },
+ },
+ );
+
+ expect(mockGetChannel).not.toHaveBeenCalled();
+ rerender({ message: null, sdkInit: true });
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetChannel).toHaveBeenCalledTimes(1);
+ expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel);
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts
new file mode 100644
index 000000000..0348a7dd8
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts
@@ -0,0 +1,220 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { BaseMessage } from '@sendbird/chat/message';
+import { ChannelType } from '@sendbird/chat';
+import useGetParentMessage from '../hooks/useGetParentMessage';
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ getParentMessageStart: mockGetParentMessageStart,
+ getParentMessageSuccess: mockGetParentMessageSuccess,
+ getParentMessageFailure: mockGetParentMessageFailure,
+ },
+ }),
+}));
+
+const mockGetParentMessageStart = jest.fn();
+const mockGetParentMessageSuccess = jest.fn();
+const mockGetParentMessageFailure = jest.fn();
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useGetParentMessage', () => {
+ const mockGetMessage = jest.fn();
+ const mockParentMessage = {
+ messageId: 12345,
+ ogMetaData: { title: 'Test OG' },
+ } as unknown as BaseMessage;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('doesnt call getParentMessage when sdkInit is false', () => {
+ const sdk = {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ };
+
+ renderHook(() => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: false,
+ parentMessage: mockParentMessage,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetParentMessageStart).not.toHaveBeenCalled();
+ expect(mockGetMessage).not.toHaveBeenCalled();
+ });
+
+ it('doesnt call getParentMessage when sdk.message.getMessage is undefined', () => {
+ const sdk = {
+ message: {},
+ };
+
+ renderHook(() => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ parentMessage: mockParentMessage,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetParentMessageStart).not.toHaveBeenCalled();
+ expect(mockGetMessage).not.toHaveBeenCalled();
+ });
+
+ it('doesnt call getParentMessage when parentMessage is null', () => {
+ const sdk = {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ };
+
+ renderHook(() => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ parentMessage: null,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ expect(mockGetParentMessageStart).not.toHaveBeenCalled();
+ expect(mockGetMessage).not.toHaveBeenCalled();
+ });
+
+ it('gets parent message successfully', async () => {
+ const receivedParentMsg = { ...mockParentMessage, ogMetaData: null };
+ mockGetMessage.mockResolvedValueOnce(receivedParentMsg);
+
+ const sdk = {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ };
+
+ renderHook(() => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ parentMessage: mockParentMessage,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetParentMessageStart).toHaveBeenCalled();
+ expect(mockGetMessage).toHaveBeenCalledWith({
+ channelUrl: 'test-channel-url',
+ channelType: ChannelType.GROUP,
+ messageId: mockParentMessage.messageId,
+ includeMetaArray: true,
+ includeReactions: true,
+ includeThreadInfo: true,
+ includeParentMessageInfo: true,
+ });
+ expect(mockGetParentMessageSuccess).toHaveBeenCalledWith({
+ ...receivedParentMsg,
+ ogMetaData: mockParentMessage.ogMetaData,
+ });
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useGetParentMessage: Get parent message succeeded.',
+ mockParentMessage,
+ );
+ });
+
+ it('handles error when getting parent message fails', async () => {
+ const mockError = new Error('Failed to get parent message');
+ mockGetMessage.mockRejectedValueOnce(mockError);
+
+ const sdk = {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ };
+
+ renderHook(() => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit: true,
+ parentMessage: mockParentMessage,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ));
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetParentMessageStart).toHaveBeenCalled();
+ expect(mockGetMessage).toHaveBeenCalled();
+ expect(mockGetParentMessageSuccess).not.toHaveBeenCalled();
+ expect(mockGetParentMessageFailure).toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useGetParentMessage: Get parent message failed.',
+ mockError,
+ );
+ });
+
+ it('calls getParentMessage again when sdkInit or parentMessage.messageId changes', async () => {
+ mockGetMessage.mockResolvedValue({ ...mockParentMessage, ogMetaData: null });
+
+ const sdk = {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ };
+
+ const { rerender } = renderHook(
+ ({ sdkInit, parentMessage }) => useGetParentMessage(
+ {
+ channelUrl: 'test-channel-url',
+ sdkInit,
+ parentMessage,
+ },
+ {
+ sdk,
+ logger: mockLogger,
+ },
+ ),
+ {
+ initialProps: { sdkInit: false, parentMessage: null },
+ },
+ );
+
+ expect(mockGetMessage).not.toHaveBeenCalled();
+
+ rerender({
+ sdkInit: true,
+ parentMessage: mockParentMessage,
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(mockGetMessage).toHaveBeenCalledTimes(1);
+ expect(mockGetParentMessageSuccess).toHaveBeenCalled();
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts
new file mode 100644
index 000000000..340d55cd9
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts
@@ -0,0 +1,169 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { GroupChannel, GroupChannelHandler } from '@sendbird/chat/groupChannel';
+import { UserMessage } from '@sendbird/chat/message';
+import { User } from '@sendbird/chat';
+import useHandleChannelEvents from '../hooks/useHandleChannelEvents';
+import { waitFor } from '@testing-library/react';
+
+const mockThreadActions = {
+ onMessageReceived: jest.fn(),
+ onMessageUpdated: jest.fn(),
+ onMessageDeleted: jest.fn(),
+ onReactionUpdated: jest.fn(),
+ onUserMuted: jest.fn(),
+ onUserUnmuted: jest.fn(),
+ onUserBanned: jest.fn(),
+ onUserUnbanned: jest.fn(),
+ onUserLeft: jest.fn(),
+ onChannelFrozen: jest.fn(),
+ onChannelUnfrozen: jest.fn(),
+ onOperatorUpdated: jest.fn(),
+ onTypingStatusUpdated: jest.fn(),
+};
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: mockThreadActions,
+ }),
+}));
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useHandleChannelEvents', () => {
+ const mockUser = { userId: 'user1' } as User;
+ const mockMessage = { messageId: 1 } as UserMessage;
+ const mockReactionEvent = { messageId: 1, key: 'like' };
+
+ const createMockChannel = () => ({
+ url: 'channel-url',
+ getTypingUsers: jest.fn().mockReturnValue([mockUser]),
+ }) as unknown as GroupChannel;
+
+ const createMockSdk = (addHandler = jest.fn(), removeHandler = jest.fn()) => ({
+ groupChannel: {
+ addGroupChannelHandler: addHandler,
+ removeGroupChannelHandler: removeHandler,
+ },
+ });
+
+ const renderChannelEventsHook = ({
+ sdk = createMockSdk(),
+ currentChannel = createMockChannel(),
+ } = {}) => {
+ return renderHook(() => useHandleChannelEvents(
+ {
+ sdk,
+ currentChannel,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should add channel handler on mount', () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+
+ renderChannelEventsHook({ sdk });
+
+ expect(mockAddHandler).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(GroupChannelHandler),
+ );
+ });
+
+ it('should remove channel handler on unmount', () => {
+ const mockRemoveHandler = jest.fn();
+ const sdk = createMockSdk(jest.fn(), mockRemoveHandler);
+
+ const { unmount } = renderChannelEventsHook({ sdk });
+ unmount();
+
+ expect(mockRemoveHandler).toHaveBeenCalledWith(expect.any(String));
+ });
+
+ it('should handle message received event', () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+ const channel = createMockChannel();
+
+ renderChannelEventsHook({ sdk, currentChannel: channel });
+
+ const handler = mockAddHandler.mock.calls[0][1];
+ handler.onMessageReceived(channel, mockMessage);
+
+ expect(mockThreadActions.onMessageReceived).toHaveBeenCalledWith(
+ channel,
+ mockMessage,
+ );
+ });
+
+ it('should handle message updated event', () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+ const channel = createMockChannel();
+
+ renderChannelEventsHook({ sdk, currentChannel: channel });
+
+ const handler = mockAddHandler.mock.calls[0][1];
+ handler.onMessageUpdated(channel, mockMessage);
+
+ expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith(
+ channel,
+ mockMessage,
+ );
+ });
+
+ it('should handle reaction updated event', () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+ const channel = createMockChannel();
+
+ renderChannelEventsHook({ sdk, currentChannel: channel });
+
+ const handler = mockAddHandler.mock.calls[0][1];
+ handler.onReactionUpdated(channel, mockReactionEvent);
+
+ expect(mockThreadActions.onReactionUpdated).toHaveBeenCalledWith(
+ mockReactionEvent,
+ );
+ });
+
+ it('should handle typing status updated event', () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+ const channel = createMockChannel();
+
+ renderChannelEventsHook({ sdk, currentChannel: channel });
+
+ const handler = mockAddHandler.mock.calls[0][1];
+ handler.onTypingStatusUpdated(channel);
+
+ expect(mockThreadActions.onTypingStatusUpdated).toHaveBeenCalledWith(
+ channel,
+ [mockUser],
+ );
+ });
+
+ it('should not add handler when sdk or currentChannel is missing', async () => {
+ const mockAddHandler = jest.fn();
+ const sdk = createMockSdk(mockAddHandler);
+
+ await act(async () => {
+ renderChannelEventsHook({ sdk, currentChannel: undefined });
+ await waitFor(() => {
+ expect(mockAddHandler).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts
new file mode 100644
index 000000000..2e96b96bd
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts
@@ -0,0 +1,167 @@
+import { renderHook } from '@testing-library/react-hooks';
+import useHandleThreadPubsubEvents from '../hooks/useHandleThreadPubsubEvents';
+import { PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { SendableMessageType } from '../../../../utils';
+
+const mockThreadActions = {
+ sendMessageStart: jest.fn(),
+ sendMessageSuccess: jest.fn(),
+ sendMessageFailure: jest.fn(),
+ onFileInfoUpdated: jest.fn(),
+ onMessageUpdated: jest.fn(),
+ onMessageDeleted: jest.fn(),
+};
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: mockThreadActions,
+ }),
+}));
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockPubSub = {
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+describe('useHandleThreadPubsubEvents', () => {
+ const mockChannel = { url: 'channel-url' } as GroupChannel;
+ const mockParentMessage = { messageId: 123 } as SendableMessageType;
+ const mockMessage = {
+ parentMessageId: 123,
+ messageId: 456,
+ isMultipleFilesMessage: () => false,
+ } as SendableMessageType;
+
+ const renderPubsubEventsHook = ({
+ sdkInit = true,
+ currentChannel = mockChannel,
+ parentMessage = mockParentMessage,
+ } = {}) => {
+ return renderHook(() => useHandleThreadPubsubEvents(
+ {
+ sdkInit,
+ currentChannel,
+ parentMessage,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should subscribe to pubsub events on mount', () => {
+ renderPubsubEventsHook();
+
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.SEND_MESSAGE_START,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.SEND_USER_MESSAGE,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.SEND_MESSAGE_FAILED,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.SEND_FILE_MESSAGE,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.UPDATE_USER_MESSAGE,
+ expect.any(Function),
+ );
+ expect(mockPubSub.subscribe).toHaveBeenCalledWith(
+ PUBSUB_TOPICS.DELETE_MESSAGE,
+ expect.any(Function),
+ );
+ });
+
+ it('should unsubscribe from pubsub events on unmount', () => {
+ const { unmount } = renderPubsubEventsHook();
+ unmount();
+
+ expect(mockPubSub.subscribe).toHaveBeenCalledTimes(7);
+ });
+
+ it('should handle SEND_MESSAGE_START event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_START)[1];
+ handler({ channel: mockChannel, message: mockMessage, publishingModules: [] });
+
+ expect(mockThreadActions.sendMessageStart).toHaveBeenCalledWith(mockMessage);
+ });
+
+ it('should handle ON_FILE_INFO_UPLOADED event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED)[1];
+ handler({ response: { channelUrl: mockChannel.url }, publishingModules: [] });
+
+ expect(mockThreadActions.onFileInfoUpdated).toHaveBeenCalled();
+ });
+
+ it('should handle SEND_USER_MESSAGE event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_USER_MESSAGE)[1];
+ handler({ channel: mockChannel, message: mockMessage });
+
+ expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage);
+ });
+
+ it('should handle SEND_MESSAGE_FAILED event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_FAILED)[1];
+ handler({ channel: mockChannel, message: mockMessage, publishingModules: [] });
+
+ expect(mockThreadActions.sendMessageFailure).toHaveBeenCalledWith(mockMessage);
+ });
+
+ it('should handle SEND_FILE_MESSAGE event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_FILE_MESSAGE)[1];
+ handler({ channel: mockChannel, message: mockMessage, publishingModules: [] });
+
+ expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage);
+ });
+
+ it('should handle UPDATE_USER_MESSAGE event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.UPDATE_USER_MESSAGE)[1];
+ handler({ channel: mockChannel, message: mockMessage });
+
+ expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith(mockChannel, mockMessage);
+ });
+
+ it('should handle DELETE_MESSAGE event', () => {
+ renderPubsubEventsHook();
+
+ const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.DELETE_MESSAGE)[1];
+ handler({ channel: mockChannel, messageId: mockMessage.messageId });
+
+ expect(mockThreadActions.onMessageDeleted).toHaveBeenCalledWith(mockChannel, mockMessage.messageId);
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts
new file mode 100644
index 000000000..adf37b84c
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts
@@ -0,0 +1,277 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { FileMessage, UserMessage, MessageType, SendingStatus, MultipleFilesMessage } from '@sendbird/chat/message';
+import useResendMessageCallback from '../hooks/useResendMessageCallback';
+import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import { SendableMessageType } from '../../../../utils';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockPubSub = {
+ publish: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockResendMessageStart = jest.fn();
+const mockSendMessageSuccess = jest.fn();
+const mockSendMessageFailure = jest.fn();
+
+describe('useResendMessageCallback', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should not resend when message is not resendable', () => {
+ const mockMessage = {
+ isResendable: false,
+ } as unknown as SendableMessageType;
+
+ const { result } = renderHook(() => useResendMessageCallback(
+ {
+ currentChannel: {} as GroupChannel,
+ resendMessageStart: mockResendMessageStart,
+ sendMessageSuccess: mockSendMessageSuccess,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockMessage);
+ expect(mockResendMessageStart).not.toHaveBeenCalled();
+ expect(mockLogger.warning).toHaveBeenCalledWith(
+ 'Thread | useResendMessageCallback: Message is not resendable.',
+ mockMessage,
+ );
+ });
+
+ it('should resend user message successfully', async () => {
+ const mockUserMessage = {
+ isResendable: true,
+ messageType: MessageType.USER,
+ isUserMessage: () => true,
+ } as UserMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockUserMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockUserMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation(() => {
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ resendMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useResendMessageCallback(
+ {
+ currentChannel: mockChannel,
+ resendMessageStart: mockResendMessageStart,
+ sendMessageSuccess: mockSendMessageSuccess,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockUserMessage);
+
+ expect(mockChannel.resendMessage).toHaveBeenCalledWith(mockUserMessage);
+ expect(mockResendMessageStart).toHaveBeenCalledWith(mockUserMessage);
+ expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockUserMessage);
+ expect(mockPubSub.publish).toHaveBeenCalled();
+ });
+
+ it('should handle user message resend failure', () => {
+ const mockError = new Error('Failed to resend message');
+ const mockUserMessage = {
+ isResendable: true,
+ messageType: MessageType.USER,
+ isUserMessage: () => true,
+ sendingStatus: SendingStatus.FAILED,
+ } as UserMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockUserMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation((cb) => {
+ cb(mockError);
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ resendMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useResendMessageCallback(
+ {
+ currentChannel: mockChannel,
+ resendMessageStart: mockResendMessageStart,
+ sendMessageSuccess: mockSendMessageSuccess,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockUserMessage);
+
+ expect(mockSendMessageFailure).toHaveBeenCalledWith(mockUserMessage);
+ expect(mockLogger.warning).toHaveBeenCalled();
+ });
+
+ it('should resend file message successfully', () => {
+ const mockFileMessage = {
+ isResendable: true,
+ isFileMessage: () => true,
+ } as FileMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockFileMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockFileMessage);
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ resendMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useResendMessageCallback(
+ {
+ currentChannel: mockChannel,
+ resendMessageStart: mockResendMessageStart,
+ sendMessageSuccess: mockSendMessageSuccess,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFileMessage);
+
+ expect(mockResendMessageStart).toHaveBeenCalledWith(mockFileMessage);
+ expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockFileMessage);
+ expect(mockPubSub.publish).toHaveBeenCalled();
+ });
+
+ it('should resend multiple files message successfully', () => {
+ const mockMultipleFilesMessage = {
+ isResendable: true,
+ isMultipleFilesMessage: () => true,
+ } as MultipleFilesMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ onFileUploaded: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockMultipleFilesMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockMultipleFilesMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFileUploaded.mockImplementation((cb) => {
+ cb('requestId', 0, { url: 'test-url' }, null);
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ resendMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useResendMessageCallback(
+ {
+ currentChannel: mockChannel,
+ resendMessageStart: mockResendMessageStart,
+ sendMessageSuccess: mockSendMessageSuccess,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockMultipleFilesMessage);
+
+ expect(mockResendMessageStart).toHaveBeenCalledWith(mockMultipleFilesMessage);
+ expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockMultipleFilesMessage);
+ expect(mockPubSub.publish).toHaveBeenCalledTimes(2); // onFileUploaded and onSucceeded
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts
new file mode 100644
index 000000000..167f6f403
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts
@@ -0,0 +1,214 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { FileMessage, SendingStatus } from '@sendbird/chat/message';
+import useSendFileMessage from '../hooks/useSendFileMessage';
+import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import { SendableMessageType } from '../../../../utils';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockPubSub = {
+ publish: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockSendMessageStart = jest.fn();
+const mockSendMessageFailure = jest.fn();
+
+describe('useSendFileMessage', () => {
+ const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' });
+ const mockQuoteMessage = {
+ messageId: 12345,
+ } as unknown as SendableMessageType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // URL.createObjectURL mock
+ global.URL.createObjectURL = jest.fn(() => 'mock-url');
+ });
+
+ it('doesnt send file message when currentChannel is null', async () => {
+ const { result } = renderHook(() => useSendFileMessage(
+ {
+ currentChannel: null,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+ await act(async () => {
+ await result.current(mockFile);
+ expect(mockSendMessageStart).not.toHaveBeenCalled();
+ });
+ });
+
+ it('sends file message successfully', async () => {
+ const mockSuccessMessage = {
+ messageId: 67890,
+ isUserMessage: false,
+ isFileMessage: true,
+ isAdminMessage: false,
+ isMultipleFilesMessage: false,
+ } as unknown as FileMessage;
+
+ const mockPendingMessage = {
+ ...mockSuccessMessage,
+ sendingStatus: SendingStatus.PENDING,
+ };
+
+ // 체이닝 구조 개선
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockSuccessMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation(() => {
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendFileMessage(
+ {
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ const response = await result.current(mockFile, mockQuoteMessage);
+
+ expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({
+ file: mockFile,
+ isReplyToChannel: true,
+ parentMessageId: mockQuoteMessage.messageId,
+ });
+ expect(mockSendMessageStart).toHaveBeenCalledWith({
+ ...mockPendingMessage,
+ url: 'mock-url',
+ });
+ expect(mockPubSub.publish).toHaveBeenCalled();
+ expect(response).toBe(mockSuccessMessage);
+ });
+
+ it('handles error when sending file message fails', async () => {
+ const mockError = new Error('Failed to send file message');
+ const mockPendingMessage = {
+ messageId: 67890,
+ sendingStatus: SendingStatus.PENDING,
+ } as FileMessage;
+
+ const mockSendFileMessagePromise = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ mockSendFileMessagePromise.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return mockSendFileMessagePromise;
+ });
+
+ mockSendFileMessagePromise.onFailed.mockImplementation((cb) => {
+ cb(mockError, mockPendingMessage);
+ return mockSendFileMessagePromise;
+ });
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendFileMessage(
+ {
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ await expect(result.current(mockFile)).rejects.toBe(mockError);
+ expect(mockSendMessageFailure).toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useSendFileMessageCallback: Sending file message failed.',
+ expect.any(Object),
+ );
+ });
+
+ it('uses onBeforeSendFileMessage callback', async () => {
+ const mockCustomParams = {
+ file: mockFile,
+ customField: 'test',
+ };
+ const mockOnBeforeSendFileMessage = jest.fn().mockReturnValue(mockCustomParams);
+
+ const mockSendFileMessagePromise = {
+ onPending: jest.fn().mockReturnThis(),
+ onSucceeded: jest.fn().mockReturnThis(),
+ onFailed: jest.fn().mockReturnThis(),
+ };
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendFileMessage(
+ {
+ currentChannel: mockChannel,
+ onBeforeSendFileMessage: mockOnBeforeSendFileMessage,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFile, mockQuoteMessage);
+
+ expect(mockOnBeforeSendFileMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage);
+ expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams);
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts
new file mode 100644
index 000000000..4e13d844e
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts
@@ -0,0 +1,250 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { UserMessage, SendingStatus } from '@sendbird/chat/message';
+import { User } from '@sendbird/chat';
+import useSendUserMessageCallback from '../hooks/useSendUserMessageCallback';
+import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import { SendableMessageType } from '../../../../utils';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockPubSub = {
+ publish: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockSendMessageStart = jest.fn();
+const mockSendMessageFailure = jest.fn();
+
+describe('useSendUserMessageCallback', () => {
+ const mockMessage = 'Hello, world!';
+ const mockQuoteMessage = {
+ messageId: 12345,
+ } as unknown as SendableMessageType;
+ const mockMentionedUsers = [{ userId: 'user1' }] as User[];
+ const mockMentionTemplate = '@{user1}';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should not send message when currentChannel is null', async () => {
+ const { result } = renderHook(() => useSendUserMessageCallback(
+ {
+ isMentionEnabled: false,
+ currentChannel: null,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current({ message: mockMessage });
+ expect(mockSendMessageStart).not.toHaveBeenCalled();
+ });
+
+ it('should send message successfully', async () => {
+ const mockSuccessMessage = {
+ messageId: 67890,
+ message: mockMessage,
+ } as UserMessage;
+
+ const mockPendingMessage = {
+ ...mockSuccessMessage,
+ sendingStatus: SendingStatus.PENDING,
+ };
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockSuccessMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation(() => {
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ sendUserMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendUserMessageCallback(
+ {
+ isMentionEnabled: false,
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current({ message: mockMessage });
+
+ expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({
+ message: mockMessage,
+ });
+ expect(mockSendMessageStart).toHaveBeenCalledWith(mockPendingMessage);
+ expect(mockPubSub.publish).toHaveBeenCalled();
+ });
+
+ it('should handle message sending failure', async () => {
+ const mockError = new Error('Failed to send message');
+ const mockPendingMessage = {
+ messageId: 67890,
+ sendingStatus: SendingStatus.PENDING,
+ } as UserMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation((cb) => {
+ cb(mockError, mockPendingMessage);
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ sendUserMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendUserMessageCallback(
+ {
+ isMentionEnabled: false,
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current({ message: mockMessage });
+
+ expect(mockSendMessageFailure).toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useSendUserMessageCallback: Sending user message failed.',
+ expect.any(Object),
+ );
+ });
+
+ it('should handle mentions when mention is enabled', () => {
+ const createMockPromise = () => ({
+ onPending: jest.fn().mockReturnThis(),
+ onSucceeded: jest.fn().mockReturnThis(),
+ onFailed: jest.fn().mockReturnThis(),
+ });
+
+ const mockChannel = {
+ sendUserMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendUserMessageCallback(
+ {
+ isMentionEnabled: true,
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current({
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ mentionTemplate: mockMentionTemplate,
+ });
+
+ expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ mentionedMessageTemplate: mockMentionTemplate,
+ });
+ });
+
+ it('should use onBeforeSendUserMessage callback when provided', () => {
+ const mockCustomParams = {
+ message: mockMessage,
+ customField: 'test',
+ };
+ const mockOnBeforeSendUserMessage = jest.fn().mockReturnValue(mockCustomParams);
+
+ const createMockPromise = () => ({
+ onPending: jest.fn().mockReturnThis(),
+ onSucceeded: jest.fn().mockReturnThis(),
+ onFailed: jest.fn().mockReturnThis(),
+ });
+
+ const mockChannel = {
+ sendUserMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendUserMessageCallback(
+ {
+ isMentionEnabled: false,
+ currentChannel: mockChannel,
+ onBeforeSendUserMessage: mockOnBeforeSendUserMessage,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current({ message: mockMessage, quoteMessage: mockQuoteMessage });
+
+ expect(mockOnBeforeSendUserMessage).toHaveBeenCalledWith(mockMessage, mockQuoteMessage);
+ expect(mockChannel.sendUserMessage).toHaveBeenCalledWith(mockCustomParams);
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts
new file mode 100644
index 000000000..4327fc156
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts
@@ -0,0 +1,236 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { FileMessage, SendingStatus, MessageMetaArray } from '@sendbird/chat/message';
+import useSendVoiceMessageCallback from '../hooks/useSendVoiceMessageCallback';
+import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import { SendableMessageType } from '../../../../utils';
+import {
+ META_ARRAY_MESSAGE_TYPE_KEY,
+ META_ARRAY_MESSAGE_TYPE_VALUE__VOICE,
+ META_ARRAY_VOICE_DURATION_KEY,
+ VOICE_MESSAGE_FILE_NAME,
+ VOICE_MESSAGE_MIME_TYPE,
+} from '../../../../utils/consts';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockPubSub = {
+ publish: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockSendMessageStart = jest.fn();
+const mockSendMessageFailure = jest.fn();
+
+describe('useSendVoiceMessageCallback', () => {
+ const mockFile = new File(['test'], 'test.mp3', { type: 'audio/mp3' });
+ const mockDuration = 10;
+ const mockQuoteMessage = {
+ messageId: 12345,
+ } as unknown as SendableMessageType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ global.URL.createObjectURL = jest.fn(() => 'mock-url');
+ });
+
+ it('should not send voice message when currentChannel is null', () => {
+ const { result } = renderHook(() => useSendVoiceMessageCallback(
+ {
+ currentChannel: null,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFile, mockDuration, mockQuoteMessage);
+ expect(mockSendMessageStart).not.toHaveBeenCalled();
+ });
+
+ it('should send voice message successfully', async () => {
+ const mockSuccessMessage = {
+ messageId: 67890,
+ isUserMessage: false,
+ isFileMessage: true,
+ isAdminMessage: false,
+ isMultipleFilesMessage: false,
+ } as unknown as FileMessage;
+
+ const mockPendingMessage = {
+ ...mockSuccessMessage,
+ sendingStatus: SendingStatus.PENDING,
+ };
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onSucceeded.mockImplementation((cb) => {
+ cb(mockSuccessMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation(() => {
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendVoiceMessageCallback(
+ {
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFile, mockDuration, mockQuoteMessage);
+
+ expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({
+ file: mockFile,
+ fileName: VOICE_MESSAGE_FILE_NAME,
+ mimeType: VOICE_MESSAGE_MIME_TYPE,
+ metaArrays: [
+ new MessageMetaArray({
+ key: META_ARRAY_VOICE_DURATION_KEY,
+ value: [`${mockDuration}`],
+ }),
+ new MessageMetaArray({
+ key: META_ARRAY_MESSAGE_TYPE_KEY,
+ value: [META_ARRAY_MESSAGE_TYPE_VALUE__VOICE],
+ }),
+ ],
+ isReplyToChannel: true,
+ parentMessageId: mockQuoteMessage.messageId,
+ });
+ expect(mockSendMessageStart).toHaveBeenCalledWith({
+ ...mockPendingMessage,
+ url: 'mock-url',
+ sendingStatus: SendingStatus.PENDING,
+ });
+ expect(mockPubSub.publish).toHaveBeenCalled();
+ });
+
+ it('should handle voice message sending failure', async () => {
+ const mockError = new Error('Failed to send voice message');
+ const mockPendingMessage = {
+ messageId: 67890,
+ sendingStatus: SendingStatus.PENDING,
+ } as FileMessage;
+
+ const createMockPromise = () => {
+ const chainMethods = {
+ onPending: jest.fn(),
+ onSucceeded: jest.fn(),
+ onFailed: jest.fn(),
+ };
+
+ chainMethods.onPending.mockImplementation((cb) => {
+ cb(mockPendingMessage);
+ return chainMethods;
+ });
+
+ chainMethods.onFailed.mockImplementation((cb) => {
+ cb(mockError, mockPendingMessage);
+ return chainMethods;
+ });
+
+ return chainMethods;
+ };
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendVoiceMessageCallback(
+ {
+ currentChannel: mockChannel,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFile, mockDuration, mockQuoteMessage);
+
+ expect(mockSendMessageFailure).toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useSendVoiceMessageCallback: Sending voice message failed.',
+ expect.any(Object),
+ );
+ });
+
+ it('should use onBeforeSendVoiceMessage callback when provided', () => {
+ const mockCustomParams = {
+ file: mockFile,
+ customField: 'test',
+ };
+ const mockOnBeforeSendVoiceMessage = jest.fn().mockReturnValue(mockCustomParams);
+
+ const createMockPromise = () => ({
+ onPending: jest.fn().mockReturnThis(),
+ onSucceeded: jest.fn().mockReturnThis(),
+ onFailed: jest.fn().mockReturnThis(),
+ });
+
+ const mockChannel = {
+ sendFileMessage: jest.fn().mockReturnValue(createMockPromise()),
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useSendVoiceMessageCallback(
+ {
+ currentChannel: mockChannel,
+ onBeforeSendVoiceMessage: mockOnBeforeSendVoiceMessage,
+ sendMessageStart: mockSendMessageStart,
+ sendMessageFailure: mockSendMessageFailure,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+
+ result.current(mockFile, mockDuration, mockQuoteMessage);
+
+ expect(mockOnBeforeSendVoiceMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage);
+ expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams);
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useThread.spec.tsx b/src/modules/Thread/context/__test__/useThread.spec.tsx
new file mode 100644
index 000000000..9fb264c53
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useThread.spec.tsx
@@ -0,0 +1,761 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import useThread from '../useThread';
+import { act, waitFor } from '@testing-library/react';
+import { ThreadProvider } from '../ThreadProvider';
+import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types';
+import { PREV_THREADS_FETCH_SIZE } from '../../consts';
+
+const mockApplyReactionEvent = jest.fn();
+
+const mockChannel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+};
+
+const mockNewMessage = {
+ messageId: 42,
+ message: 'new message',
+};
+
+const mockParentMessage = {
+ messageId: 100,
+ parentMessageId: 0,
+ parentMessage: null,
+ message: 'parent message',
+ reqId: 100,
+ applyReactionEvent: mockApplyReactionEvent,
+};
+
+const mockGetChannel = jest.fn().mockResolvedValue(mockChannel);
+const mockGetMessage = jest.fn().mockResolvedValue(mockParentMessage);
+const mockPubSub = { publish: jest.fn(), subscribe: jest.fn() };
+
+jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ stores: {
+ sdkStore: {
+ sdk: {
+ message: {
+ getMessage: mockGetMessage,
+ },
+ groupChannel: {
+ getChannel: mockGetChannel,
+ },
+ },
+ initialized: true,
+ },
+ userStore: { user: { userId: 'test-user-id' } },
+ },
+ config: {
+ logger: console,
+ pubSub: mockPubSub,
+ groupChannel: {
+ enableMention: true,
+ enableReactions: true,
+ },
+ },
+ },
+ })),
+}));
+
+describe('useThread', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws an error if used outside of ThreadProvider', () => {
+ const { result } = renderHook(() => useThread());
+ expect(result.error).toEqual(new Error('useThread must be used within a ThreadProvider'));
+ });
+
+ it('handles sendMessageStart action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { sendMessageStart } = result.current.actions;
+
+ const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.localThreadMessages).toContain(mockMessage);
+ });
+ });
+
+ it('handles sendMessageSuccess action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { sendMessageStart, sendMessageSuccess } = result.current.actions;
+
+ const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 };
+ const mockMessage2 = { messageId: 3, message: 'Test message', reqId: 3 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ sendMessageSuccess(mockMessage);
+ sendMessageStart(mockMessage2);
+ sendMessageSuccess(mockMessage2);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.allThreadMessages).toContain(mockMessage);
+ expect(result.current.state.allThreadMessages).toContain(mockMessage2);
+ });
+ });
+
+ it('handles sendMessageFailure action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { sendMessageStart, sendMessageFailure } = result.current.actions;
+
+ const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ sendMessageFailure(mockMessage);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.localThreadMessages).toContain(mockMessage);
+ });
+ });
+
+ it('handles resendMessageStart action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { sendMessageStart, resendMessageStart } = result.current.actions;
+
+ const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ resendMessageStart(mockMessage);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.localThreadMessages).toContain(mockMessage);
+ });
+ });
+
+ it('handles onMessageUpdated action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { sendMessageStart, sendMessageSuccess, onMessageUpdated } = result.current.actions;
+
+ const otherChannel = {
+ url: 'test-channel2',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const channel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ sendMessageSuccess(mockMessage);
+ onMessageUpdated(otherChannel, mockMessage);
+ onMessageUpdated(channel, mockMessage);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.allThreadMessages).toContain(mockMessage);
+ });
+ });
+
+ it('handles onMessageDeleted action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { sendMessageStart, sendMessageSuccess, onMessageDeleted } = result.current.actions;
+
+ const otherChannel = {
+ url: 'test-channel2',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const channel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ sendMessageSuccess(mockMessage);
+ onMessageDeleted(otherChannel, mockMessage.messageId);
+ onMessageDeleted(channel, mockMessage.messageId);
+ onMessageDeleted(channel, 100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.parentMessage).toBe(null);
+ expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL);
+ expect(result.current.state.allThreadMessages).toBeEmpty();
+ });
+ });
+
+ it('handles onMessageDeletedByReqId action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { sendMessageStart, onMessageDeletedByReqId } = result.current.actions;
+
+ const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ onMessageDeletedByReqId(mockMessage.reqId);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.localThreadMessages).toBeEmpty();
+ });
+ });
+
+ it('handles initializeThreadListStart action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { initializeThreadListStart } = result.current.actions;
+
+ await act(() => {
+ initializeThreadListStart();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING);
+ });
+ });
+
+ it('handles initializeThreadListSuccess action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { initializeThreadListStart, initializeThreadListSuccess } = result.current.actions;
+
+ await act(() => {
+ initializeThreadListStart();
+ initializeThreadListSuccess(mockParentMessage, mockParentMessage, []);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.threadListState).toBe(ThreadListStateTypes.INITIALIZED);
+ });
+ });
+
+ it('handles initializeThreadListFailure action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { initializeThreadListStart, initializeThreadListFailure } = result.current.actions;
+
+ await act(() => {
+ initializeThreadListStart();
+ initializeThreadListFailure();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING);
+ expect(result.current.state.allThreadMessages).toBeEmpty();
+ });
+ });
+
+ it('handles getPrevMessagesSuccess action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { getPrevMessagesStart, getPrevMessagesSuccess } = result.current.actions;
+
+ await act(() => {
+ getPrevMessagesStart();
+ getPrevMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => {
+ return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 };
+ }));
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.hasMorePrev).toBe(true);
+ expect(result.current.state.allThreadMessages).toHaveLength(30);
+ });
+ });
+
+ it('handles getPrevMessagesFailure action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { getPrevMessagesStart, getPrevMessagesFailure } = result.current.actions;
+
+ await act(() => {
+ getPrevMessagesStart();
+ getPrevMessagesFailure();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.hasMorePrev).toBe(false);
+ });
+ });
+
+ it('handles getNextMessagesSuccess action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { getNextMessagesStart, getNextMessagesSuccess } = result.current.actions;
+
+ await act(() => {
+ getNextMessagesStart();
+ getNextMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => {
+ return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 };
+ }));
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.hasMoreNext).toBe(true);
+ expect(result.current.state.allThreadMessages).toHaveLength(30);
+ });
+ });
+
+ it('handles getNextMessagesFailure action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { getNextMessagesStart, getNextMessagesFailure } = result.current.actions;
+
+ await act(() => {
+ getNextMessagesStart();
+ getNextMessagesFailure();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.hasMoreNext).toBe(false);
+ });
+ });
+
+ it('handles setEmojiContainer action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useThread(), { wrapper });
+ const { setEmojiContainer } = result.current.actions;
+
+ const emojiContainer = {
+ emojiHash: 'test-hash',
+ emojiCategories: [{
+ id: 'test-category-id',
+ name: 'test-category',
+ url: 'test-category-url',
+ emojis: [],
+ }],
+ };
+
+ await act(() => {
+ setEmojiContainer(emojiContainer);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.emojiContainer).toBe(emojiContainer);
+ });
+ });
+
+ it('handles onMessageReceived action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onMessageReceived } = result.current.actions;
+
+ const otherChannel = {
+ url: 'test-channel2',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const channel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+ updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage),
+ };
+ const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage };
+
+ await act(() => {
+ onMessageReceived(otherChannel, mockMessage);
+ onMessageReceived(channel, mockMessage);
+ onMessageReceived(channel, mockMessage);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.allThreadMessages).toContain(mockMessage);
+ });
+ });
+
+ it('handles onReactionUpdated action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { sendMessageStart, sendMessageSuccess, onReactionUpdated } = result.current.actions;
+
+ const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ sendMessageSuccess(mockMessage);
+ onReactionUpdated({
+ messageId: mockParentMessage.messageId,
+ userId: 'test-user-id',
+ key: '1',
+ operation: 'ADD',
+ updatedAt: 0,
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockApplyReactionEvent).toHaveBeenCalled();
+ });
+ });
+
+ it('handles onUserMuted action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onUserMuted } = result.current.actions;
+
+ await act(() => {
+ onUserMuted(mockChannel, { userId: 'other-user-id' });
+ onUserMuted(mockChannel, { userId: 'test-user-id' });
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.isMuted).toBe(true);
+ });
+ });
+
+ it('handles onUserUnmuted action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onUserUnmuted } = result.current.actions;
+
+ await act(() => {
+ onUserUnmuted(mockChannel, { userId: 'other-user-id' });
+ onUserUnmuted(mockChannel, { userId: 'test-user-id' });
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.isMuted).toBe(false);
+ });
+ });
+
+ it('handles onUserBanned action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onUserBanned } = result.current.actions;
+
+ await act(() => {
+ onUserBanned();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL);
+ expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL);
+ expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL);
+ });
+ });
+
+ it('handles onUserUnbanned action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onUserUnbanned } = result.current.actions;
+
+ await act(() => {
+ onUserUnbanned();
+ });
+ });
+
+ it('handles onUserLeft action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onUserLeft } = result.current.actions;
+
+ await act(() => {
+ onUserLeft();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL);
+ expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL);
+ expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL);
+ });
+ });
+
+ it('handles onChannelFrozen action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onChannelFrozen } = result.current.actions;
+
+ await act(() => {
+ onChannelFrozen();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.isChannelFrozen).toBe(true);
+ });
+ });
+
+ it('handles onChannelFrozen action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onChannelFrozen, onChannelUnfrozen } = result.current.actions;
+
+ await act(() => {
+ onChannelFrozen();
+ onChannelUnfrozen();
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.isChannelFrozen).toBe(false);
+ });
+ });
+
+ it('handles onOperatorUpdated action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onOperatorUpdated } = result.current.actions;
+
+ const newMockChannel = {
+ url: 'test-channel',
+ };
+ await act(() => {
+ onOperatorUpdated(newMockChannel);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).toBe(newMockChannel);
+ });
+ });
+
+ it('handles onTypingStatusUpdated action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { onTypingStatusUpdated } = result.current.actions;
+ const mockMember = { userId: '1', nickname: 'user1' };
+
+ await act(() => {
+ onTypingStatusUpdated(mockChannel, [mockMember]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.state.typingMembers).toContain(mockMember);
+ });
+ });
+
+ it('handles onFileInfoUpdated action correctly', async () => {
+ const wrapper = ({ children }) => (
+ {children}
+ );
+
+ let result;
+ await act(async () => {
+ result = renderHook(() => useThread(), { wrapper }).result;
+
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).not.toBe(undefined);
+ });
+ });
+ const { sendMessageStart, onFileInfoUpdated } = result.current.actions;
+ const mockMessage = {
+ messageId: 2,
+ message: 'Test message',
+ reqId: 2,
+ parentMessage: mockParentMessage,
+ messageParams: {
+ fileInfoList: [],
+ },
+ };
+ const newFileInfo = { name: 'new-file-info' };
+
+ await act(() => {
+ sendMessageStart(mockMessage);
+ onFileInfoUpdated({
+ channelUrl: 'test-channel',
+ requestId: mockMessage.reqId,
+ index: 0,
+ uploadableFileInfo: newFileInfo,
+ });
+ });
+
+ await waitFor(() => {
+ console.log(result.current.state.localThreadMessages[0]);
+ expect(result.current.state.localThreadMessages[0].messageParams.fileInfoList).toContain(newFileInfo);
+ });
+ });
+
+});
diff --git a/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts
new file mode 100644
index 000000000..564276d56
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts
@@ -0,0 +1,161 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { BaseMessage } from '@sendbird/chat/message';
+import { useThreadFetchers } from '../hooks/useThreadFetchers';
+import { ThreadListStateTypes } from '../../types';
+import { SendableMessageType } from '../../../../utils';
+
+jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: () => ({
+ state: {
+ stores: {
+ sdkStore: {
+ initialized: true,
+ },
+ },
+ },
+ }),
+}));
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useThreadFetchers', () => {
+ const mockParentMessage = {
+ messageId: 12345,
+ getThreadedMessagesByTimestamp: jest.fn(),
+ } as unknown as SendableMessageType;
+
+ const mockAnchorMessage = {
+ messageId: 67890,
+ createdAt: 1234567890,
+ } as unknown as SendableMessageType;
+
+ const mockThreadedMessages = [
+ { messageId: 1 },
+ { messageId: 2 },
+ ] as BaseMessage[];
+
+ const createMockCallbacks = () => ({
+ initializeThreadListStart: jest.fn(),
+ initializeThreadListSuccess: jest.fn(),
+ initializeThreadListFailure: jest.fn(),
+ getPrevMessagesStart: jest.fn(),
+ getPrevMessagesSuccess: jest.fn(),
+ getPrevMessagesFailure: jest.fn(),
+ getNextMessagesStart: jest.fn(),
+ getNextMessagesSuccess: jest.fn(),
+ getNextMessagesFailure: jest.fn(),
+ });
+
+ const renderThreadFetchersHook = ({
+ threadListState = ThreadListStateTypes.INITIALIZED,
+ oldestMessageTimeStamp = 0,
+ latestMessageTimeStamp = 0,
+ callbacks = createMockCallbacks(),
+ } = {}) => {
+ return renderHook(() => useThreadFetchers({
+ anchorMessage: mockAnchorMessage,
+ parentMessage: mockParentMessage,
+ isReactionEnabled: true,
+ logger: mockLogger,
+ threadListState,
+ oldestMessageTimeStamp,
+ latestMessageTimeStamp,
+ ...callbacks,
+ }));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should initialize thread list successfully', async () => {
+ (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({
+ threadedMessages: mockThreadedMessages,
+ parentMessage: mockParentMessage,
+ });
+
+ const callbacks = createMockCallbacks();
+ const { result } = renderThreadFetchersHook({ callbacks });
+
+ await result.current.initializeThreadFetcher();
+
+ expect(callbacks.initializeThreadListStart).toHaveBeenCalled();
+ expect(callbacks.initializeThreadListSuccess).toHaveBeenCalledWith(
+ mockParentMessage,
+ mockAnchorMessage,
+ mockThreadedMessages,
+ );
+ });
+
+ it('should handle initialization failure', async () => {
+ const mockError = new Error('Failed to initialize');
+ (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockRejectedValue(mockError);
+
+ const callbacks = createMockCallbacks();
+ const { result } = renderThreadFetchersHook({ callbacks });
+
+ await result.current.initializeThreadFetcher();
+
+ expect(callbacks.initializeThreadListStart).toHaveBeenCalled();
+ expect(callbacks.initializeThreadListFailure).toHaveBeenCalled();
+ });
+
+ it('should fetch previous messages successfully', async () => {
+ (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({
+ threadedMessages: mockThreadedMessages,
+ parentMessage: mockParentMessage,
+ });
+
+ const callbacks = createMockCallbacks();
+ const { result } = renderThreadFetchersHook({
+ oldestMessageTimeStamp: 1000,
+ latestMessageTimeStamp: 2000,
+ callbacks,
+ });
+
+ await result.current.fetchPrevThreads();
+
+ expect(callbacks.getPrevMessagesStart).toHaveBeenCalled();
+ expect(callbacks.getPrevMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages);
+ });
+
+ it('should fetch next messages successfully', async () => {
+ (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({
+ threadedMessages: mockThreadedMessages,
+ parentMessage: mockParentMessage,
+ });
+
+ const callbacks = createMockCallbacks();
+ const { result } = renderThreadFetchersHook({
+ oldestMessageTimeStamp: 1000,
+ latestMessageTimeStamp: 2000,
+ callbacks,
+ });
+
+ await result.current.fetchNextThreads();
+
+ expect(callbacks.getNextMessagesStart).toHaveBeenCalled();
+ expect(callbacks.getNextMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages);
+ });
+
+ it('should not fetch when threadListState is not INITIALIZED', async () => {
+ const callbacks = createMockCallbacks();
+ const { result } = renderThreadFetchersHook({
+ threadListState: ThreadListStateTypes.LOADING,
+ oldestMessageTimeStamp: 1000,
+ latestMessageTimeStamp: 2000,
+ callbacks,
+ });
+
+ await result.current.fetchPrevThreads();
+ await result.current.fetchNextThreads();
+
+ expect(callbacks.getPrevMessagesStart).not.toHaveBeenCalled();
+ expect(callbacks.getNextMessagesStart).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts
new file mode 100644
index 000000000..ad8701ede
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts
@@ -0,0 +1,148 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { BaseMessage } from '@sendbird/chat/message';
+import useToggleReactionCallback from '../hooks/useToggleReactionsCallback';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useToggleReactionCallback', () => {
+ const mockMessage = {
+ messageId: 12345,
+ } as BaseMessage;
+ const REACTION_KEY = 'thumbs_up';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should not toggle reaction when currentChannel is null', () => {
+ const { result } = renderHook(() => useToggleReactionCallback(
+ {
+ currentChannel: null,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+
+ result.current(mockMessage, REACTION_KEY, true);
+ expect(mockLogger.warning).not.toHaveBeenCalled();
+ });
+
+ it('should delete reaction when isReacted is true', async () => {
+ const mockDeleteReaction = jest.fn().mockResolvedValue({ success: true });
+ const mockChannel = {
+ deleteReaction: mockDeleteReaction,
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useToggleReactionCallback(
+ {
+ currentChannel: mockChannel,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+
+ await result.current(mockMessage, REACTION_KEY, true);
+
+ expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useToggleReactionsCallback: Delete reaction succeeded.',
+ { success: true },
+ );
+ });
+
+ it('should handle delete reaction failure', async () => {
+ const mockError = new Error('Failed to delete reaction');
+ const mockDeleteReaction = jest.fn().mockRejectedValue(mockError);
+ const mockChannel = {
+ deleteReaction: mockDeleteReaction,
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useToggleReactionCallback(
+ {
+ currentChannel: mockChannel,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+
+ result.current(mockMessage, REACTION_KEY, true);
+
+ await new Promise(process.nextTick);
+
+ expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY);
+ expect(mockLogger.warning).toHaveBeenCalledWith(
+ 'Thread | useToggleReactionsCallback: Delete reaction failed.',
+ mockError,
+ );
+ });
+
+ it('should add reaction when isReacted is false', async () => {
+ const mockAddReaction = jest.fn().mockResolvedValue({ success: true });
+ const mockChannel = {
+ addReaction: mockAddReaction,
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useToggleReactionCallback(
+ {
+ currentChannel: mockChannel,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+
+ await result.current(mockMessage, REACTION_KEY, false);
+
+ expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY);
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ 'Thread | useToggleReactionsCallback: Add reaction succeeded.',
+ { success: true },
+ );
+ });
+
+ it('should handle add reaction failure', async () => {
+ const mockError = new Error('Failed to add reaction');
+ const mockAddReaction = jest.fn().mockRejectedValue(mockError);
+ const mockChannel = {
+ addReaction: mockAddReaction,
+ } as unknown as GroupChannel;
+
+ const { result } = renderHook(() => useToggleReactionCallback(
+ {
+ currentChannel: mockChannel,
+ },
+ {
+ logger: mockLogger,
+ },
+ ));
+
+ result.current(mockMessage, REACTION_KEY, false);
+
+ await new Promise(process.nextTick);
+
+ expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY);
+ expect(mockLogger.warning).toHaveBeenCalledWith(
+ 'Thread | useToggleReactionsCallback: Add reaction failed.',
+ mockError,
+ );
+ });
+});
diff --git a/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts
new file mode 100644
index 000000000..ba6947499
--- /dev/null
+++ b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts
@@ -0,0 +1,169 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { UserMessage } from '@sendbird/chat/message';
+import { User } from '@sendbird/chat';
+import useUpdateMessageCallback from '../hooks/useUpdateMessageCallback';
+import { PublishingModuleType } from '../../../internalInterfaces';
+import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+
+const mockSetEmojiContainer = jest.fn();
+
+jest.mock('../useThread', () => ({
+ __esModule: true,
+ default: () => ({
+ actions: {
+ setEmojiContainer: mockSetEmojiContainer,
+ },
+ }),
+}));
+
+const mockPubSub = {
+ publish: jest.fn(),
+} as unknown as SBUGlobalPubSub;
+
+const mockLogger = {
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+};
+
+describe('useUpdateMessageCallback', () => {
+ const mockMessageId = 12345;
+ const mockMessage = 'Updated message content';
+ const mockMentionedUsers = [{ userId: 'user1' }] as User[];
+ const mockMentionTemplate = '@{user1}';
+
+ const createMockChannel = (updateUserMessage = jest.fn()) => ({
+ updateUserMessage,
+ }) as unknown as GroupChannel;
+ const createMockCallbacks = () => ({
+ onMessageUpdated: jest.fn(),
+ });
+
+ const renderUpdateMessageCallbackHook = ({
+ currentChannel = undefined,
+ isMentionEnabled = false,
+ callbacks = createMockCallbacks(),
+ } = {}) => {
+ return renderHook(() => useUpdateMessageCallback(
+ {
+ currentChannel: currentChannel ?? null,
+ isMentionEnabled,
+ onMessageUpdated: callbacks.onMessageUpdated,
+ },
+ {
+ logger: mockLogger,
+ pubSub: mockPubSub,
+ },
+ ));
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should update message successfully', async () => {
+ const updatedMessage = {
+ messageId: mockMessageId,
+ message: mockMessage,
+ } as UserMessage;
+
+ const mockUpdateUserMessage = jest.fn().mockResolvedValue(updatedMessage);
+ const mockChannel = createMockChannel(mockUpdateUserMessage);
+ const callbacks = createMockCallbacks();
+
+ const { result } = renderUpdateMessageCallbackHook({
+ currentChannel: mockChannel,
+ callbacks,
+ });
+
+ await result.current({
+ messageId: mockMessageId,
+ message: mockMessage,
+ });
+
+ expect(mockUpdateUserMessage).toHaveBeenCalledWith(
+ mockMessageId,
+ expect.objectContaining({
+ message: mockMessage,
+ }),
+ );
+ expect(callbacks.onMessageUpdated).toHaveBeenCalledWith(mockChannel, updatedMessage);
+ expect(mockPubSub.publish).toHaveBeenCalledWith(
+ 'UPDATE_USER_MESSAGE',
+ expect.objectContaining({
+ fromSelector: true,
+ channel: mockChannel,
+ message: updatedMessage,
+ publishingModules: [PublishingModuleType.THREAD],
+ }),
+ );
+ });
+
+ it('should include mention data when mention is enabled', async () => {
+ const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage);
+ const mockChannel = createMockChannel(mockUpdateUserMessage);
+
+ const { result } = renderUpdateMessageCallbackHook({
+ currentChannel: mockChannel,
+ isMentionEnabled: true,
+ });
+
+ await result.current({
+ messageId: mockMessageId,
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ mentionTemplate: mockMentionTemplate,
+ });
+
+ expect(mockUpdateUserMessage).toHaveBeenCalledWith(
+ mockMessageId,
+ expect.objectContaining({
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ mentionedMessageTemplate: mockMentionTemplate,
+ }),
+ );
+ });
+
+ it('should use message as mention template when template is not provided', async () => {
+ const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage);
+ const mockChannel = createMockChannel(mockUpdateUserMessage);
+
+ const { result } = renderUpdateMessageCallbackHook({
+ currentChannel: mockChannel,
+ isMentionEnabled: true,
+ });
+
+ await result.current({
+ messageId: mockMessageId,
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ });
+
+ expect(mockUpdateUserMessage).toHaveBeenCalledWith(
+ mockMessageId,
+ expect.objectContaining({
+ message: mockMessage,
+ mentionedUsers: mockMentionedUsers,
+ mentionedMessageTemplate: mockMessage,
+ }),
+ );
+ });
+
+ it('should not update message when currentChannel is undefined', async () => {
+ const callbacks = createMockCallbacks();
+ const { result } = renderUpdateMessageCallbackHook({
+ currentChannel: undefined,
+ callbacks,
+ });
+
+ await result.current({
+ messageId: mockMessageId,
+ message: mockMessage,
+ });
+
+ expect(callbacks.onMessageUpdated).not.toHaveBeenCalled();
+ expect(mockPubSub.publish).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts
index fb81a7def..e5d888ceb 100644
--- a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts
+++ b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts
@@ -31,9 +31,12 @@ export default function useDeleteMessageCallback({
onMessageDeletedByReqId(message.reqId);
resolve();
}
-
+ if (currentChannel == null) {
+ logger.info('Thread | useDeleteMessageCallback: No current channel');
+ resolve();
+ }
logger.info('Thread | useDeleteMessageCallback: Deleting message from remote:', sendingStatus);
- currentChannel?.deleteMessage?.(message)
+ currentChannel.deleteMessage?.(message)
.then(() => {
logger.info('Thread | useDeleteMessageCallback: Deleting message success!', message);
onMessageDeleted(currentChannel, message.messageId);
diff --git a/src/modules/Thread/context/hooks/useResendMessageCallback.ts b/src/modules/Thread/context/hooks/useResendMessageCallback.ts
index 428add20e..07497fb11 100644
--- a/src/modules/Thread/context/hooks/useResendMessageCallback.ts
+++ b/src/modules/Thread/context/hooks/useResendMessageCallback.ts
@@ -35,107 +35,110 @@ export default function useResendMessageCallback({
pubSub,
}: StaticProps): (failedMessage: SendableMessageType) => void {
return useCallback((failedMessage: SendableMessageType) => {
- if ((failedMessage as SendableMessageType)?.isResendable) {
- logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage);
- if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) {
- try {
- currentChannel?.resendMessage(failedMessage as UserMessage)
- .onPending((message) => {
- logger.info('Thread | useResendMessageCallback: Resending user message started.', message);
- resendMessageStart(message);
- })
- .onSucceeded((message) => {
- logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message);
- sendMessageSuccess(message);
- pubSub.publish(topics.SEND_USER_MESSAGE, {
- channel: currentChannel,
- message: message,
- publishingModules: [PublishingModuleType.THREAD],
- });
- })
- .onFailed((error) => {
- logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error);
- failedMessage.sendingStatus = SendingStatus.FAILED;
- sendMessageFailure(failedMessage);
+ if (!(failedMessage as SendableMessageType)?.isResendable) {
+ logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage);
+ return;
+ }
+
+ logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage);
+ if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) {
+ try {
+ currentChannel?.resendMessage(failedMessage as UserMessage)
+ .onPending((message) => {
+ logger.info('Thread | useResendMessageCallback: Resending user message started.', message);
+ resendMessageStart(message);
+ })
+ .onSucceeded((message) => {
+ logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message);
+ sendMessageSuccess(message);
+ pubSub.publish(topics.SEND_USER_MESSAGE, {
+ channel: currentChannel,
+ message: message,
+ publishingModules: [PublishingModuleType.THREAD],
});
- } catch (err) {
- logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err);
- failedMessage.sendingStatus = SendingStatus.FAILED;
- sendMessageFailure(failedMessage);
- }
- } else if (failedMessage?.isFileMessage?.()) {
- try {
- currentChannel?.resendMessage?.(failedMessage as FileMessage)
- .onPending((message) => {
- logger.info('Thread | useResendMessageCallback: Resending file message started.', message);
- resendMessageStart(message);
- })
- .onSucceeded((message) => {
- logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message);
- sendMessageSuccess(message);
- pubSub.publish(topics.SEND_FILE_MESSAGE, {
- channel: currentChannel,
- message: failedMessage,
- publishingModules: [PublishingModuleType.THREAD],
- });
- })
- .onFailed((error) => {
- logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error);
- failedMessage.sendingStatus = SendingStatus.FAILED;
- sendMessageFailure(failedMessage);
+ })
+ .onFailed((error) => {
+ logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error);
+ failedMessage.sendingStatus = SendingStatus.FAILED;
+ sendMessageFailure(failedMessage);
+ });
+ } catch (err) {
+ logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err);
+ failedMessage.sendingStatus = SendingStatus.FAILED;
+ sendMessageFailure(failedMessage);
+ }
+ } else if (failedMessage?.isFileMessage?.()) {
+ try {
+ currentChannel?.resendMessage?.(failedMessage as FileMessage)
+ .onPending((message) => {
+ logger.info('Thread | useResendMessageCallback: Resending file message started.', message);
+ resendMessageStart(message);
+ })
+ .onSucceeded((message) => {
+ logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message);
+ sendMessageSuccess(message);
+ pubSub.publish(topics.SEND_FILE_MESSAGE, {
+ channel: currentChannel,
+ message: failedMessage,
+ publishingModules: [PublishingModuleType.THREAD],
});
- } catch (err) {
- logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err);
- failedMessage.sendingStatus = SendingStatus.FAILED;
- sendMessageFailure(failedMessage);
- }
- } else if (failedMessage?.isMultipleFilesMessage?.()) {
- try {
- currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage)
- .onPending((message) => {
- logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message);
- resendMessageStart(message);
- })
- .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => {
- logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', {
+ })
+ .onFailed((error) => {
+ logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error);
+ failedMessage.sendingStatus = SendingStatus.FAILED;
+ sendMessageFailure(failedMessage);
+ });
+ } catch (err) {
+ logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err);
+ failedMessage.sendingStatus = SendingStatus.FAILED;
+ sendMessageFailure(failedMessage);
+ }
+ } else if (failedMessage?.isMultipleFilesMessage?.()) {
+ try {
+ currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage)
+ .onPending((message) => {
+ logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message);
+ resendMessageStart(message);
+ })
+ .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => {
+ logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', {
+ requestId,
+ index,
+ error,
+ uploadableFileInfo,
+ });
+ pubSub.publish(topics.ON_FILE_INFO_UPLOADED, {
+ response: {
+ channelUrl: currentChannel.url,
requestId,
index,
- error,
uploadableFileInfo,
- });
- pubSub.publish(topics.ON_FILE_INFO_UPLOADED, {
- response: {
- channelUrl: currentChannel.url,
- requestId,
- index,
- uploadableFileInfo,
- error,
- },
- publishingModules: [PublishingModuleType.THREAD],
- });
- })
- .onSucceeded((message: MultipleFilesMessage) => {
- logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message);
- sendMessageSuccess(message);
- pubSub.publish(topics.SEND_FILE_MESSAGE, {
- channel: currentChannel,
- message,
- publishingModules: [PublishingModuleType.THREAD],
- });
- })
- .onFailed((error, message) => {
- logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error);
- sendMessageFailure(message);
+ error,
+ },
+ publishingModules: [PublishingModuleType.THREAD],
});
- } catch (err) {
- logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err);
- sendMessageFailure(failedMessage);
- }
- } else {
- logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage);
- failedMessage.sendingStatus = SendingStatus.FAILED;
+ })
+ .onSucceeded((message: MultipleFilesMessage) => {
+ logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message);
+ sendMessageSuccess(message);
+ pubSub.publish(topics.SEND_FILE_MESSAGE, {
+ channel: currentChannel,
+ message,
+ publishingModules: [PublishingModuleType.THREAD],
+ });
+ })
+ .onFailed((error, message) => {
+ logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error);
+ sendMessageFailure(message);
+ });
+ } catch (err) {
+ logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err);
sendMessageFailure(failedMessage);
}
+ } else {
+ logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage);
+ failedMessage.sendingStatus = SendingStatus.FAILED;
+ sendMessageFailure(failedMessage);
}
}, [currentChannel]);
}
diff --git a/src/modules/Thread/context/hooks/useSendFileMessage.ts b/src/modules/Thread/context/hooks/useSendFileMessage.ts
index ef1b4bc14..fd38846ab 100644
--- a/src/modules/Thread/context/hooks/useSendFileMessage.ts
+++ b/src/modules/Thread/context/hooks/useSendFileMessage.ts
@@ -50,37 +50,42 @@ export default function useSendFileMessageCallback({
const params = onBeforeSendFileMessage?.(file, quoteMessage) ?? createParamsDefault();
logger.info('Thread | useSendFileMessageCallback: Sending file message start.', params);
- currentChannel?.sendFileMessage(params)
- .onPending((pendingMessage) => {
+ if (currentChannel == null) {
+ logger.warning('Thread | useSendFileMessageCallback: currentChannel is null. Skipping file message send.');
+ resolve(null);
+ } else {
+ currentChannel.sendFileMessage(params)
+ .onPending((pendingMessage) => {
// @ts-ignore
- sendMessageStart({
- ...pendingMessage,
- url: URL.createObjectURL(file),
- // pending thumbnail message seems to be failed
- sendingStatus: SendingStatus.PENDING,
- isUserMessage: pendingMessage.isUserMessage,
- isFileMessage: pendingMessage.isFileMessage,
- isAdminMessage: pendingMessage.isAdminMessage,
- isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage,
+ sendMessageStart({
+ ...pendingMessage,
+ url: URL.createObjectURL(file),
+ // pending thumbnail message seems to be failed
+ sendingStatus: SendingStatus.PENDING,
+ isUserMessage: pendingMessage.isUserMessage,
+ isFileMessage: pendingMessage.isFileMessage,
+ isAdminMessage: pendingMessage.isAdminMessage,
+ isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage,
+ });
+ setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND);
+ })
+ .onFailed((error, message) => {
+ (message as LocalFileMessage).localUrl = URL.createObjectURL(file);
+ (message as LocalFileMessage).file = file;
+ logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error });
+ sendMessageFailure(message as SendableMessageType);
+ reject(error);
+ })
+ .onSucceeded((message) => {
+ logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message);
+ pubSub.publish(topics.SEND_FILE_MESSAGE, {
+ channel: currentChannel,
+ message: message as FileMessage,
+ publishingModules: [PublishingModuleType.THREAD],
+ });
+ resolve(message as FileMessage);
});
- setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND);
- })
- .onFailed((error, message) => {
- (message as LocalFileMessage).localUrl = URL.createObjectURL(file);
- (message as LocalFileMessage).file = file;
- logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error });
- sendMessageFailure(message as SendableMessageType);
- reject(error);
- })
- .onSucceeded((message) => {
- logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message);
- pubSub.publish(topics.SEND_FILE_MESSAGE, {
- channel: currentChannel,
- message: message as FileMessage,
- publishingModules: [PublishingModuleType.THREAD],
- });
- resolve(message as FileMessage);
- });
+ }
});
},
[currentChannel],
diff --git a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts
index 3a23b6658..17881c824 100644
--- a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts
+++ b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts
@@ -60,7 +60,12 @@ export default function useUpdateMessageCallback({
const params = createParamsDefault();
logger.info('Thread | useUpdateMessageCallback: Message update start.', params);
- currentChannel?.updateUserMessage?.(messageId, params)
+ if (currentChannel == null) {
+ logger.warning('Thread | useUpdateMessageCallback: currentChannel is null.');
+ return;
+ }
+
+ currentChannel.updateUserMessage?.(messageId, params)
.then((message: UserMessage) => {
logger.info('Thread | useUpdateMessageCallback: Message update succeeded.', message);
onMessageUpdated(currentChannel, message);
diff --git a/src/modules/Thread/context/useThread.ts b/src/modules/Thread/context/useThread.ts
index 7d68ebcaf..7cf276719 100644
--- a/src/modules/Thread/context/useThread.ts
+++ b/src/modules/Thread/context/useThread.ts
@@ -34,8 +34,7 @@ function hasReqId(
const useThread = () => {
const store = useContext(ThreadContext);
- if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider');
-
+ if (!store) throw new Error('useThread must be used within a ThreadProvider');
// SendbirdStateContext config
const { state: { stores, config } } = useSendbird();
const { logger, pubSub } = config;