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';