diff --git a/apps/testing/vite.config.ts b/apps/testing/vite.config.ts
index 52a9ace08..a6bca4296 100644
--- a/apps/testing/vite.config.ts
+++ b/apps/testing/vite.config.ts
@@ -9,6 +9,11 @@ import postcssRtlOptions from '../../postcssRtlOptions.mjs';
export default defineConfig({
plugins: [react(), vitePluginSvgr({ include: '**/*.svg' })],
css: {
+ preprocessorOptions: {
+ scss: {
+ silenceDeprecations: ['legacy-js-api'],
+ },
+ },
postcss: {
plugins: [postcssRtl(postcssRtlOptions)],
},
diff --git a/src/modules/Channel/context/ChannelProvider.tsx b/src/modules/Channel/context/ChannelProvider.tsx
index 8c8d769e3..8027d44ce 100644
--- a/src/modules/Channel/context/ChannelProvider.tsx
+++ b/src/modules/Channel/context/ChannelProvider.tsx
@@ -43,7 +43,7 @@ import useUpdateMessageCallback from './hooks/useUpdateMessageCallback';
import useResendMessageCallback from './hooks/useResendMessageCallback';
import useSendMessageCallback from './hooks/useSendMessageCallback';
import useSendFileMessageCallback from './hooks/useSendFileMessageCallback';
-import useToggleReactionCallback from '../../GroupChannel/context/hooks/useToggleReactionCallback';
+import useToggleReactionCallback from './hooks/useToggleReactionCallback';
import useScrollToMessage from './hooks/useScrollToMessage';
import useSendVoiceMessageCallback from './hooks/useSendVoiceMessageCallback';
import { getCaseResolvedReplyType, getCaseResolvedThreadReplySelectType } from '../../../lib/utils/resolvedReplyType';
diff --git a/src/modules/Channel/context/hooks/useToggleReactionCallback.ts b/src/modules/Channel/context/hooks/useToggleReactionCallback.ts
index ffa984357..bbdfef315 100644
--- a/src/modules/Channel/context/hooks/useToggleReactionCallback.ts
+++ b/src/modules/Channel/context/hooks/useToggleReactionCallback.ts
@@ -3,38 +3,53 @@ import { GroupChannel } from '@sendbird/chat/groupChannel';
import { LoggerInterface } from '../../../../lib/Logger';
import { BaseMessage } from '@sendbird/chat/message';
-type UseToggleReactionCallbackOptions = {
- currentGroupChannel: GroupChannel | null;
-};
-type UseToggleReactionCallbackParams = {
- logger: LoggerInterface;
-};
+const LOG_PRESET = 'useToggleReactionCallback:';
+
+/**
+ * POTENTIAL IMPROVEMENT NEEDED:
+ * Current implementation might have race condition issues when the hook is called multiple times in rapid succession:
+ *
+ * 1. Race Condition Risk:
+ * - Multiple rapid clicks on reaction buttons could trigger concurrent API calls
+ * - The server responses might arrive in different order than the requests were sent
+ * - This could lead to inconsistent UI states where the final reaction state doesn't match user's last action
+ *
+ * 2. Performance Impact:
+ * - Each click generates a separate API call without debouncing/throttling
+ * - Under high-frequency clicks, this could cause unnecessary server load
+ *
+ * But we won't address these issues for now since it's being used only in the legacy codebase.
+ * */
export default function useToggleReactionCallback(
- { currentGroupChannel }: UseToggleReactionCallbackOptions,
- { logger }: UseToggleReactionCallbackParams,
+ currentChannel: GroupChannel | null,
+ logger?: LoggerInterface,
) {
return useCallback(
(message: BaseMessage, key: string, isReacted: boolean) => {
+ if (!currentChannel) {
+ logger?.warning(`${LOG_PRESET} currentChannel doesn't exist`, currentChannel);
+ return;
+ }
if (isReacted) {
- currentGroupChannel
- ?.deleteReaction(message, key)
+ currentChannel
+ .deleteReaction(message, key)
.then((res) => {
- logger.info('Delete reaction success', res);
+ logger?.info(`${LOG_PRESET} Delete reaction success`, res);
})
.catch((err) => {
- logger.warning('Delete reaction failed', err);
+ logger?.warning(`${LOG_PRESET} Delete reaction failed`, err);
});
} else {
- currentGroupChannel
- ?.addReaction(message, key)
+ currentChannel
+ .addReaction(message, key)
.then((res) => {
- logger.info('Add reaction success', res);
+ logger?.info(`${LOG_PRESET} Add reaction success`, res);
})
.catch((err) => {
- logger.warning('Add reaction failed', err);
+ logger?.warning(`${LOG_PRESET} Add reaction failed`, err);
});
}
},
- [currentGroupChannel],
+ [currentChannel],
);
}
diff --git a/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx
new file mode 100644
index 000000000..b8d751185
--- /dev/null
+++ b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import { GroupChannelUIView } from '../components/GroupChannelUI/GroupChannelUIView';
+import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext';
+
+jest.mock('../../../hooks/useSendbirdStateContext');
+
+const mockUseSendbirdStateContext = useSendbirdStateContext as jest.Mock;
+
+describe('GroupChannelUIView Integration Tests', () => {
+ const defaultProps = {
+ channelUrl: 'test-channel',
+ isInvalid: false,
+ renderChannelHeader: jest.fn(() =>
Channel Header
),
+ renderMessageList: jest.fn(() => Message List
),
+ renderMessageInput: jest.fn(() => Message Input
),
+ };
+
+ beforeEach(() => {
+ mockUseSendbirdStateContext.mockImplementation(() => ({
+ stores: {
+ sdkStore: { error: null },
+ },
+ config: {
+ logger: { info: jest.fn() },
+ isOnline: true,
+ groupChannel: {
+ enableTypingIndicator: true,
+ typingIndicatorTypes: new Set(['text']),
+ },
+ },
+ }));
+ });
+
+ it('renders basic channel components correctly', () => {
+ render();
+
+ expect(screen.getByText('Channel Header')).toBeInTheDocument();
+ expect(screen.getByText('Message List')).toBeInTheDocument();
+ expect(screen.getByText('Message Input')).toBeInTheDocument();
+ });
+
+ it('renders loading placeholder when isLoading is true', () => {
+ render();
+ // Placeholder is a just loading spinner in this case
+ expect(screen.getByRole('button')).toHaveClass('sendbird-icon-spinner');
+ });
+
+ it('renders invalid placeholder when channelUrl is missing', () => {
+ render();
+ expect(screen.getByText('No channels')).toBeInTheDocument();
+ });
+
+ it('renders error placeholder when isInvalid is true', () => {
+ render();
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('renders SDK error placeholder when SDK has error', () => {
+ mockUseSendbirdStateContext.mockImplementation(() => ({
+ stores: {
+ sdkStore: { error: new Error('SDK Error') },
+ },
+ config: {
+ logger: { info: jest.fn() },
+ isOnline: true,
+ },
+ }));
+
+ render();
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.getByText('Retry')).toBeInTheDocument();
+ });
+
+ it('renders custom placeholders when provided', () => {
+ const renderPlaceholderLoader = () => Custom Loader
;
+ const renderPlaceholderInvalid = () => Custom Invalid
;
+
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText('Custom Loader')).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText('Custom Invalid')).toBeInTheDocument();
+ });
+});
diff --git a/src/modules/GroupChannel/components/FileViewer/index.tsx b/src/modules/GroupChannel/components/FileViewer/index.tsx
index a5219b631..c6caa96bc 100644
--- a/src/modules/GroupChannel/components/FileViewer/index.tsx
+++ b/src/modules/GroupChannel/components/FileViewer/index.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import type { FileMessage } from '@sendbird/chat/message';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
import { FileViewerView } from './FileViewerView';
-import { useGroupChannelContext } from '../../context/GroupChannelProvider';
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
export interface FileViewerProps {
@@ -11,7 +11,10 @@ export interface FileViewerProps {
}
export const FileViewer = (props: FileViewerProps) => {
- const { deleteMessage, onBeforeDownloadFileMessage } = useGroupChannelContext();
+ const {
+ state: { onBeforeDownloadFileMessage },
+ actions: { deleteMessage },
+ } = useGroupChannel();
const { config } = useSendbirdStateContext();
const { logger } = config;
return (
diff --git a/src/modules/GroupChannel/components/GroupChannelUI/index.tsx b/src/modules/GroupChannel/components/GroupChannelUI/index.tsx
index 06e2887c6..8ad95ca1f 100644
--- a/src/modules/GroupChannel/components/GroupChannelUI/index.tsx
+++ b/src/modules/GroupChannel/components/GroupChannelUI/index.tsx
@@ -7,12 +7,13 @@ import GroupChannelHeader, { GroupChannelHeaderProps } from '../GroupChannelHead
import MessageList, { GroupChannelMessageListProps } from '../MessageList';
import MessageInputWrapper from '../MessageInputWrapper';
import { deleteNullish } from '../../../../utils/utils';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
export interface GroupChannelUIProps extends GroupChannelUIBasicProps {}
export const GroupChannelUI = (props: GroupChannelUIProps) => {
const context = useGroupChannelContext();
- const { channelUrl, fetchChannelError } = context;
+ const { state: { channelUrl, fetchChannelError } } = useGroupChannel();
// Inject components to presentation layer
const {
diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx
index 4ad40fc16..397312f56 100644
--- a/src/modules/GroupChannel/components/Message/MessageView.tsx
+++ b/src/modules/GroupChannel/components/Message/MessageView.tsx
@@ -20,7 +20,7 @@ import MessageContent, { MessageContentProps } from '../../../../ui/MessageConte
import SuggestedReplies, { SuggestedRepliesProps } from '../SuggestedReplies';
import SuggestedMentionListView from '../SuggestedMentionList/SuggestedMentionListView';
-import type { OnBeforeDownloadFileMessageType } from '../../context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../context/types';
import { classnames, deleteNullish } from '../../../../utils/utils';
export interface MessageProps {
diff --git a/src/modules/GroupChannel/components/Message/index.tsx b/src/modules/GroupChannel/components/Message/index.tsx
index 96f0ff675..c4425b3d9 100644
--- a/src/modules/GroupChannel/components/Message/index.tsx
+++ b/src/modules/GroupChannel/components/Message/index.tsx
@@ -4,38 +4,42 @@ import { useIIFE } from '@sendbird/uikit-tools';
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
import { getSuggestedReplies, isSendableMessage } from '../../../../utils';
import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../context/utils';
-import { useGroupChannelContext } from '../../context/GroupChannelProvider';
import MessageView, { MessageProps } from './MessageView';
import FileViewer from '../FileViewer';
import RemoveMessageModal from '../RemoveMessageModal';
import { ThreadReplySelectType } from '../../context/const';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
export const Message = (props: MessageProps): React.ReactElement => {
const { config, emojiManager } = useSendbirdStateContext();
const {
- loading,
- currentChannel,
- animatedMessageId,
- setAnimatedMessageId,
- scrollToMessage,
- replyType,
- threadReplySelectType,
- isReactionEnabled,
- toggleReaction,
- nicknamesMap,
- setQuoteMessage,
- renderUserMentionItem,
- filterEmojiCategoryIds,
- onQuoteMessageClick,
- onReplyInThreadClick,
- onMessageAnimated,
- onBeforeDownloadFileMessage,
- messages,
- updateUserMessage,
- sendUserMessage,
- resendMessage,
- deleteMessage,
- } = useGroupChannelContext();
+ state: {
+ loading,
+ currentChannel,
+ animatedMessageId,
+ replyType,
+ threadReplySelectType,
+ isReactionEnabled,
+ nicknamesMap,
+ renderUserMentionItem,
+ filterEmojiCategoryIds,
+ onQuoteMessageClick,
+ onReplyInThreadClick,
+ onMessageAnimated,
+ onBeforeDownloadFileMessage,
+ messages,
+ },
+ actions: {
+ toggleReaction,
+ setQuoteMessage,
+ setAnimatedMessageId,
+ scrollToMessage,
+ updateUserMessage,
+ sendUserMessage,
+ resendMessage,
+ deleteMessage,
+ },
+ } = useGroupChannel();
const { message } = props;
const initialized = !loading && Boolean(currentChannel);
diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/index.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/index.tsx
index af518d88c..721eb1f2f 100644
--- a/src/modules/GroupChannel/components/MessageInputWrapper/index.tsx
+++ b/src/modules/GroupChannel/components/MessageInputWrapper/index.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import MessageInputWrapperView from './MessageInputWrapperView';
-import { useGroupChannelContext } from '../../context/GroupChannelProvider';
import { GroupChannelUIBasicProps } from '../GroupChannelUI/GroupChannelUIView';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
export interface MessageInputWrapperProps {
value?: string;
@@ -13,8 +13,8 @@ export interface MessageInputWrapperProps {
}
export const MessageInputWrapper = (props: MessageInputWrapperProps) => {
- const context = useGroupChannelContext();
- return ;
+ const { state, actions } = useGroupChannel();
+ return ;
};
export { VoiceMessageInputWrapper, type VoiceMessageInputWrapperProps } from './VoiceMessageInputWrapper';
diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx
index 7e8019df6..75289ff55 100644
--- a/src/modules/GroupChannel/components/MessageList/index.tsx
+++ b/src/modules/GroupChannel/components/MessageList/index.tsx
@@ -14,13 +14,13 @@ import FrozenNotification from '../FrozenNotification';
import { SCROLL_BUFFER } from '../../../../utils/consts';
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble';
-import { useGroupChannelContext } from '../../context/GroupChannelProvider';
import { GroupChannelUIBasicProps } from '../GroupChannelUI/GroupChannelUIView';
import { deleteNullish } from '../../../../utils/utils';
import { getMessagePartsInfo } from './getMessagePartsInfo';
import { MessageProvider } from '../../../Message/context/MessageProvider';
import { getComponentKeyFromMessage } from '../../context/utils';
import { InfiniteList } from './InfiniteList';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
export interface GroupChannelMessageListProps {
className?: string;
@@ -67,25 +67,29 @@ export const MessageList = (props: GroupChannelMessageListProps) => {
} = deleteNullish(props);
const {
- channelUrl,
- hasNext,
- loading,
- messages,
- newMessages,
- scrollToBottom,
- isScrollBottomReached,
- isMessageGroupingEnabled,
- scrollRef,
- scrollDistanceFromBottomRef,
- scrollPositionRef,
- currentChannel,
- replyType,
- scrollPubSub,
- loadNext,
- loadPrevious,
- setIsScrollBottomReached,
- resetNewMessages,
- } = useGroupChannelContext();
+ state: {
+ channelUrl,
+ hasNext,
+ loading,
+ messages,
+ newMessages,
+ isScrollBottomReached,
+ isMessageGroupingEnabled,
+ currentChannel,
+ replyType,
+ scrollPubSub,
+ loadNext,
+ loadPrevious,
+ resetNewMessages,
+ scrollRef,
+ scrollPositionRef,
+ scrollDistanceFromBottomRef,
+ },
+ actions: {
+ scrollToBottom,
+ setIsScrollBottomReached,
+ },
+ } = useGroupChannel();
const store = useSendbirdStateContext();
diff --git a/src/modules/GroupChannel/components/RemoveMessageModal/index.tsx b/src/modules/GroupChannel/components/RemoveMessageModal/index.tsx
index 23cb445cc..c0772fe68 100644
--- a/src/modules/GroupChannel/components/RemoveMessageModal/index.tsx
+++ b/src/modules/GroupChannel/components/RemoveMessageModal/index.tsx
@@ -1,9 +1,9 @@
import React from 'react';
-import { useGroupChannelContext } from '../../context/GroupChannelProvider';
import RemoveMessageModalView, { RemoveMessageModalProps } from './RemoveMessageModalView';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
export const RemoveMessageModal = (props: RemoveMessageModalProps) => {
- const { deleteMessage } = useGroupChannelContext();
+ const { actions: { deleteMessage } } = useGroupChannel();
return ;
};
diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx
index 8213cf5e1..a8a33281f 100644
--- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx
+++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx
@@ -1,116 +1,75 @@
-import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
-import type { EmojiCategory, SendbirdError, User } from '@sendbird/chat';
+import React, { useMemo, useEffect, useRef, createContext } from 'react';
import {
- type FileMessage,
- FileMessageCreateParams,
- type MultipleFilesMessage,
- MultipleFilesMessageCreateParams,
ReplyType as ChatReplyType,
- UserMessageCreateParams,
- UserMessageUpdateParams,
} from '@sendbird/chat/message';
-import type { GroupChannel, MessageCollectionParams, MessageFilterParams } from '@sendbird/chat/groupChannel';
+import type { GroupChannel } from '@sendbird/chat/groupChannel';
import { MessageFilter } from '@sendbird/chat/groupChannel';
-import { useAsyncEffect, useAsyncLayoutEffect, useGroupChannelMessages, useIIFE, usePreservedCallback } from '@sendbird/uikit-tools';
+import {
+ useAsyncEffect,
+ useAsyncLayoutEffect,
+ useIIFE,
+ useGroupChannelMessages,
+} from '@sendbird/uikit-tools';
-import type { SendableMessageType } from '../../../utils';
-import { UserProfileProvider, UserProfileProviderProps } from '../../../lib/UserProfileContext';
+import { UserProfileProvider } from '../../../lib/UserProfileContext';
import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext';
-import { ThreadReplySelectType } from './const';
-import { ReplyType } from '../../../types';
-import useToggleReactionCallback from './hooks/useToggleReactionCallback';
-import { getCaseResolvedReplyType, getCaseResolvedThreadReplySelectType } from '../../../lib/utils/resolvedReplyType';
-import { getMessageTopOffset, isContextMenuClosed } from './utils';
-import { ScrollTopics, ScrollTopicUnion, useMessageListScroll } from './hooks/useMessageListScroll';
-import PUBSUB_TOPICS, { PubSubSendMessagePayload } from '../../../lib/pubSub/topics';
-import { PubSubTypes } from '../../../lib/pubSub';
-import { useMessageActions } from './hooks/useMessageActions';
+import { useMessageListScroll } from './hooks/useMessageListScroll';
import { getIsReactionEnabled } from '../../../utils/getIsReactionEnabled';
+import {
+ getCaseResolvedReplyType,
+ getCaseResolvedThreadReplySelectType,
+} from '../../../lib/utils/resolvedReplyType';
+import { isContextMenuClosed } from './utils';
+import PUBSUB_TOPICS from '../../../lib/pubSub/topics';
+import { createStore } from '../../../utils/storeManager';
+import { useStore } from '../../../hooks/useStore';
+import { useGroupChannel } from './hooks/useGroupChannel';
+import { ThreadReplySelectType } from './const';
+import type {
+ GroupChannelProviderProps,
+ MessageListQueryParamsType,
+ GroupChannelState,
+} from './types';
+
+const initialState = {
+ currentChannel: null,
+ channelUrl: '',
+ fetchChannelError: null,
+ nicknamesMap: new Map(),
+
+ quoteMessage: null,
+ animatedMessageId: null,
+ isScrollBottomReached: true,
+
+ scrollRef: { current: null },
+ scrollDistanceFromBottomRef: { current: 0 },
+ scrollPositionRef: { current: 0 },
+ messageInputRef: { current: null },
+
+ isReactionEnabled: false,
+ isMessageGroupingEnabled: true,
+ isMultipleFilesMessageEnabled: false,
+ showSearchIcon: true,
+ replyType: 'NONE',
+ threadReplySelectType: ThreadReplySelectType.PARENT,
+ disableMarkAsRead: false,
+ scrollBehavior: 'auto',
+ scrollPubSub: null,
+} as GroupChannelState;
+
+export const GroupChannelContext = createContext> | null>(null);
+
+export const InternalGroupChannelProvider: React.FC> = ({ children }) => {
+ const storeRef = useRef(createStore(initialState));
-export { ThreadReplySelectType } from './const'; // export for external usage
-
-type OnBeforeHandler = (params: T) => T | Promise;
-type MessageListQueryParamsType = Omit & MessageFilterParams;
-type MessageActions = ReturnType;
-type MessageListDataSourceWithoutActions = Omit, keyof MessageActions | `_dangerous_${string}`>;
-export type OnBeforeDownloadFileMessageType = (params: { message: FileMessage | MultipleFilesMessage; index?: number }) => Promise;
-
-interface ContextBaseType extends
- Pick {
- // Required
- channelUrl: string;
-
- // Flags
- isReactionEnabled?: boolean;
- isMessageGroupingEnabled?: boolean;
- isMultipleFilesMessageEnabled?: boolean;
- showSearchIcon?: boolean;
- replyType?: ReplyType;
- threadReplySelectType?: ThreadReplySelectType;
- disableMarkAsRead?: boolean;
- scrollBehavior?: 'smooth' | 'auto';
- forceLeftToRightMessageLayout?: boolean;
-
- startingPoint?: number;
-
- // Message Focusing
- animatedMessageId?: number | null;
- onMessageAnimated?: () => void;
-
- // Custom
- messageListQueryParams?: MessageListQueryParamsType;
- filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
-
- // Handlers
- onBeforeSendUserMessage?: OnBeforeHandler;
- onBeforeSendFileMessage?: OnBeforeHandler;
- onBeforeSendVoiceMessage?: OnBeforeHandler;
- onBeforeSendMultipleFilesMessage?: OnBeforeHandler;
- onBeforeUpdateUserMessage?: OnBeforeHandler;
- onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType;
-
- // Click
- onBackClick?(): void;
- onChatHeaderActionClick?(event: React.MouseEvent): void;
- onReplyInThreadClick?: (props: { message: SendableMessageType }) => void;
- onSearchClick?(): void;
- onQuoteMessageClick?: (props: { message: SendableMessageType }) => void;
-
- // Render
- renderUserMentionItem?: (props: { user: User }) => JSX.Element;
-}
-
-export interface GroupChannelContextType extends ContextBaseType, MessageListDataSourceWithoutActions, MessageActions {
- currentChannel: GroupChannel | null;
- fetchChannelError: SendbirdError | null;
- nicknamesMap: Map;
-
- scrollRef: React.RefObject;
- scrollDistanceFromBottomRef: React.MutableRefObject;
- scrollPositionRef: React.MutableRefObject;
- scrollPubSub: PubSubTypes;
- messageInputRef: React.RefObject;
-
- quoteMessage: SendableMessageType | null;
- setQuoteMessage: React.Dispatch>;
- animatedMessageId: number | null;
- setAnimatedMessageId: React.Dispatch>;
- isScrollBottomReached: boolean;
- setIsScrollBottomReached: React.Dispatch>;
-
- scrollToBottom: (animated?: boolean) => void;
- scrollToMessage: (createdAt: number, messageId: number) => void;
- toggleReaction(message: SendableMessageType, emojiKey: string, isReacted: boolean): void;
-}
-
-export interface GroupChannelProviderProps extends
- ContextBaseType,
- Pick {
- children?: React.ReactNode;
-}
+ return (
+
+ {children}
+
+ );
+};
-export const GroupChannelContext = React.createContext(null);
-export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
+const GroupChannelManager :React.FC> = (props) => {
const {
channelUrl,
children,
@@ -118,7 +77,6 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
replyType: moduleReplyType,
threadReplySelectType: moduleThreadReplySelectType,
isMessageGroupingEnabled = true,
- isMultipleFilesMessageEnabled,
showSearchIcon,
disableMarkAsRead = false,
scrollBehavior = 'auto',
@@ -141,45 +99,42 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
filterEmojiCategoryIds,
} = props;
- // Global context
+ const { state, actions } = useGroupChannel();
+ const { updateState } = useGroupChannelStore();
const { config, stores } = useSendbirdStateContext();
-
const { sdkStore } = stores;
const { markAsReadScheduler, logger } = config;
- // State
- const [quoteMessage, setQuoteMessage] = useState(null);
- const [animatedMessageId, setAnimatedMessageId] = useState(null);
- const [currentChannel, setCurrentChannel] = useState(null);
- const [fetchChannelError, setFetchChannelError] = useState(null);
-
- // Ref
- const { scrollRef, scrollPubSub, scrollDistanceFromBottomRef, isScrollBottomReached, setIsScrollBottomReached, scrollPositionRef } = useMessageListScroll(scrollBehavior, [currentChannel?.url]);
- const messageInputRef = useRef(null);
-
- const toggleReaction = useToggleReactionCallback(currentChannel, logger);
- const replyType = getCaseResolvedReplyType(moduleReplyType ?? config.groupChannel.replyType).upperCase;
- const threadReplySelectType = getCaseResolvedThreadReplySelectType(
+ // ScrollHandler initialization
+ const {
+ scrollRef,
+ scrollPubSub,
+ scrollDistanceFromBottomRef,
+ isScrollBottomReached,
+ scrollPositionRef,
+ } = useMessageListScroll(scrollBehavior, [state.currentChannel?.url]);
+
+ // Configuration resolution
+ const resolvedReplyType = getCaseResolvedReplyType(moduleReplyType ?? config.groupChannel.replyType).upperCase;
+ const resolvedThreadReplySelectType = getCaseResolvedThreadReplySelectType(
moduleThreadReplySelectType ?? config.groupChannel.threadReplySelectType,
).upperCase;
+ const replyType = getCaseResolvedReplyType(moduleReplyType ?? config.groupChannel.replyType).upperCase;
+ const resolvedIsReactionEnabled = getIsReactionEnabled({
+ channel: state.currentChannel,
+ config,
+ moduleLevel: moduleReactionEnabled,
+ });
const chatReplyType = useIIFE(() => {
if (replyType === 'NONE') return ChatReplyType.NONE;
return ChatReplyType.ONLY_REPLY_TO_CHANNEL;
});
- const isReactionEnabled = getIsReactionEnabled({
- channel: currentChannel,
- config,
- moduleLevel: moduleReactionEnabled,
- });
- const nicknamesMap = useMemo(
- () => new Map((currentChannel?.members ?? []).map(({ userId, nickname }) => [userId, nickname])),
- [currentChannel?.members],
- );
- const messageDataSource = useGroupChannelMessages(sdkStore.sdk, currentChannel!, {
+ // Message Collection setup
+ const messageDataSource = useGroupChannelMessages(sdkStore.sdk, state.currentChannel!, {
startingPoint,
replyType: chatReplyType,
- collectionCreator: getCollectionCreator(currentChannel!, messageListQueryParams),
+ collectionCreator: getCollectionCreator(state.currentChannel!, messageListQueryParams),
shouldCountNewMessages: () => !isScrollBottomReached,
markAsRead: (channels) => {
if (isScrollBottomReached && !disableMarkAsRead) {
@@ -187,213 +142,201 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => {
}
},
onMessagesReceived: () => {
- // FIXME: onMessagesReceived called with onApiResult
if (isScrollBottomReached && isContextMenuClosed()) {
- setTimeout(() => {
- scrollPubSub.publish('scrollToBottom', {});
- }, 10);
+ setTimeout(() => actions.scrollToBottom(true), 10);
}
},
onChannelDeleted: () => {
- setCurrentChannel(null);
- setFetchChannelError(null);
+ actions.setCurrentChannel(null);
onBackClick?.();
},
onCurrentUserBanned: () => {
- setCurrentChannel(null);
- setFetchChannelError(null);
+ actions.setCurrentChannel(null);
onBackClick?.();
},
- onChannelUpdated: (channel) => setCurrentChannel(channel),
+ onChannelUpdated: (channel) => {
+ actions.setCurrentChannel(channel);
+ },
logger: logger as any,
});
- // SideEffect: Fetch and set to current channel by channelUrl prop.
+ // Channel initialization
useAsyncEffect(async () => {
if (sdkStore.initialized && channelUrl) {
try {
const channel = await sdkStore.sdk.groupChannel.getChannel(channelUrl);
- setCurrentChannel(channel);
- setFetchChannelError(null);
+ actions.setCurrentChannel(channel);
} catch (error) {
- setCurrentChannel(null);
- setFetchChannelError(error as SendbirdError);
+ actions.handleChannelError(error);
logger?.error?.('GroupChannelProvider: error when fetching channel', error);
- } finally {
- // Reset states when channel changes
- setQuoteMessage(null);
- setAnimatedMessageId(null);
}
}
}, [sdkStore.initialized, sdkStore.sdk, channelUrl]);
- // SideEffect: Scroll to the bottom
- // - On the initialized message list
- // - On messages sent from the thread
+ // Message sync effect
useAsyncLayoutEffect(async () => {
if (messageDataSource.initialized) {
- scrollPubSub.publish('scrollToBottom', {});
+ actions.scrollToBottom();
}
- const onSentMessageFromOtherModule = (data: PubSubSendMessagePayload) => {
- if (data.channel.url === currentChannel?.url) scrollPubSub.publish('scrollToBottom', {});
+ const handleExternalMessage = (data) => {
+ if (data.channel.url === state.currentChannel?.url) {
+ actions.scrollToBottom(true);
+ }
};
- const subscribes = [
- config.pubSub.subscribe(PUBSUB_TOPICS.SEND_USER_MESSAGE, onSentMessageFromOtherModule),
- config.pubSub.subscribe(PUBSUB_TOPICS.SEND_FILE_MESSAGE, onSentMessageFromOtherModule),
+
+ const subscriptions = [
+ config.pubSub.subscribe(PUBSUB_TOPICS.SEND_USER_MESSAGE, handleExternalMessage),
+ config.pubSub.subscribe(PUBSUB_TOPICS.SEND_FILE_MESSAGE, handleExternalMessage),
];
+
return () => {
- subscribes.forEach((subscribe) => subscribe.remove());
+ subscriptions.forEach(subscription => subscription.remove());
};
- }, [messageDataSource.initialized, currentChannel?.url]);
+ }, [messageDataSource.initialized, state.currentChannel?.url]);
- // SideEffect: Reset MessageCollection with startingPoint prop.
+ // Starting point handling
useEffect(() => {
if (typeof startingPoint === 'number') {
- // We do not handle animation for message search here.
- // Please update the animatedMessageId prop to trigger the animation.
- scrollToMessage(startingPoint, 0, false, false);
+ actions.scrollToMessage(startingPoint, 0, false, false);
}
}, [startingPoint]);
- // SideEffect: Update animatedMessageId prop to state.
+ // Animated message handling
useEffect(() => {
- if (_animatedMessageId) setAnimatedMessageId(_animatedMessageId);
- }, [_animatedMessageId]);
-
- const scrollToBottom = usePreservedCallback(async (animated?: boolean) => {
- if (!scrollRef.current) return;
-
- setAnimatedMessageId(null);
- setIsScrollBottomReached(true);
-
- if (config.isOnline && messageDataSource.hasNext()) {
- await messageDataSource.resetWithStartingPoint(Number.MAX_SAFE_INTEGER);
- scrollPubSub.publish('scrollToBottom', { animated });
- } else {
- scrollPubSub.publish('scrollToBottom', { animated });
+ if (_animatedMessageId) {
+ actions.setAnimatedMessageId(_animatedMessageId);
}
+ }, [_animatedMessageId]);
- if (currentChannel && !messageDataSource.hasNext()) {
- messageDataSource.resetNewMessages();
- if (!disableMarkAsRead) markAsReadScheduler.push(currentChannel);
- }
- });
-
- const scrollToMessage = usePreservedCallback(
- async (createdAt: number, messageId: number, messageFocusAnimated?: boolean, scrollAnimated?: boolean) => {
- // NOTE: To prevent multiple clicks on the message in the channel while scrolling
- // Check if it can be replaced with event.stopPropagation()
- const element = scrollRef.current;
- const parentNode = element?.parentNode as HTMLDivElement;
- const clickHandler = {
- activate() {
- if (!element || !parentNode) return;
- element.style.pointerEvents = 'auto';
- parentNode.style.cursor = 'auto';
- },
- deactivate() {
- if (!element || !parentNode) return;
- element.style.pointerEvents = 'none';
- parentNode.style.cursor = 'wait';
- },
- };
-
- clickHandler.deactivate();
-
- setAnimatedMessageId(null);
- const message = messageDataSource.messages.find((it) => it.messageId === messageId || it.createdAt === createdAt);
- if (message) {
- const topOffset = getMessageTopOffset(message.createdAt);
- if (topOffset) scrollPubSub.publish('scroll', { top: topOffset, animated: scrollAnimated });
- if (messageFocusAnimated ?? true) setAnimatedMessageId(messageId);
- } else {
- await messageDataSource.resetWithStartingPoint(createdAt);
- setTimeout(() => {
- const topOffset = getMessageTopOffset(createdAt);
- if (topOffset) scrollPubSub.publish('scroll', { top: topOffset, lazy: false, animated: scrollAnimated });
- if (messageFocusAnimated ?? true) setAnimatedMessageId(messageId);
- });
- }
+ // State update effect
+ const eventHandlers = useMemo(() => ({
+ onBeforeSendUserMessage,
+ onBeforeSendFileMessage,
+ onBeforeSendVoiceMessage,
+ onBeforeSendMultipleFilesMessage,
+ onBeforeUpdateUserMessage,
+ onBeforeDownloadFileMessage,
+ onBackClick,
+ onChatHeaderActionClick,
+ onReplyInThreadClick,
+ onSearchClick,
+ onQuoteMessageClick,
+ onMessageAnimated,
+ }), [
+ onBeforeSendUserMessage,
+ onBeforeSendFileMessage,
+ onBeforeSendVoiceMessage,
+ onBeforeSendMultipleFilesMessage,
+ onBeforeUpdateUserMessage,
+ onBeforeDownloadFileMessage,
+ onBackClick,
+ onChatHeaderActionClick,
+ onReplyInThreadClick,
+ onSearchClick,
+ onQuoteMessageClick,
+ onMessageAnimated,
+ ]);
- clickHandler.activate();
- },
- );
+ const renderProps = useMemo(() => ({
+ renderUserMentionItem,
+ filterEmojiCategoryIds,
+ }), [renderUserMentionItem, filterEmojiCategoryIds]);
+
+ const configurations = useMemo(() => ({
+ isReactionEnabled: resolvedIsReactionEnabled,
+ isMessageGroupingEnabled,
+ replyType: resolvedReplyType,
+ threadReplySelectType: resolvedThreadReplySelectType,
+ showSearchIcon: showSearchIcon ?? config.groupChannelSettings.enableMessageSearch,
+ disableMarkAsRead,
+ scrollBehavior,
+ }), [
+ resolvedIsReactionEnabled,
+ isMessageGroupingEnabled,
+ resolvedReplyType,
+ resolvedThreadReplySelectType,
+ showSearchIcon,
+ disableMarkAsRead,
+ scrollBehavior,
+ config.groupChannelSettings.enableMessageSearch,
+ ]);
+
+ const scrollState = useMemo(() => ({
+ scrollRef,
+ scrollPubSub,
+ scrollDistanceFromBottomRef,
+ scrollPositionRef,
+ isScrollBottomReached,
+ }), [
+ scrollRef,
+ scrollPubSub,
+ scrollDistanceFromBottomRef,
+ scrollPositionRef,
+ isScrollBottomReached,
+ ]);
- const messageActions = useMessageActions({ ...props, ...messageDataSource, scrollToBottom, quoteMessage, replyType });
+ useEffect(() => {
+ updateState({
+ // Channel state
+ channelUrl,
+ currentChannel: state.currentChannel,
+
+ // Grouped states
+ ...configurations,
+ ...scrollState,
+ ...eventHandlers,
+ ...renderProps,
+
+ // Message data source & actions
+ ...messageDataSource,
+ });
+ }, [
+ channelUrl,
+ state.currentChannel,
+ messageDataSource.initialized,
+ messageDataSource.loading,
+ messageDataSource.messages,
+ configurations,
+ scrollState,
+ eventHandlers,
+ renderProps,
+ ]);
+
+ return children;
+};
+const GroupChannelProvider: React.FC> = (props) => {
return (
-
-
- {children}
-
-
+
+
+
+ {props.children}
+
+
+
);
};
-export const useGroupChannelContext = () => {
- const context = useContext(GroupChannelContext);
- if (!context) throw new Error('GroupChannelContext not found. Use within the GroupChannel module.');
- return context;
+/**
+ * A specialized hook for GroupChannel state management
+ * @returns {ReturnType>}
+ */
+const useGroupChannelStore = () => {
+ return useStore(GroupChannelContext, state => state, initialState);
+};
+/**
+ * Keep this function for backward compatibility.
+ */
+const useGroupChannelContext = () => {
+ const { state, actions } = useGroupChannel();
+ return { ...state, ...actions };
+};
+
+export {
+ GroupChannelProvider,
+ useGroupChannelContext,
+ GroupChannelManager,
};
function getCollectionCreator(groupChannel: GroupChannel, messageListQueryParams?: MessageListQueryParamsType) {
diff --git a/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx b/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx
new file mode 100644
index 000000000..c5adeb06f
--- /dev/null
+++ b/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { waitFor, act } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider';
+import { useGroupChannel } from '../hooks/useGroupChannel';
+
+const mockLogger = { warning: jest.fn() };
+const mockChannel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+};
+
+const mockGetChannel = jest.fn().mockResolvedValue(mockChannel);
+const mockMessageCollection = {
+ dispose: jest.fn(),
+ setMessageCollectionHandler: jest.fn(),
+ initialize: jest.fn().mockResolvedValue(null),
+ loadPrevious: jest.fn(),
+ loadNext: jest.fn(),
+};
+jest.mock('../../../../hooks/useSendbirdStateContext', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ stores: {
+ sdkStore: {
+ sdk: {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ addGroupChannelHandler: jest.fn(),
+ removeGroupChannelHandler: jest.fn(),
+ },
+ createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection),
+ },
+ initialized: true,
+ },
+ },
+ config: {
+ logger: mockLogger,
+ markAsReadScheduler: {
+ push: jest.fn(),
+ },
+ groupChannel: {
+ replyType: 'NONE',
+ threadReplySelectType: 'PARENT',
+ },
+ groupChannelSettings: {
+ enableMessageSearch: true,
+ },
+ isOnline: true,
+ pubSub: {
+ subscribe: () => ({ remove: jest.fn() }),
+ },
+ },
+ })),
+}));
+
+describe('GroupChannelProvider', () => {
+ it('provides the correct initial state', () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannelContext(), { wrapper });
+
+ expect(result.current.channelUrl).toBe('test-channel');
+ expect(result.current.currentChannel).toBe(null);
+ expect(result.current.isScrollBottomReached).toBe(true);
+ });
+
+ it('updates state correctly when channel is fetched', async () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(async () => {
+ await waitFor(() => {
+ expect(result.current.state.currentChannel).toBeTruthy();
+ expect(result.current.state.currentChannel?.url).toBe('test-channel');
+ });
+ });
+ });
+
+ it('handles channel error correctly', async () => {
+ const mockError = new Error('Channel fetch failed');
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ jest.mock('../../../../hooks/useSendbirdStateContext', () => ({
+ default: () => ({
+ stores: {
+ sdkStore: {
+ sdk: {
+ groupChannel: {
+ getChannel: jest.fn().mockRejectedValue(mockError),
+ },
+ },
+ initialized: true,
+ },
+ },
+ config: { logger: console },
+ }),
+ }));
+
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(async () => {
+ await waitFor(() => {
+ expect(result.current.state.fetchChannelError).toBeNull();
+ expect(result.current.state.currentChannel).toBeNull();
+ });
+ });
+ });
+
+ it('correctly handles scroll to bottom', async () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(async () => {
+ result.current.actions.scrollToBottom();
+ await waitFor(() => {
+ expect(result.current.state.isScrollBottomReached).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx
new file mode 100644
index 000000000..31ba50334
--- /dev/null
+++ b/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx
@@ -0,0 +1,268 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { GroupChannelProvider } from '../GroupChannelProvider';
+import { useGroupChannel } from '../hooks/useGroupChannel';
+import { SendableMessageType } from '../../../../utils';
+
+const mockLogger = { warning: jest.fn() };
+const mockChannel = {
+ url: 'test-channel',
+ members: [{ userId: '1', nickname: 'user1' }],
+};
+
+const mockGetChannel = jest.fn().mockResolvedValue(mockChannel);
+const mockMessageCollection = {
+ dispose: jest.fn(),
+ setMessageCollectionHandler: jest.fn(),
+ initialize: jest.fn().mockResolvedValue(null),
+ loadPrevious: jest.fn(),
+ loadNext: jest.fn(),
+};
+jest.mock('../../../../hooks/useSendbirdStateContext', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ stores: {
+ sdkStore: {
+ sdk: {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ addGroupChannelHandler: jest.fn(),
+ removeGroupChannelHandler: jest.fn(),
+ },
+ createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection),
+ },
+ initialized: true,
+ },
+ },
+ config: {
+ logger: mockLogger,
+ markAsReadScheduler: {
+ push: jest.fn(),
+ },
+ groupChannel: {
+ replyType: 'NONE',
+ threadReplySelectType: 'PARENT',
+ },
+ groupChannelSettings: {
+ enableMessageSearch: true,
+ },
+ isOnline: true,
+ pubSub: {
+ subscribe: () => ({ remove: jest.fn() }),
+ },
+ },
+ })),
+}));
+
+describe('useGroupChannel', () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ describe('State management', () => {
+ it('provides initial state', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ expect(result.current.state).toEqual(expect.objectContaining({
+ currentChannel: null,
+ channelUrl: mockChannel.url,
+ fetchChannelError: null,
+ quoteMessage: null,
+ animatedMessageId: null,
+ isScrollBottomReached: true,
+ }));
+ });
+
+ it('updates channel state', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ result.current.actions.setCurrentChannel(mockChannel as GroupChannel);
+ });
+
+ expect(result.current.state.currentChannel).toEqual(mockChannel);
+ expect(result.current.state.fetchChannelError).toBeNull();
+
+ // nicknamesMap should be created from channel members
+ expect(result.current.state.nicknamesMap.get('1')).toBe('user1');
+ });
+
+ it('handles channel error', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+ const error = new Error('Failed to fetch channel');
+
+ act(() => {
+ result.current.actions.handleChannelError(error);
+ });
+
+ expect(result.current.state.currentChannel).toBeNull();
+ expect(result.current.state.fetchChannelError).toBe(error);
+ expect(result.current.state.quoteMessage).toBeNull();
+ expect(result.current.state.animatedMessageId).toBeNull();
+ });
+
+ it('manages quote message state', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+ const mockMessage = { messageId: 1, message: 'test' } as SendableMessageType;
+
+ act(() => {
+ result.current.actions.setQuoteMessage(mockMessage);
+ });
+
+ expect(result.current.state.quoteMessage).toEqual(mockMessage);
+
+ act(() => {
+ result.current.actions.setQuoteMessage(null);
+ });
+
+ expect(result.current.state.quoteMessage).toBeNull();
+ });
+
+ it('manages animated message state', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ result.current.actions.setAnimatedMessageId(123);
+ });
+
+ expect(result.current.state.animatedMessageId).toBe(123);
+
+ act(() => {
+ result.current.actions.setAnimatedMessageId(null);
+ });
+
+ expect(result.current.state.animatedMessageId).toBeNull();
+ });
+
+ it('manages scroll bottom reached state', () => {
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ expect(result.current.state.isScrollBottomReached).toBe(true); // initial state
+
+ act(() => {
+ result.current.actions.setIsScrollBottomReached(false);
+ });
+
+ expect(result.current.state.isScrollBottomReached).toBe(false);
+
+ act(() => {
+ result.current.actions.setIsScrollBottomReached(true);
+ });
+
+ expect(result.current.state.isScrollBottomReached).toBe(true);
+ });
+ });
+
+ describe('Channel actions', () => {
+ it('processes reaction toggle', async () => {
+ const mockChannelWithReactions = {
+ ...mockChannel,
+ addReaction: jest.fn().mockResolvedValue({}),
+ deleteReaction: jest.fn().mockResolvedValue({}),
+ };
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ result.current.actions.setCurrentChannel(mockChannelWithReactions as any);
+ });
+
+ const mockMessage = { messageId: 1 };
+ const emojiKey = 'thumbs_up';
+
+ act(() => {
+ result.current.actions.toggleReaction(
+ mockMessage as SendableMessageType,
+ emojiKey,
+ false,
+ );
+ });
+
+ expect(mockChannelWithReactions.addReaction)
+ .toHaveBeenCalledWith(mockMessage, emojiKey);
+
+ // Test removing reaction
+ act(() => {
+ result.current.actions.toggleReaction(
+ mockMessage as SendableMessageType,
+ emojiKey,
+ true,
+ );
+ });
+
+ expect(mockChannelWithReactions.deleteReaction)
+ .toHaveBeenCalledWith(mockMessage, emojiKey);
+ });
+
+ it('logs errors for reaction deletion failure', async () => {
+ const mockError = new Error('Failed to delete reaction');
+ const mockChannelWithReactions = {
+ ...mockChannel,
+ deleteReaction: jest.fn().mockRejectedValue(mockError),
+ };
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(async () => {
+ result.current.actions.setCurrentChannel(mockChannelWithReactions as any);
+ });
+
+ act(async () => {
+ await result.current.actions.toggleReaction(
+ { messageId: 1 } as SendableMessageType,
+ 'thumbs_up',
+ true,
+ );
+ await waitFor(() => {
+ expect(mockLogger.warning).toHaveBeenCalledWith(
+ 'Failed to delete reaction:',
+ mockError,
+ );
+ });
+ });
+
+ });
+
+ it('processes successful reaction toggles without logging errors', async () => {
+ const mockChannelWithReactions = {
+ ...mockChannel,
+ addReaction: jest.fn().mockResolvedValue({}),
+ deleteReaction: jest.fn().mockResolvedValue({}),
+ };
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(async () => {
+ result.current.actions.setCurrentChannel(mockChannelWithReactions as any);
+ });
+
+ act(async () => {
+ result.current.actions.toggleReaction(
+ { messageId: 1 } as SendableMessageType,
+ 'thumbs_up',
+ false,
+ );
+ await waitFor(() => {
+ expect(mockChannelWithReactions.addReaction).toHaveBeenCalled();
+ expect(mockLogger.warning).not.toHaveBeenCalled();
+ });
+ });
+
+ act(async () => {
+ result.current.actions.toggleReaction(
+ { messageId: 1 } as SendableMessageType,
+ 'thumbs_up',
+ true,
+ );
+ await waitFor(() => {
+ expect(mockChannelWithReactions.deleteReaction).toHaveBeenCalled();
+ expect(mockLogger.warning).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx b/src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx
new file mode 100644
index 000000000..821fc009e
--- /dev/null
+++ b/src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx
@@ -0,0 +1,186 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { useMessageActions } from '../hooks/useMessageActions';
+
+describe('useMessageActions', () => {
+ // Setup common mocks
+ const mockSendUserMessage = jest.fn();
+ const mockSendFileMessage = jest.fn();
+ const mockSendMultipleFilesMessage = jest.fn();
+ const mockUpdateUserMessage = jest.fn();
+ const mockScrollToBottom = jest.fn();
+
+ // Default params for the hook
+ const defaultParams = {
+ sendUserMessage: mockSendUserMessage,
+ sendFileMessage: mockSendFileMessage,
+ sendMultipleFilesMessage: mockSendMultipleFilesMessage,
+ updateUserMessage: mockUpdateUserMessage,
+ scrollToBottom: mockScrollToBottom,
+ quoteMessage: null,
+ replyType: 'NONE',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('sendUserMessage', () => {
+ it('sends basic message without quote', async () => {
+ const { result } = renderHook(() => useMessageActions(defaultParams));
+ const messageParams = { message: 'test message' };
+
+ mockSendUserMessage.mockResolvedValueOnce({ messageId: 1, message: 'test message' });
+
+ await result.current.sendUserMessage(messageParams);
+
+ expect(mockSendUserMessage).toHaveBeenCalledWith(
+ messageParams,
+ expect.any(Function),
+ );
+ });
+
+ it('includes parent message id when quote message exists', async () => {
+ const paramsWithQuote = {
+ ...defaultParams,
+ quoteMessage: { messageId: 123, message: 'quoted message' },
+ replyType: 'QUOTE_REPLY',
+ };
+
+ const { result } = renderHook(() => useMessageActions(paramsWithQuote));
+ const messageParams = { message: 'test reply' };
+
+ await result.current.sendUserMessage(messageParams);
+
+ expect(mockSendUserMessage).toHaveBeenCalledWith(
+ {
+ ...messageParams,
+ isReplyToChannel: true,
+ parentMessageId: 123,
+ },
+ expect.any(Function),
+ );
+ });
+
+ it('applies onBeforeSendUserMessage hook', async () => {
+ const onBeforeSendUserMessage = jest.fn((params) => ({
+ ...params,
+ message: `Modified: ${params.message}`,
+ }));
+
+ const paramsWithHook = {
+ ...defaultParams,
+ onBeforeSendUserMessage,
+ };
+
+ const { result } = renderHook(() => useMessageActions(paramsWithHook));
+ const messageParams = { message: 'test message' };
+
+ await result.current.sendUserMessage(messageParams);
+
+ expect(onBeforeSendUserMessage).toHaveBeenCalledWith(messageParams);
+ expect(mockSendUserMessage).toHaveBeenCalledWith(
+ {
+ message: 'Modified: test message',
+ },
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('sendFileMessage', () => {
+ it('sends basic file message', async () => {
+ const { result } = renderHook(() => useMessageActions(defaultParams));
+ const file = new File(['test'], 'test.txt', { type: 'text/plain' });
+ const messageParams = { file };
+
+ await result.current.sendFileMessage(messageParams);
+
+ expect(mockSendFileMessage).toHaveBeenCalledWith(
+ messageParams,
+ expect.any(Function),
+ );
+ });
+
+ it('applies onBeforeSendFileMessage hook', async () => {
+ const onBeforeSendFileMessage = jest.fn((params) => ({
+ ...params,
+ fileName: 'modified.txt',
+ }));
+
+ const paramsWithHook = {
+ ...defaultParams,
+ onBeforeSendFileMessage,
+ };
+
+ const { result } = renderHook(() => useMessageActions(paramsWithHook));
+ const messageParams = { file: new File(['test'], 'test.txt') };
+
+ await result.current.sendFileMessage(messageParams);
+
+ expect(onBeforeSendFileMessage).toHaveBeenCalledWith(messageParams);
+ expect(mockSendFileMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ fileName: 'modified.txt' }),
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('sendMultipleFilesMessage', () => {
+ it('sends multiple files message', async () => {
+ const { result } = renderHook(() => useMessageActions(defaultParams));
+ const files = [
+ new File(['test1'], 'test1.txt'),
+ new File(['test2'], 'test2.txt'),
+ ];
+ const messageParams = { files };
+
+ await result.current.sendMultipleFilesMessage(messageParams);
+
+ expect(mockSendMultipleFilesMessage).toHaveBeenCalledWith(
+ messageParams,
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('updateUserMessage', () => {
+ it('updates user message', async () => {
+ const { result } = renderHook(() => useMessageActions(defaultParams));
+ const messageId = 1;
+ const updateParams = { message: 'updated message' };
+
+ await result.current.updateUserMessage(messageId, updateParams);
+
+ expect(mockUpdateUserMessage).toHaveBeenCalledWith(
+ messageId,
+ updateParams,
+ );
+ });
+
+ it('applies onBeforeUpdateUserMessage hook', async () => {
+ const onBeforeUpdateUserMessage = jest.fn((params) => ({
+ ...params,
+ message: `Modified: ${params.message}`,
+ }));
+
+ const paramsWithHook = {
+ ...defaultParams,
+ onBeforeUpdateUserMessage,
+ };
+
+ const { result } = renderHook(() => useMessageActions(paramsWithHook));
+ const messageId = 1;
+ const updateParams = { message: 'update test' };
+
+ await result.current.updateUserMessage(messageId, updateParams);
+
+ expect(onBeforeUpdateUserMessage).toHaveBeenCalledWith(updateParams);
+ expect(mockUpdateUserMessage).toHaveBeenCalledWith(
+ messageId,
+ {
+ message: 'Modified: update test',
+ },
+ );
+ });
+ });
+});
diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts
new file mode 100644
index 000000000..153f5a0a9
--- /dev/null
+++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts
@@ -0,0 +1,194 @@
+import { useContext, useMemo } from 'react';
+import type { GroupChannel } from '@sendbird/chat/groupChannel';
+import type { SendbirdError } from '@sendbird/chat';
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+
+import type {
+ FileMessage,
+ FileMessageCreateParams,
+ MultipleFilesMessage,
+ MultipleFilesMessageCreateParams,
+ UserMessage,
+ UserMessageCreateParams,
+ UserMessageUpdateParams,
+} from '@sendbird/chat/message';
+
+import { SendableMessageType } from '../../../../utils';
+import { getMessageTopOffset } from '../utils';
+import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
+import { GroupChannelContext } from '../GroupChannelProvider';
+import type { GroupChannelState, MessageActions } from '../types';
+import { useMessageActions } from './useMessageActions';
+
+export interface GroupChannelActions extends MessageActions {
+ // Channel actions
+ setCurrentChannel: (channel: GroupChannel) => void;
+ handleChannelError: (error: SendbirdError) => void;
+
+ // Message actions
+ sendUserMessage: (params: UserMessageCreateParams) => Promise;
+ sendFileMessage: (params: FileMessageCreateParams) => Promise;
+ sendMultipleFilesMessage: (params: MultipleFilesMessageCreateParams) => Promise;
+ updateUserMessage: (messageId: number, params: UserMessageUpdateParams) => Promise;
+
+ // UI actions
+ setQuoteMessage: (message: SendableMessageType | null) => void;
+ setAnimatedMessageId: (messageId: number | null) => void;
+ setIsScrollBottomReached: (isReached: boolean) => void;
+
+ // Scroll actions
+ scrollToBottom: (animated?: boolean) => Promise;
+ scrollToMessage: (
+ createdAt: number,
+ messageId: number,
+ messageFocusAnimated?: boolean,
+ scrollAnimated?: boolean
+ ) => Promise;
+
+ // Reaction action
+ toggleReaction: (message: SendableMessageType, emojiKey: string, isReacted: boolean) => void;
+}
+
+export const useGroupChannel = () => {
+ const store = useContext(GroupChannelContext);
+ if (!store) throw new Error('useGroupChannel must be used within a GroupChannelProvider');
+
+ const { config } = useSendbirdStateContext();
+ const { markAsReadScheduler } = config;
+ const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState);
+
+ const flagActions = {
+ setAnimatedMessageId: (messageId: number | null) => {
+ store.setState(state => ({ ...state, animatedMessageId: messageId }));
+ },
+
+ setIsScrollBottomReached: (isReached: boolean) => {
+ store.setState(state => ({ ...state, isScrollBottomReached: isReached }));
+ },
+ };
+
+ const scrollToBottom = async (animated?: boolean) => {
+ if (!state.scrollRef.current) return;
+
+ flagActions.setAnimatedMessageId(null);
+ flagActions.setIsScrollBottomReached(true);
+
+ if (config.isOnline && state.hasNext()) {
+ await state.resetWithStartingPoint(Number.MAX_SAFE_INTEGER);
+ state.scrollPubSub.publish('scrollToBottom', { animated });
+ } else {
+ state.scrollPubSub.publish('scrollToBottom', { animated });
+ }
+
+ if (state.currentChannel && !state.hasNext()) {
+ state.resetNewMessages();
+ if (!state.disableMarkAsRead) {
+ markAsReadScheduler.push(state.currentChannel);
+ }
+ }
+ };
+ const messageActions = useMessageActions({
+ ...state,
+ scrollToBottom,
+ });
+
+ const actions: GroupChannelActions = useMemo(() => ({
+ setCurrentChannel: (channel: GroupChannel) => {
+ store.setState(state => ({
+ ...state,
+ currentChannel: channel,
+ fetchChannelError: null,
+ quoteMessage: null,
+ animatedMessageId: null,
+ nicknamesMap: new Map(
+ channel.members.map(({ userId, nickname }) => [userId, nickname]),
+ ),
+ }));
+ },
+
+ handleChannelError: (error: SendbirdError) => {
+ store.setState(state => ({
+ ...state,
+ currentChannel: null,
+ fetchChannelError: error,
+ quoteMessage: null,
+ animatedMessageId: null,
+ }));
+ },
+
+ setQuoteMessage: (message: SendableMessageType | null) => {
+ store.setState(state => ({ ...state, quoteMessage: message }));
+ },
+
+ scrollToBottom,
+ scrollToMessage: async (
+ createdAt: number,
+ messageId: number,
+ messageFocusAnimated?: boolean,
+ scrollAnimated?: boolean,
+ ) => {
+ const element = state.scrollRef.current;
+ const parentNode = element?.parentNode as HTMLDivElement;
+ const clickHandler = {
+ activate() {
+ if (!element || !parentNode) return;
+ element.style.pointerEvents = 'auto';
+ parentNode.style.cursor = 'auto';
+ },
+ deactivate() {
+ if (!element || !parentNode) return;
+ element.style.pointerEvents = 'none';
+ parentNode.style.cursor = 'wait';
+ },
+ };
+
+ clickHandler.deactivate();
+
+ flagActions.setAnimatedMessageId(null);
+ const message = state.messages.find(
+ (it) => it.messageId === messageId || it.createdAt === createdAt,
+ );
+
+ if (message) {
+ const topOffset = getMessageTopOffset(message.createdAt);
+ if (topOffset) state.scrollPubSub.publish('scroll', { top: topOffset, animated: scrollAnimated });
+ if (messageFocusAnimated ?? true) flagActions.setAnimatedMessageId(messageId);
+ } else {
+ await state.resetWithStartingPoint(createdAt);
+ setTimeout(() => {
+ const topOffset = getMessageTopOffset(createdAt);
+ if (topOffset) {
+ state.scrollPubSub.publish('scroll', {
+ top: topOffset,
+ lazy: false,
+ animated: scrollAnimated,
+ });
+ }
+ if (messageFocusAnimated ?? true) flagActions.setAnimatedMessageId(messageId);
+ });
+ }
+
+ clickHandler.activate();
+ },
+
+ toggleReaction: (message: SendableMessageType, emojiKey: string, isReacted: boolean) => {
+ if (!state.currentChannel) return;
+
+ if (isReacted) {
+ state.currentChannel.deleteReaction(message, emojiKey)
+ .catch(error => {
+ config.logger?.warning('Failed to delete reaction:', error);
+ });
+ } else {
+ state.currentChannel.addReaction(message, emojiKey)
+ .catch(error => {
+ config.logger?.warning('Failed to add reaction:', error);
+ });
+ }
+ },
+ ...flagActions,
+ ...messageActions,
+ }), [store, state, config.isOnline, markAsReadScheduler]);
+
+ return { state, actions };
+};
diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts
index f6f6763a1..48d63412b 100644
--- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts
+++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts
@@ -19,9 +19,7 @@ import {
VOICE_MESSAGE_FILE_NAME,
VOICE_MESSAGE_MIME_TYPE,
} from '../../../../utils/consts';
-import type { SendableMessageType } from '../../../../utils';
-import type { ReplyType } from '../../../../types';
-import type { GroupChannelProviderProps } from '../GroupChannelProvider';
+import type { GroupChannelState } from '../types';
type MessageListDataSource = ReturnType;
type MessageActions = {
@@ -30,12 +28,10 @@ type MessageActions = {
sendVoiceMessage: (params: FileMessageCreateParams, duration: number) => Promise;
sendMultipleFilesMessage: (params: MultipleFilesMessageCreateParams) => Promise;
updateUserMessage: (messageId: number, params: UserMessageUpdateParams) => Promise;
-};
+} & Partial;
-interface Params extends GroupChannelProviderProps, MessageListDataSource {
+interface Params extends GroupChannelState {
scrollToBottom(animated?: boolean): Promise;
- quoteMessage?: SendableMessageType | null;
- replyType: ReplyType;
}
const pass = (value: T) => value;
@@ -55,6 +51,10 @@ export function useMessageActions(params: Params): MessageActions {
sendMultipleFilesMessage,
sendUserMessage,
updateUserMessage,
+ updateFileMessage,
+ resendMessage,
+ deleteMessage,
+ resetNewMessages,
scrollToBottom,
quoteMessage,
@@ -144,5 +144,9 @@ export function useMessageActions(params: Params): MessageActions {
},
[buildInternalMessageParams, updateUserMessage],
),
+ updateFileMessage,
+ resendMessage,
+ deleteMessage,
+ resetNewMessages,
};
}
diff --git a/src/modules/GroupChannel/context/hooks/useToggleReactionCallback.ts b/src/modules/GroupChannel/context/hooks/useToggleReactionCallback.ts
deleted file mode 100644
index e9cb60d55..000000000
--- a/src/modules/GroupChannel/context/hooks/useToggleReactionCallback.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useCallback } from 'react';
-import { GroupChannel } from '@sendbird/chat/groupChannel';
-import { LoggerInterface } from '../../../../lib/Logger';
-import { BaseMessage } from '@sendbird/chat/message';
-
-const LOG_PRESET = 'useToggleReactionCallback:';
-
-export default function useToggleReactionCallback(
- currentChannel: GroupChannel | null,
- logger?: LoggerInterface,
-) {
- return useCallback(
- (message: BaseMessage, key: string, isReacted: boolean) => {
- if (!currentChannel) {
- logger?.warning(`${LOG_PRESET} currentChannel doesn't exist`, currentChannel);
- return;
- }
- if (isReacted) {
- currentChannel
- .deleteReaction(message, key)
- .then((res) => {
- logger?.info(`${LOG_PRESET} Delete reaction success`, res);
- })
- .catch((err) => {
- logger?.warning(`${LOG_PRESET} Delete reaction failed`, err);
- });
- } else {
- currentChannel
- .addReaction(message, key)
- .then((res) => {
- logger?.info(`${LOG_PRESET} Add reaction success`, res);
- })
- .catch((err) => {
- logger?.warning(`${LOG_PRESET} Add reaction failed`, err);
- });
- }
- },
- [currentChannel],
- );
-}
diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts
new file mode 100644
index 000000000..d81b7fe85
--- /dev/null
+++ b/src/modules/GroupChannel/context/types.ts
@@ -0,0 +1,112 @@
+import type { EmojiCategory, SendbirdError, User } from '@sendbird/chat';
+import {
+ type FileMessage,
+ FileMessageCreateParams,
+ type MultipleFilesMessage,
+ MultipleFilesMessageCreateParams,
+ UserMessageCreateParams,
+ UserMessageUpdateParams,
+} from '@sendbird/chat/message';
+import type { GroupChannel, MessageCollectionParams, MessageFilterParams } from '@sendbird/chat/groupChannel';
+import type { PubSubTypes } from '../../../lib/pubSub';
+import type { ScrollTopics, ScrollTopicUnion } from './hooks/useMessageListScroll';
+import type { SendableMessageType } from '../../../utils';
+import type { UserProfileProviderProps } from '../../../lib/UserProfileContext';
+import { ReplyType } from '../../../types';
+import { useMessageActions } from './hooks/useMessageActions';
+import { useGroupChannelMessages } from '@sendbird/uikit-tools';
+import { ThreadReplySelectType } from './const';
+
+// Message data source types
+type MessageDataSource = ReturnType;
+export type MessageActions = ReturnType;
+export type MessageListQueryParamsType = Omit & MessageFilterParams;
+
+// Handler types
+type OnBeforeHandler = (params: T) => T | Promise;
+export type OnBeforeDownloadFileMessageType = (params: {
+ message: FileMessage | MultipleFilesMessage;
+ index?: number
+}) => Promise;
+
+// Include all the props and states
+export interface GroupChannelState extends GroupChannelProviderProps,
+ Omit {
+}
+// Only include the states
+interface InternalGroupChannelState extends MessageDataSource {
+ // Channel state
+ currentChannel: GroupChannel | null;
+ channelUrl: string;
+ fetchChannelError: SendbirdError | null;
+ nicknamesMap: Map;
+
+ // UI state
+ quoteMessage: SendableMessageType | null;
+ animatedMessageId: number | null;
+ isScrollBottomReached: boolean;
+
+ // References - will be managed together
+ scrollRef: React.RefObject;
+ scrollDistanceFromBottomRef: React.MutableRefObject;
+ scrollPositionRef: React.MutableRefObject;
+ messageInputRef: React.RefObject;
+
+ // Configuration
+ isReactionEnabled: boolean;
+ isMessageGroupingEnabled: boolean;
+ isMultipleFilesMessageEnabled: boolean;
+ showSearchIcon: boolean;
+ replyType: ReplyType;
+ threadReplySelectType: ThreadReplySelectType;
+ disableMarkAsRead: boolean;
+ scrollBehavior: 'smooth' | 'auto';
+
+ // Legacy - Will be removed after migration
+ scrollPubSub: PubSubTypes;
+}
+
+export interface GroupChannelProviderProps extends
+ Pick {
+ // Required
+ channelUrl: string;
+
+ // Flags
+ isReactionEnabled?: boolean;
+ isMessageGroupingEnabled?: boolean;
+ isMultipleFilesMessageEnabled?: boolean;
+ showSearchIcon?: boolean;
+ replyType?: ReplyType;
+ threadReplySelectType?: ThreadReplySelectType;
+ disableMarkAsRead?: boolean;
+ scrollBehavior?: 'smooth' | 'auto';
+ forceLeftToRightMessageLayout?: boolean;
+
+ startingPoint?: number;
+
+ // Message Focusing
+ animatedMessageId?: number | null;
+ onMessageAnimated?: () => void;
+
+ // Custom
+ messageListQueryParams?: MessageListQueryParamsType;
+ filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][];
+
+ // Handlers
+ onBeforeSendUserMessage?: OnBeforeHandler;
+ onBeforeSendFileMessage?: OnBeforeHandler;
+ onBeforeSendVoiceMessage?: OnBeforeHandler;
+ onBeforeSendMultipleFilesMessage?: OnBeforeHandler;
+ onBeforeUpdateUserMessage?: OnBeforeHandler;
+ onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType;
+
+ // Click handlers
+ onBackClick?(): void;
+ onChatHeaderActionClick?(event: React.MouseEvent): void;
+ onReplyInThreadClick?: (props: { message: SendableMessageType }) => void;
+ onSearchClick?(): void;
+ onQuoteMessageClick?: (props: { message: SendableMessageType }) => void;
+
+ // Render props
+ renderUserMentionItem?: (props: { user: User }) => JSX.Element;
+}
diff --git a/src/modules/GroupChannel/index.tsx b/src/modules/GroupChannel/index.tsx
index e062fbb34..aaf89a917 100644
--- a/src/modules/GroupChannel/index.tsx
+++ b/src/modules/GroupChannel/index.tsx
@@ -1,6 +1,7 @@
import React from 'react';
-import { GroupChannelProvider, GroupChannelProviderProps } from './context/GroupChannelProvider';
+import { GroupChannelProvider } from './context/GroupChannelProvider';
+import { type GroupChannelProviderProps } from './context/types';
import GroupChannelUI, { GroupChannelUIProps } from './components/GroupChannelUI';
export interface GroupChannelProps extends GroupChannelProviderProps, GroupChannelUIProps { }
diff --git a/src/modules/MessageSearch/context/MessageSearchProvider.tsx b/src/modules/MessageSearch/context/MessageSearchProvider.tsx
index a526f924c..8a45a19d6 100644
--- a/src/modules/MessageSearch/context/MessageSearchProvider.tsx
+++ b/src/modules/MessageSearch/context/MessageSearchProvider.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useRef, useContext, useCallback, useEffect } from 'react';
+import React, { createContext, useRef, useCallback, useEffect } from 'react';
import type { GroupChannel } from '@sendbird/chat/groupChannel';
import { MessageSearchQuery } from '@sendbird/chat/message';
import { ClientSentMessages } from '../../../types';
@@ -14,6 +14,7 @@ import useSearchStringEffect from './hooks/useSearchStringEffect';
import { CoreMessageType } from '../../../utils';
import { createStore } from '../../../utils/storeManager';
import { useStore } from '../../../hooks/useStore';
+import useMessageSearch from './hooks/useMessageSearch';
export interface MessageSearchProviderProps {
channelUrl: string;
@@ -162,10 +163,12 @@ const MessageSearchProvider: React.FC = ({
);
};
+/**
+ * Keep this function for backward compatibility.
+ */
const useMessageSearchContext = () => {
- const context = useContext(MessageSearchContext);
- if (!context) throw new Error('MessageSearchContext not found. Use within the MessageSearch module.');
- return context;
+ const { state, actions } = useMessageSearch();
+ return { ...state, ...actions };
};
export {
diff --git a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx
index 1f7df34d1..ecd1df693 100644
--- a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx
+++ b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx
@@ -3,7 +3,7 @@ import { waitFor, act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { MessageSearchQuery } from '@sendbird/chat/message';
-import { MessageSearchProvider, useMessageSearchContext } from '../MessageSearchProvider';
+import { MessageSearchProvider } from '../MessageSearchProvider';
import useMessageSearch from '../hooks/useMessageSearch';
jest.mock('../../../../hooks/useSendbirdStateContext', () => ({
@@ -71,28 +71,38 @@ describe('MessageSearchProvider', () => {
);
- const { result } = renderHook(() => useMessageSearchContext(), { wrapper });
+ const { result } = renderHook(() => useMessageSearch(), { wrapper });
- expect(result.current.getState()).toEqual(expect.objectContaining(initialState));
+ expect(result.current.state).toEqual(expect.objectContaining(initialState));
});
it('updates state correctly when props change', async () => {
- const wrapper = ({ children }) => (
-
+ const initialUrl = 'test-channel';
+ const newUrl = 'new-channel';
+
+ const wrapper = ({ children, channelUrl }) => (
+
{children}
);
- const { result } = renderHook(() => useMessageSearchContext(), { wrapper });
+ const { result, rerender } = renderHook(
+ () => useMessageSearch(),
+ {
+ wrapper,
+ initialProps: { channelUrl: initialUrl, children: null },
+ },
+ );
- expect(result.current.getState().channelUrl).toBe('test-channel');
+ expect(result.current.state.channelUrl).toBe(initialUrl);
await act(async () => {
- result.current.setState({ channelUrl: 'new-channel' });
+ rerender({ channelUrl: newUrl, children: null });
+
await waitFor(() => {
- const newState = result.current.getState();
- expect(newState.channelUrl).toBe('new-channel');
// Verify other states remain unchanged
+ const newState = result.current.state;
+ expect(newState.channelUrl).toBe(newUrl);
expect(newState.allMessages).toEqual(initialState.allMessages);
expect(newState.loading).toBe(initialState.loading);
expect(newState.isQueryInvalid).toBe(initialState.isQueryInvalid);
diff --git a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx
index 97bb7fb06..3b55d524b 100644
--- a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx
+++ b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx
@@ -37,7 +37,7 @@ import { useMediaQueryContext } from '../../../../lib/MediaQueryContext';
import { useThreadMessageKindKeySelector } from '../../../Channel/context/hooks/useThreadMessageKindKeySelector';
import { useFileInfoListWithUploaded } from '../../../Channel/context/hooks/useFileInfoListWithUploaded';
import { Colors } from '../../../../utils/color';
-import type { OnBeforeDownloadFileMessageType } from '../../../GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../../GroupChannel/context/types';
import { openURL } from '../../../../utils/utils';
export interface ParentMessageInfoItemProps {
diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx
index a72a0d33f..28c727c4c 100644
--- a/src/modules/Thread/context/ThreadProvider.tsx
+++ b/src/modules/Thread/context/ThreadProvider.tsx
@@ -17,7 +17,7 @@ import threadReducer from './dux/reducer';
import { ThreadContextActionTypes } from './dux/actionTypes';
import threadInitialState, { ThreadContextInitialState } from './dux/initialState';
-import type { OnBeforeDownloadFileMessageType } from '../../GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../GroupChannel/context/types';
import useGetChannel from './hooks/useGetChannel';
import useGetAllEmoji from './hooks/useGetAllEmoji';
import useGetParentMessage from './hooks/useGetParentMessage';
diff --git a/src/ui/FileMessageItemBody/index.tsx b/src/ui/FileMessageItemBody/index.tsx
index 8290d5748..47e784cda 100644
--- a/src/ui/FileMessageItemBody/index.tsx
+++ b/src/ui/FileMessageItemBody/index.tsx
@@ -9,7 +9,7 @@ import { getClassName, getUIKitFileType, truncateString } from '../../utils';
import { Colors } from '../../utils/color';
import { useMediaQueryContext } from '../../lib/MediaQueryContext';
import useSendbirdStateContext from '../../hooks/useSendbirdStateContext';
-import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/types';
import { LoggerInterface } from '../../lib/Logger';
import { openURL } from '../../utils/utils';
diff --git a/src/ui/MessageContent/MessageBody/index.tsx b/src/ui/MessageContent/MessageBody/index.tsx
index 7b090e586..bbc679144 100644
--- a/src/ui/MessageContent/MessageBody/index.tsx
+++ b/src/ui/MessageContent/MessageBody/index.tsx
@@ -21,7 +21,7 @@ import { Nullable, SendbirdTheme } from '../../../types';
import { GroupChannel } from '@sendbird/chat/groupChannel';
import { match } from 'ts-pattern';
import TemplateMessageItemBody from '../../TemplateMessageItemBody';
-import type { OnBeforeDownloadFileMessageType } from '../../../modules/GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../../modules/GroupChannel/context/types';
import FormMessageItemBody from '../../FormMessageItemBody';
import { MESSAGE_TEMPLATE_KEY } from '../../../utils/consts';
diff --git a/src/ui/MessageContent/index.tsx b/src/ui/MessageContent/index.tsx
index 433ad1cbc..2146aa956 100644
--- a/src/ui/MessageContent/index.tsx
+++ b/src/ui/MessageContent/index.tsx
@@ -11,7 +11,7 @@ import EmojiReactions, { EmojiReactionsProps } from '../EmojiReactions';
import AdminMessage from '../AdminMessage';
import QuoteMessage from '../QuoteMessage';
-import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/types';
import {
CoreMessageType,
getClassName,
diff --git a/src/ui/MultipleFilesMessageItemBody/index.tsx b/src/ui/MultipleFilesMessageItemBody/index.tsx
index c94b673c4..2ce03436e 100644
--- a/src/ui/MultipleFilesMessageItemBody/index.tsx
+++ b/src/ui/MultipleFilesMessageItemBody/index.tsx
@@ -1,7 +1,7 @@
import React, { ReactElement, useState } from 'react';
import { MultipleFilesMessage, SendingStatus } from '@sendbird/chat/message';
-import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/GroupChannelProvider';
+import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/types';
import useSendbirdStateContext from '../../hooks/useSendbirdStateContext';
import Icon, { IconColors, IconTypes } from '../Icon';
import ImageRenderer, { getBorderRadiusForMultipleImageRenderer } from '../ImageRenderer';